feat: add material trace page for bidirectional LOT/material query
Implement full-stack material trace feature enabling forward (LOT/工單 → 原物料) and reverse (原物料 → LOT) queries with wildcard support, safeguards (memory guard, IN-clause batching, Oracle slow-query channel), CSV export, and portal-shell integration. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -99,6 +99,13 @@
|
||||
"drawer_id": "drawer",
|
||||
"order": 3
|
||||
},
|
||||
{
|
||||
"route": "/material-trace",
|
||||
"name": "原物料追溯查詢",
|
||||
"status": "released",
|
||||
"drawer_id": "drawer",
|
||||
"order": 4
|
||||
},
|
||||
{
|
||||
"route": "/admin/pages",
|
||||
"name": "頁面管理",
|
||||
|
||||
@@ -191,6 +191,18 @@
|
||||
"canonical_shell_path": "/portal-shell/mid-section-defect",
|
||||
"rollback_strategy": "fallback_to_legacy_route",
|
||||
"compatibility_policy": "redirect_to_shell_when_spa_enabled"
|
||||
},
|
||||
{
|
||||
"route": "/material-trace",
|
||||
"route_id": "material-trace",
|
||||
"title": "Material Trace",
|
||||
"scope": "in-scope",
|
||||
"render_mode": "native",
|
||||
"owner": "frontend-mes-reporting",
|
||||
"visibility_policy": "released_or_admin",
|
||||
"canonical_shell_path": "/portal-shell/material-trace",
|
||||
"rollback_strategy": "fallback_to_legacy_route",
|
||||
"compatibility_policy": "redirect_to_shell_when_spa_enabled"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
438
frontend/src/material-trace/App.vue
Normal file
438
frontend/src/material-trace/App.vue
Normal file
@@ -0,0 +1,438 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
|
||||
import { apiGet, apiPost } from '../core/api.js';
|
||||
import { parseMultiLineInput } from '../core/reject-history-filters.js';
|
||||
|
||||
const API_TIMEOUT = 60000;
|
||||
const DEFAULT_PER_PAGE = 50;
|
||||
const FORWARD_INPUT_LIMIT = 200;
|
||||
const REVERSE_INPUT_LIMIT = 50;
|
||||
|
||||
// ---- Query mode state ----
|
||||
const queryMode = ref('forward'); // 'forward' | 'reverse'
|
||||
const forwardInputType = ref('lot'); // 'lot' | 'workorder'
|
||||
const inputText = ref('');
|
||||
|
||||
// ---- Filter state ----
|
||||
const workcenterGroupOptions = ref([]);
|
||||
const selectedWorkcenterGroups = ref([]);
|
||||
const workcenterDropdownOpen = ref(false);
|
||||
const workcenterSearch = ref('');
|
||||
|
||||
// ---- Result state ----
|
||||
const rows = ref([]);
|
||||
const pagination = ref({ page: 1, per_page: DEFAULT_PER_PAGE, total: 0, total_pages: 0 });
|
||||
const loading = ref(false);
|
||||
const errorMessage = ref('');
|
||||
const warningMessage = ref('');
|
||||
const unresolvedWarning = ref('');
|
||||
|
||||
// ---- Computed ----
|
||||
const parsedValues = computed(() => parseMultiLineInput(inputText.value));
|
||||
const inputCount = computed(() => parsedValues.value.length);
|
||||
const currentInputLimit = computed(() =>
|
||||
queryMode.value === 'forward' ? FORWARD_INPUT_LIMIT : REVERSE_INPUT_LIMIT,
|
||||
);
|
||||
const isOverLimit = computed(() => inputCount.value > currentInputLimit.value);
|
||||
const hasResults = computed(() => rows.value.length > 0);
|
||||
const canQuery = computed(() => inputCount.value > 0 && !isOverLimit.value && !loading.value);
|
||||
const canExport = computed(() => hasResults.value && !loading.value);
|
||||
|
||||
const queryModeForApi = computed(() => {
|
||||
if (queryMode.value === 'reverse') return 'material_lot';
|
||||
return forwardInputType.value; // 'lot' or 'workorder'
|
||||
});
|
||||
|
||||
const filteredWorkcenterOptions = computed(() => {
|
||||
const q = workcenterSearch.value.toLowerCase().trim();
|
||||
if (!q) return workcenterGroupOptions.value;
|
||||
return workcenterGroupOptions.value.filter((g) => g.toLowerCase().includes(q));
|
||||
});
|
||||
|
||||
const workcenterTriggerText = computed(() => {
|
||||
if (selectedWorkcenterGroups.value.length === 0) return '全部站點';
|
||||
if (selectedWorkcenterGroups.value.length === 1) return selectedWorkcenterGroups.value[0];
|
||||
return `已選 ${selectedWorkcenterGroups.value.length} 個站群組`;
|
||||
});
|
||||
|
||||
// ---- Table columns ----
|
||||
const TABLE_COLUMNS = [
|
||||
{ key: 'CONTAINERNAME', label: 'LOT ID' },
|
||||
{ key: 'PJ_WORKORDER', label: '工單' },
|
||||
{ key: 'WORKCENTER_GROUP', label: '站群組' },
|
||||
{ key: 'WORKCENTERNAME', label: '站點' },
|
||||
{ key: 'MATERIALPARTNAME', label: '料號' },
|
||||
{ key: 'MATERIALLOTNAME', label: '物料批號' },
|
||||
{ key: 'VENDORLOTNUMBER', label: '供應商批號' },
|
||||
{ key: 'QTYREQUIRED', label: '應領量' },
|
||||
{ key: 'QTYCONSUMED', label: '實際消耗' },
|
||||
{ key: 'EQUIPMENTNAME', label: '機台' },
|
||||
{ key: 'TXNDATE', label: '交易日期' },
|
||||
{ key: 'PRIMARY_CATEGORY', label: '主分類' },
|
||||
{ key: 'SECONDARY_CATEGORY', label: '副分類' },
|
||||
];
|
||||
|
||||
// ---- Mode switching ----
|
||||
function switchQueryMode(mode) {
|
||||
if (queryMode.value === mode) return;
|
||||
queryMode.value = mode;
|
||||
clearAll();
|
||||
}
|
||||
|
||||
function switchForwardInputType(type) {
|
||||
if (forwardInputType.value === type) return;
|
||||
forwardInputType.value = type;
|
||||
inputText.value = '';
|
||||
clearResults();
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
inputText.value = '';
|
||||
clearResults();
|
||||
}
|
||||
|
||||
function clearResults() {
|
||||
rows.value = [];
|
||||
pagination.value = { page: 1, per_page: DEFAULT_PER_PAGE, total: 0, total_pages: 0 };
|
||||
errorMessage.value = '';
|
||||
warningMessage.value = '';
|
||||
unresolvedWarning.value = '';
|
||||
}
|
||||
|
||||
// ---- Workcenter multi-select ----
|
||||
function toggleWorkcenterGroup(group) {
|
||||
const idx = selectedWorkcenterGroups.value.indexOf(group);
|
||||
if (idx >= 0) {
|
||||
selectedWorkcenterGroups.value.splice(idx, 1);
|
||||
} else {
|
||||
selectedWorkcenterGroups.value.push(group);
|
||||
}
|
||||
}
|
||||
|
||||
function selectAllWorkcenterGroups() {
|
||||
selectedWorkcenterGroups.value = [...workcenterGroupOptions.value];
|
||||
}
|
||||
|
||||
function clearWorkcenterGroups() {
|
||||
selectedWorkcenterGroups.value = [];
|
||||
}
|
||||
|
||||
// ---- API calls ----
|
||||
async function loadFilterOptions() {
|
||||
try {
|
||||
const res = await apiGet('/api/material-trace/filter-options', { timeout: API_TIMEOUT });
|
||||
if (res.success && res.data?.workcenter_groups) {
|
||||
workcenterGroupOptions.value = res.data.workcenter_groups;
|
||||
}
|
||||
} catch {
|
||||
// Silently ignore — filter options will be empty
|
||||
}
|
||||
}
|
||||
|
||||
async function executePrimaryQuery(page = 1) {
|
||||
if (!canQuery.value) return;
|
||||
|
||||
errorMessage.value = '';
|
||||
warningMessage.value = '';
|
||||
unresolvedWarning.value = '';
|
||||
loading.value = true;
|
||||
|
||||
const body = {
|
||||
mode: queryModeForApi.value,
|
||||
values: parsedValues.value,
|
||||
page,
|
||||
per_page: DEFAULT_PER_PAGE,
|
||||
};
|
||||
if (selectedWorkcenterGroups.value.length > 0) {
|
||||
body.workcenter_groups = selectedWorkcenterGroups.value;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await apiPost('/api/material-trace/query', body, { timeout: API_TIMEOUT });
|
||||
|
||||
if (!result.success) {
|
||||
errorMessage.value = result.error || '查詢失敗';
|
||||
rows.value = [];
|
||||
pagination.value = { page: 1, per_page: DEFAULT_PER_PAGE, total: 0, total_pages: 0 };
|
||||
return;
|
||||
}
|
||||
|
||||
rows.value = result.rows || [];
|
||||
pagination.value = result.pagination || {
|
||||
page: 1,
|
||||
per_page: DEFAULT_PER_PAGE,
|
||||
total: 0,
|
||||
total_pages: 0,
|
||||
};
|
||||
|
||||
// Handle meta warnings
|
||||
if (result.meta?.unresolved?.length > 0) {
|
||||
unresolvedWarning.value = `以下 LOT ID 無法解析:${result.meta.unresolved.join('、')}`;
|
||||
}
|
||||
if (result.meta?.truncated) {
|
||||
warningMessage.value = `查詢結果超過 ${result.meta.max_rows?.toLocaleString() || '10,000'} 筆上限,請縮小查詢範圍`;
|
||||
}
|
||||
} catch (err) {
|
||||
errorMessage.value = err.message || '查詢失敗,請稍後再試';
|
||||
rows.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function goToPage(page) {
|
||||
executePrimaryQuery(page);
|
||||
}
|
||||
|
||||
async function exportCsv() {
|
||||
if (!canExport.value) return;
|
||||
|
||||
const body = {
|
||||
mode: queryModeForApi.value,
|
||||
values: parsedValues.value,
|
||||
};
|
||||
if (selectedWorkcenterGroups.value.length > 0) {
|
||||
body.workcenter_groups = selectedWorkcenterGroups.value;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/material-trace/export', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
errorMessage.value = data.error || '匯出失敗';
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'material_trace.csv';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
errorMessage.value = err.message || '匯出失敗,請稍後再試';
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Lifecycle ----
|
||||
onMounted(() => {
|
||||
loadFilterOptions();
|
||||
});
|
||||
|
||||
// Close dropdown on click outside
|
||||
function onDocumentClick(e) {
|
||||
if (!e.target.closest('.multi-select')) {
|
||||
workcenterDropdownOpen.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dashboard" @click="onDocumentClick">
|
||||
<!-- Header -->
|
||||
<div class="header material-trace-header">
|
||||
<div class="header-left">
|
||||
<h1>原物料追溯查詢</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error / Warning Banners -->
|
||||
<div v-if="errorMessage" class="error-banner">{{ errorMessage }}</div>
|
||||
<div v-if="unresolvedWarning" class="warning-banner">{{ unresolvedWarning }}</div>
|
||||
<div v-if="warningMessage" class="warning-banner">{{ warningMessage }}</div>
|
||||
|
||||
<!-- Query Card -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">查詢條件</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- Mode tabs -->
|
||||
<div class="mode-tab-row" style="margin-bottom: 14px">
|
||||
<button
|
||||
class="mode-tab"
|
||||
:class="{ active: queryMode === 'forward' }"
|
||||
@click="switchQueryMode('forward')"
|
||||
>
|
||||
正向查詢:LOT/工單 → 原物料
|
||||
</button>
|
||||
<button
|
||||
class="mode-tab"
|
||||
:class="{ active: queryMode === 'reverse' }"
|
||||
@click="switchQueryMode('reverse')"
|
||||
>
|
||||
反向查詢:原物料 → LOT
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="filter-panel">
|
||||
<!-- Forward: input type selector -->
|
||||
<div v-if="queryMode === 'forward'" class="filter-group">
|
||||
<label class="filter-label">輸入類型</label>
|
||||
<div class="input-type-row">
|
||||
<select
|
||||
class="filter-input input-type-select"
|
||||
:value="forwardInputType"
|
||||
@change="switchForwardInputType($event.target.value)"
|
||||
>
|
||||
<option value="lot">LOT ID</option>
|
||||
<option value="workorder">工單</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Workcenter group filter -->
|
||||
<div class="filter-group">
|
||||
<label class="filter-label">站群組篩選</label>
|
||||
<div class="multi-select" @click.stop>
|
||||
<button
|
||||
class="multi-select-trigger"
|
||||
@click="workcenterDropdownOpen = !workcenterDropdownOpen"
|
||||
>
|
||||
<span class="multi-select-text">{{ workcenterTriggerText }}</span>
|
||||
<span class="multi-select-arrow">▾</span>
|
||||
</button>
|
||||
<div v-if="workcenterDropdownOpen" class="multi-select-dropdown">
|
||||
<input
|
||||
v-model="workcenterSearch"
|
||||
class="multi-select-search"
|
||||
placeholder="搜尋站群組..."
|
||||
/>
|
||||
<div class="multi-select-options">
|
||||
<button
|
||||
v-for="group in filteredWorkcenterOptions"
|
||||
:key="group"
|
||||
class="multi-select-option"
|
||||
@click="toggleWorkcenterGroup(group)"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selectedWorkcenterGroups.includes(group)"
|
||||
tabindex="-1"
|
||||
/>
|
||||
{{ group }}
|
||||
</button>
|
||||
<div v-if="filteredWorkcenterOptions.length === 0" class="multi-select-empty">
|
||||
無符合的站群組
|
||||
</div>
|
||||
</div>
|
||||
<div class="multi-select-actions">
|
||||
<button class="btn-sm" @click="selectAllWorkcenterGroups">全選</button>
|
||||
<button class="btn-sm" @click="clearWorkcenterGroups">清除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Textarea input -->
|
||||
<div class="filter-group filter-group-full">
|
||||
<label class="filter-label">
|
||||
{{
|
||||
queryMode === 'forward'
|
||||
? forwardInputType === 'lot'
|
||||
? 'LOT ID(每行一筆或逗號分隔,支援萬用字元 * )'
|
||||
: '工單號碼(每行一筆或逗號分隔,支援萬用字元 * )'
|
||||
: '原物料批號(每行一筆或逗號分隔,支援萬用字元 * )'
|
||||
}}
|
||||
</label>
|
||||
<textarea
|
||||
v-model="inputText"
|
||||
class="filter-input filter-textarea"
|
||||
rows="5"
|
||||
:placeholder="
|
||||
queryMode === 'forward'
|
||||
? forwardInputType === 'lot'
|
||||
? 'GA25060001-A01\nGA250605*\n...'
|
||||
: 'WO-2025-001\nWO-2025*\n...'
|
||||
: 'WIRE-LOT-20250101-A\nSLD-LOT-2025*\n...'
|
||||
"
|
||||
></textarea>
|
||||
<div class="input-count" :class="{ 'over-limit': isOverLimit }">
|
||||
已輸入 {{ inputCount }} 筆
|
||||
<template v-if="isOverLimit">
|
||||
(超過上限 {{ currentInputLimit }} 筆)
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Buttons -->
|
||||
<div class="filter-toolbar">
|
||||
<div class="filter-actions">
|
||||
<button class="btn btn-primary" :disabled="!canQuery" @click="executePrimaryQuery()">
|
||||
<span v-if="loading" class="btn-spinner"></span>
|
||||
查詢
|
||||
</button>
|
||||
<button class="btn btn-secondary" @click="clearAll">清除</button>
|
||||
<button class="btn btn-export" :disabled="!canExport" @click="exportCsv">
|
||||
匯出 CSV
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Result Card -->
|
||||
<div v-if="hasResults || loading" class="card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">
|
||||
查詢結果
|
||||
<template v-if="pagination.total > 0">
|
||||
(共 {{ pagination.total.toLocaleString() }} 筆)
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- Loading overlay -->
|
||||
<div class="detail-table-wrap" :class="{ 'is-loading': loading }">
|
||||
<div v-if="loading" class="table-loading-overlay">
|
||||
<span class="table-spinner"></span>
|
||||
</div>
|
||||
<table class="detail-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-for="col in TABLE_COLUMNS" :key="col.key">{{ col.label }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, idx) in rows" :key="idx">
|
||||
<td v-for="col in TABLE_COLUMNS" :key="col.key">{{ row[col.key] ?? '' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Empty message -->
|
||||
<div v-if="!loading && rows.length === 0 && pagination.total === 0" class="empty-message">
|
||||
查無資料
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div v-if="pagination.total_pages > 1" class="pagination-bar">
|
||||
<div class="pagination-info">
|
||||
第 {{ pagination.page }} / {{ pagination.total_pages }} 頁,共
|
||||
{{ pagination.total.toLocaleString() }} 筆
|
||||
</div>
|
||||
<div class="pagination-actions">
|
||||
<button :disabled="pagination.page <= 1" @click="goToPage(pagination.page - 1)">
|
||||
上一頁
|
||||
</button>
|
||||
<button
|
||||
:disabled="pagination.page >= pagination.total_pages"
|
||||
@click="goToPage(pagination.page + 1)"
|
||||
>
|
||||
下一頁
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
12
frontend/src/material-trace/index.html
Normal file
12
frontend/src/material-trace/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-Hant">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>原物料追溯查詢</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="./main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
7
frontend/src/material-trace/main.js
Normal file
7
frontend/src/material-trace/main.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import { createApp } from 'vue';
|
||||
|
||||
import App from './App.vue';
|
||||
import '../wip-shared/styles.css';
|
||||
import './style.css';
|
||||
|
||||
createApp(App).mount('#app');
|
||||
444
frontend/src/material-trace/style.css
Normal file
444
frontend/src/material-trace/style.css
Normal file
@@ -0,0 +1,444 @@
|
||||
.material-trace-header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.mode-tab-row {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.mode-tab {
|
||||
padding: 7px 16px;
|
||||
border: none;
|
||||
background: #f8fafc;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.mode-tab:not(:last-child) {
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.mode-tab.active {
|
||||
background: #667eea;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.mode-tab:hover:not(.active) {
|
||||
background: #eef2f7;
|
||||
}
|
||||
|
||||
.input-type-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.input-type-select {
|
||||
width: auto;
|
||||
min-width: 120px;
|
||||
max-width: 180px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.filter-textarea {
|
||||
resize: vertical;
|
||||
font-family: monospace;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.input-count {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.input-count.over-limit {
|
||||
color: #dc2626;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--card-bg);
|
||||
border-radius: 10px;
|
||||
box-shadow: var(--shadow);
|
||||
overflow: visible;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: #f8fafc;
|
||||
border-radius: 10px 10px 0 0;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
margin-bottom: 14px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
background: #fef2f2;
|
||||
color: #991b1b;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.warning-banner {
|
||||
margin-bottom: 14px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
background: #fffbeb;
|
||||
color: #b45309;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.filter-panel {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 14px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filter-group-full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.filter-input {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.filter-input:focus {
|
||||
outline: none;
|
||||
border-color: #0ea5e9;
|
||||
box-shadow: 0 0 0 2px rgba(14, 165, 233, 0.18);
|
||||
}
|
||||
|
||||
.filter-toolbar {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.detail-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.detail-table th,
|
||||
.detail-table td {
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 8px 10px;
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.detail-table thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: #f8fafc;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.detail-table tbody tr:hover {
|
||||
background: #f8fbff;
|
||||
}
|
||||
|
||||
.detail-table-wrap {
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
max-height: 70vh;
|
||||
}
|
||||
|
||||
.detail-table-wrap.is-loading table {
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.table-loading-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.table-spinner {
|
||||
display: block;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 3px solid #d1d5db;
|
||||
border-top-color: #667eea;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
.btn-spinner {
|
||||
display: inline-block;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.4);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
margin-right: 6px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.pagination-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 0;
|
||||
font-size: 13px;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.pagination-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pagination-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pagination-actions button {
|
||||
padding: 5px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
color: #334155;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pagination-actions button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.pagination-actions button:hover:not(:disabled) {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.btn.btn-export {
|
||||
background: #667eea;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn.btn-export:hover {
|
||||
background: #5568d3;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn.btn-export:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.empty-message {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
color: #94a3b8;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Multi-select (copied from reject-history for standalone use) */
|
||||
|
||||
.multi-select {
|
||||
position: relative;
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.multi-select-trigger {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
font-size: 13px;
|
||||
color: #1f2937;
|
||||
background: #ffffff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.multi-select-trigger:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.multi-select-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.multi-select-arrow {
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.multi-select-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 20;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 12px 24px rgba(15, 23, 42, 0.14);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.multi-select-search {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
color: #1f2937;
|
||||
outline: none;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.multi-select-search::placeholder {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.multi-select-options {
|
||||
max-height: 250px;
|
||||
overflow-y: auto;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.multi-select-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 6px 12px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
color: #334155;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.multi-select-option:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.multi-select-option input[type='checkbox'] {
|
||||
margin: 0;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
accent-color: #667eea;
|
||||
}
|
||||
|
||||
.multi-select-empty {
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
color: #94a3b8;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.multi-select-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: #f8fafc;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.btn-sm:hover {
|
||||
border-color: #c2d0e0;
|
||||
background: #eef4fb;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.filter-panel {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.filter-group-full {
|
||||
grid-column: span 1;
|
||||
}
|
||||
|
||||
.filter-toolbar {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,10 @@ const NATIVE_MODULE_LOADERS = Object.freeze({
|
||||
() => import('../mid-section-defect/App.vue'),
|
||||
[() => import('../mid-section-defect/style.css')],
|
||||
),
|
||||
'/material-trace': createNativeLoader(
|
||||
() => import('../material-trace/App.vue'),
|
||||
[() => import('../wip-shared/styles.css'), () => import('../material-trace/style.css')],
|
||||
),
|
||||
'/admin/performance': createNativeLoader(
|
||||
() => import('../admin-performance/App.vue'),
|
||||
[() => import('../admin-performance/style.css')],
|
||||
|
||||
@@ -13,6 +13,7 @@ const IN_SCOPE_REPORT_ROUTES = Object.freeze([
|
||||
'/excel-query',
|
||||
'/query-tool',
|
||||
'/mid-section-defect',
|
||||
'/material-trace',
|
||||
]);
|
||||
|
||||
const IN_SCOPE_ADMIN_ROUTES = Object.freeze([
|
||||
@@ -230,6 +231,17 @@ const ROUTE_CONTRACTS = Object.freeze({
|
||||
scope: 'in-scope',
|
||||
compatibilityPolicy: 'redirect_to_shell_when_spa_enabled',
|
||||
}),
|
||||
'/material-trace': buildContract({
|
||||
route: '/material-trace',
|
||||
routeId: 'material-trace',
|
||||
renderMode: 'native',
|
||||
owner: 'frontend-mes-reporting',
|
||||
title: '原物料追溯查詢',
|
||||
rollbackStrategy: 'fallback_to_legacy_route',
|
||||
visibilityPolicy: 'released_or_admin',
|
||||
scope: 'in-scope',
|
||||
compatibilityPolicy: 'redirect_to_shell_when_spa_enabled',
|
||||
}),
|
||||
});
|
||||
|
||||
const REQUIRED_FIELDS = Object.freeze([
|
||||
|
||||
@@ -28,7 +28,8 @@ export default defineConfig(({ mode }) => ({
|
||||
'query-tool': resolve(__dirname, 'src/query-tool/main.js'),
|
||||
'qc-gate': resolve(__dirname, 'src/qc-gate/index.html'),
|
||||
'mid-section-defect': resolve(__dirname, 'src/mid-section-defect/index.html'),
|
||||
'admin-performance': resolve(__dirname, 'src/admin-performance/index.html')
|
||||
'admin-performance': resolve(__dirname, 'src/admin-performance/index.html'),
|
||||
'material-trace': resolve(__dirname, 'src/material-trace/index.html')
|
||||
},
|
||||
output: {
|
||||
entryFileNames: '[name].js',
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-03-03
|
||||
@@ -0,0 +1,78 @@
|
||||
## Context
|
||||
|
||||
工程師需要查詢 LOT/工單對應的原物料消耗記錄,以及反向從原物料批號追溯使用該批料的所有 LOT。目前原物料資訊只能在 Query Tool 的 LotDetail "原物料" tab 逐筆查看(透過 `/api/trace/events?domains=["materials"]`),不支援批量輸入或反向查詢。
|
||||
|
||||
資料來源 `DWH.DW_MES_LOTMATERIALSHISTORY` 有 1800 萬筆記錄,已建立四個索引:
|
||||
- IDX1: `CONTAINERID`(正向 LOT 查詢)
|
||||
- IDX2: `PJ_WORKORDER`(正向工單查詢)
|
||||
- IDX3: `MATERIALPARTNAME`(料號,本次不使用)
|
||||
- IDX4: `MATERIALLOTNAME`(反向原物料批號查詢)
|
||||
|
||||
站群組(WORKCENTER_GROUP)對應由 `filter_cache.get_workcenter_mapping()` 提供,從 `DW_MES_SPEC_WORKCENTER_V` 載入,每小時刷新。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- 提供獨立頁面,支援正向(LOT ID / 工單 → 原物料)和反向(原物料批號 → LOT)雙向查詢
|
||||
- 正向查詢支援 LOT ID 和工單兩種輸入模式切換
|
||||
- 支援多筆輸入(換行/逗號分隔)
|
||||
- 結果含站群組篩選、分頁、CSV 匯出
|
||||
- 使用既有 Oracle 索引,查詢效率可控
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- 不支援 MATERIALPARTNAME(料號)反向查詢(資料量風險過高,同一料號可能數萬筆)
|
||||
- 不需日期範圍篩選(以 LOT/工單/原物料批號為查詢條件即可)
|
||||
- 不做 Redis 快取或 BatchQueryEngine 分片(查詢範圍由輸入筆數控制,非時間範圍)
|
||||
- 不做 BOM 對照或原物料品質統計
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1: 使用 `read_sql_df`(pooled connection)而非 `read_sql_df_slow`
|
||||
|
||||
**決定**: 查詢使用 pooled connection(`read_sql_df`),不走 slow query path。
|
||||
|
||||
**理由**: 此查詢依賴索引命中,預期回應時間 < 5s。不像 reject-history 的全表掃描需要 dedicated connection。正向查詢最多幾千筆結果,反向查詢設結果上限 10,000 筆。
|
||||
|
||||
**替代方案**: 使用 `read_sql_df_slow`。
|
||||
**為何不採用**: 佔用 slow query semaphore 會排擠需要長時間執行的查詢(reject-history、resource-history)。
|
||||
|
||||
### D2: 正向查詢先解析 LOT ID → CONTAINERID
|
||||
|
||||
**決定**: LOT ID 輸入模式需要先將 CONTAINERNAME 轉換為 CONTAINERID(16-char hex),因為 `LOTMATERIALSHISTORY` 的索引是 CONTAINERID。使用 `DW_MES_CONTAINER` 做 batch lookup。工單模式直接查 `PJ_WORKORDER` 索引,不需轉換。
|
||||
|
||||
**理由**: 使用者輸入的是可讀的 LOT 名稱(如 GA25060001-A01),但資料表索引是 CONTAINERID。直接 JOIN 會讓 optimizer 可能選擇低效計畫。先 batch resolve 再用 IN clause 更可預測。
|
||||
|
||||
**替代方案**: SQL 內直接 JOIN CONTAINER 表。
|
||||
**為何不採用**: 對於多筆 LOT 輸入,兩步驟(resolve + query)的執行計畫更穩定,且 resolve 結果可重用於顯示。
|
||||
|
||||
### D3: 站群組篩選在後端 enrichment 而非 SQL WHERE
|
||||
|
||||
**決定**: SQL 查詢不加 WORKCENTERNAME 過濾。查詢結果回來後,後端用 `get_workcenter_mapping()` 對每列添加 `WORKCENTER_GROUP` 欄位,前端可做篩選。若使用者選了站群組篩選,後端先 resolve 站群組 → WORKCENTERNAME 清單,再在 SQL 加 `AND WORKCENTERNAME IN (...)` 過濾。
|
||||
|
||||
**理由**: 如果不篩選,使用者能看到所有站點的資料(含站群組欄位)。如果篩選了,SQL 層就縮減結果集,減少傳輸和分頁壓力。
|
||||
|
||||
### D4: 反向查詢結果數上限 10,000 筆
|
||||
|
||||
**決定**: 反向查詢(原物料批號 → LOT)加入 `FETCH FIRST 10001 ROWS ONLY` 上限。若回傳超過 10,000 筆,前端顯示警告「結果超過上限,請縮小查詢範圍」。
|
||||
|
||||
**理由**: 一批常用原物料可能被上千個 LOT 使用。無上限的反向查詢可能回傳數萬筆,壓垮前端和 Oracle 連線。10,000 筆足以覆蓋絕大多數場景。
|
||||
|
||||
### D5: 前端頁面結構沿用 Vite multi-page 模式
|
||||
|
||||
**決定**: 新增 `frontend/material-trace.html` + `frontend/src/material-trace/App.vue` 作為獨立 Vite entry point。沿用 reject-history 的單檔 App.vue + 子元件模式。
|
||||
|
||||
**理由**: 專案的所有查詢頁面(reject-history、hold-history、resource-history)都是獨立 Vite entry。統一架構。
|
||||
|
||||
### D6: 輸入筆數上限
|
||||
|
||||
**決定**: 正向查詢(LOT ID / 工單)輸入上限 200 筆,反向查詢(原物料批號)輸入上限 50 筆。
|
||||
|
||||
**理由**: 正向查詢每筆 LOT 平均產生 10-50 筆原物料記錄,200 筆 LOT 最多 10,000 筆結果。反向查詢每批原物料可能對應 100-1000 個 LOT,50 批已有碰上 10,000 筆上限的風險。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **[低] CONTAINERID resolve 多一次 round-trip** — LOT ID 模式需先查 `DW_MES_CONTAINER` 轉換。→ Container 表有 CONTAINERNAME 索引,batch IN query 很快(< 1s)。
|
||||
- **[低] 站群組 mapping 可能未涵蓋所有 WORKCENTERNAME** — `DW_MES_SPEC_WORKCENTER_V` 可能缺少新站點。→ 未映射的站點在結果中站群組欄位顯示空值,不影響查詢結果。
|
||||
- **[中] 反向查詢結果截斷** — 10,000 筆上限可能截斷大量使用的原物料批號結果。→ 前端明確顯示截斷警告,引導使用者縮小範圍。
|
||||
@@ -0,0 +1,35 @@
|
||||
## Why
|
||||
|
||||
生產追溯過程中,工程師需要查詢「某個 LOT/工單在哪個站群組用了什麼原物料」以及「某批原物料被哪些 LOT 使用」。目前原物料消耗資訊散落在 Query Tool 的 LotDetail "原物料" tab 中,只能逐筆 LOT 查看,無法批量查詢或反向追溯。缺少專屬頁面讓原物料異常時的影響範圍評估非常耗時。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 新增「原物料追溯查詢」獨立頁面,提供雙向查詢能力:
|
||||
- **正向查詢**:輸入 LOT ID 或工單號碼(多筆),查詢對應的原物料消耗記錄,可依站群組篩選
|
||||
- **反向查詢**:輸入原物料批號 MATERIALLOTNAME(多筆),查詢該批原物料被哪些 LOT 使用
|
||||
- 結果表格含分頁、站群組篩選、CSV 匯出
|
||||
- 後端新增 `/api/material-trace/query` 和 `/api/material-trace/export` API 端點
|
||||
- 查詢資料來源:`DWH.DW_MES_LOTMATERIALSHISTORY`(1800 萬筆),利用既有索引(CONTAINERID, PJ_WORKORDER, MATERIALLOTNAME)
|
||||
- 站群組對應透過 `filter_cache.get_workcenter_mapping()` 解析(與設備歷史績效共用同一份 mapping)
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `material-trace-page`: 原物料追溯查詢頁面 — 前端 UI、查詢模式切換、結果表格、分頁、CSV 匯出
|
||||
- `material-trace-api`: 原物料追溯 API — 正向/反向查詢端點、輸入驗證、結果分頁、匯出端點、rate limiting
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
(無既有 spec 需修改)
|
||||
|
||||
## Impact
|
||||
|
||||
- **新增後端服務** — `src/mes_dashboard/services/material_trace_service.py`:正向/反向查詢邏輯、站群組 enrichment
|
||||
- **新增後端路由** — `src/mes_dashboard/routes/material_trace_routes.py`:API 端點註冊
|
||||
- **新增 SQL** — `src/mes_dashboard/sql/material_trace/`:3 個查詢檔(forward_by_lot、forward_by_workorder、reverse_by_material_lot)
|
||||
- **新增前端頁面** — `frontend/src/material-trace/`:App.vue + 子元件(FilterPanel、ResultTable)
|
||||
- **新增前端入口** — `frontend/material-trace.html` + Vite entry
|
||||
- **共用依賴** — `filter_cache.get_workcenter_mapping()` 提供站群組對應、`parseMultiLineInput()` 處理多筆輸入
|
||||
- **資料庫** — 查詢 `DWH.DW_MES_LOTMATERIALSHISTORY`,使用既有索引,無 schema 變更
|
||||
- **Sidebar** — 需在導覽列新增頁面入口
|
||||
@@ -0,0 +1,120 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Material Trace API SHALL provide forward query endpoint
|
||||
The API SHALL accept LOT IDs or work order numbers and return corresponding material consumption records from `DW_MES_LOTMATERIALSHISTORY`.
|
||||
|
||||
#### Scenario: Forward query by LOT ID
|
||||
- **WHEN** `POST /api/material-trace/query` is called with `mode: "lot"` and `values: ["GA25060001-A01", "GA25060502"]`
|
||||
- **THEN** the API SHALL resolve LOT names to CONTAINERIDs via `DW_MES_CONTAINER`
|
||||
- **THEN** the API SHALL return material consumption records matching those CONTAINERIDs
|
||||
- **THEN** each record SHALL include CONTAINERID, CONTAINERNAME, PJ_WORKORDER, WORKCENTERNAME, WORKCENTER_GROUP, MATERIALPARTNAME, MATERIALLOTNAME, VENDORLOTNUMBER, QTYREQUIRED, QTYCONSUMED, EQUIPMENTNAME, TXNDATE, PRIMARY_CATEGORY, SECONDARY_CATEGORY
|
||||
|
||||
#### Scenario: Forward query by work order
|
||||
- **WHEN** `POST /api/material-trace/query` is called with `mode: "workorder"` and `values: ["WO-2025-001", "WO-2025-002"]`
|
||||
- **THEN** the API SHALL query `DW_MES_LOTMATERIALSHISTORY` using `PJ_WORKORDER` index directly
|
||||
- **THEN** the response format SHALL be identical to LOT ID mode
|
||||
|
||||
#### Scenario: Forward query with workcenter group filter
|
||||
- **WHEN** `POST /api/material-trace/query` includes `workcenter_groups: ["焊接_DB"]`
|
||||
- **THEN** the API SHALL resolve group names to WORKCENTERNAME list via `filter_cache.get_workcenter_mapping()`
|
||||
- **THEN** the SQL query SHALL include `AND WORKCENTERNAME IN (...)` filter
|
||||
- **THEN** results SHALL only contain records from workcenters belonging to the selected groups
|
||||
|
||||
#### Scenario: Forward query input limit
|
||||
- **WHEN** `POST /api/material-trace/query` with `mode: "lot"` or `mode: "workorder"` contains more than 200 values
|
||||
- **THEN** the API SHALL return HTTP 400 with error message indicating the 200-value limit
|
||||
|
||||
### Requirement: Material Trace API SHALL provide reverse query endpoint
|
||||
The API SHALL accept material lot names and return LOTs that consumed those materials.
|
||||
|
||||
#### Scenario: Reverse query by material lot name
|
||||
- **WHEN** `POST /api/material-trace/query` is called with `mode: "material_lot"` and `values: ["WIRE-LOT-20250101-A"]`
|
||||
- **THEN** the API SHALL query `DW_MES_LOTMATERIALSHISTORY` using `MATERIALLOTNAME` index
|
||||
- **THEN** each record SHALL include the same fields as forward query results
|
||||
|
||||
#### Scenario: Reverse query with workcenter group filter
|
||||
- **WHEN** reverse query includes `workcenter_groups` parameter
|
||||
- **THEN** the same workcenter group filtering logic as forward query SHALL apply
|
||||
|
||||
#### Scenario: Reverse query input limit
|
||||
- **WHEN** `POST /api/material-trace/query` with `mode: "material_lot"` contains more than 50 values
|
||||
- **THEN** the API SHALL return HTTP 400 with error message indicating the 50-value limit
|
||||
|
||||
#### Scenario: Reverse query result limit
|
||||
- **WHEN** reverse query results exceed 10,000 rows
|
||||
- **THEN** the API SHALL return exactly 10,000 rows
|
||||
- **THEN** the response `meta` SHALL include `truncated: true` and `max_rows: 10000`
|
||||
|
||||
### Requirement: Material Trace API SHALL validate query parameters
|
||||
The API SHALL validate input parameters before executing database queries.
|
||||
|
||||
#### Scenario: Missing required fields
|
||||
- **WHEN** `POST /api/material-trace/query` is called without `mode` or `values`
|
||||
- **THEN** the API SHALL return HTTP 400 with descriptive validation error
|
||||
|
||||
#### Scenario: Invalid mode
|
||||
- **WHEN** `mode` is not one of `lot`, `workorder`, `material_lot`
|
||||
- **THEN** the API SHALL return HTTP 400
|
||||
|
||||
#### Scenario: Empty values
|
||||
- **WHEN** `values` is an empty array or all values are blank after trimming
|
||||
- **THEN** the API SHALL return HTTP 400 with error message "請輸入至少一筆查詢條件"
|
||||
|
||||
#### Scenario: Unresolvable LOT IDs
|
||||
- **WHEN** some LOT names cannot be resolved to CONTAINERIDs
|
||||
- **THEN** the API SHALL proceed with the resolved subset
|
||||
- **THEN** the response `meta` SHALL include `unresolved` array listing unresolvable LOT names
|
||||
|
||||
### Requirement: Material Trace API SHALL support paginated results
|
||||
The API SHALL support server-side pagination for query results.
|
||||
|
||||
#### Scenario: Pagination parameters
|
||||
- **WHEN** `POST /api/material-trace/query` includes `page` and `per_page`
|
||||
- **THEN** results SHALL be paginated accordingly
|
||||
- **THEN** response SHALL include `pagination: { page, per_page, total, total_pages }`
|
||||
|
||||
#### Scenario: Default pagination
|
||||
- **WHEN** `page` or `per_page` is not provided
|
||||
- **THEN** `page` SHALL default to 1
|
||||
- **THEN** `per_page` SHALL default to 50
|
||||
|
||||
#### Scenario: Per-page cap
|
||||
- **WHEN** `per_page` exceeds 200
|
||||
- **THEN** `per_page` SHALL be capped at 200
|
||||
|
||||
### Requirement: Material Trace API SHALL provide CSV export endpoint
|
||||
The API SHALL provide CSV export using the same query parameters as the query endpoint.
|
||||
|
||||
#### Scenario: Export request
|
||||
- **WHEN** `POST /api/material-trace/export` is called with the same parameters as query
|
||||
- **THEN** the response SHALL be a CSV file with UTF-8 BOM encoding
|
||||
- **THEN** CSV headers SHALL be in Chinese
|
||||
- **THEN** all matching records SHALL be included (no pagination, subject to result limits)
|
||||
|
||||
#### Scenario: Export result limit
|
||||
- **WHEN** export results exceed 50,000 rows
|
||||
- **THEN** the export SHALL be truncated at 50,000 rows
|
||||
- **THEN** a warning header SHALL indicate truncation
|
||||
|
||||
### Requirement: Material Trace API SHALL enrich results with workcenter group
|
||||
The API SHALL add WORKCENTER_GROUP to each result row based on `filter_cache.get_workcenter_mapping()`.
|
||||
|
||||
#### Scenario: Workcenter group enrichment
|
||||
- **WHEN** query results are returned
|
||||
- **THEN** each row SHALL include a `WORKCENTER_GROUP` field
|
||||
- **THEN** the value SHALL be resolved from `filter_cache.get_workcenter_mapping()` using the row's `WORKCENTERNAME`
|
||||
|
||||
#### Scenario: Unknown workcenter
|
||||
- **WHEN** a row's WORKCENTERNAME has no mapping in the workcenter cache
|
||||
- **THEN** `WORKCENTER_GROUP` SHALL be empty string
|
||||
|
||||
### Requirement: Material Trace API SHALL apply rate limiting
|
||||
The API SHALL rate-limit query and export endpoints to protect Oracle resources.
|
||||
|
||||
#### Scenario: Query rate limit
|
||||
- **WHEN** `/api/material-trace/query` receives excessive requests
|
||||
- **THEN** requests beyond 30 per 60 seconds SHALL be rejected with HTTP 429
|
||||
|
||||
#### Scenario: Export rate limit
|
||||
- **WHEN** `/api/material-trace/export` receives excessive requests
|
||||
- **THEN** requests beyond 10 per 60 seconds SHALL be rejected with HTTP 429
|
||||
@@ -0,0 +1,120 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Material Trace page SHALL provide bidirectional query mode switching
|
||||
The page SHALL provide two query directions with explicit tab switching.
|
||||
|
||||
#### Scenario: Forward query mode (default)
|
||||
- **WHEN** the page loads
|
||||
- **THEN** "正向查詢:LOT/工單 → 原物料" tab SHALL be active by default
|
||||
- **THEN** the input area SHALL show input type selector (LOT ID / 工單) and a multi-line text input
|
||||
|
||||
#### Scenario: Reverse query mode
|
||||
- **WHEN** user clicks "反向查詢:原物料 → LOT" tab
|
||||
- **THEN** the input area SHALL switch to material lot name multi-line input
|
||||
- **THEN** query results and pagination SHALL be cleared
|
||||
|
||||
#### Scenario: Forward input type switching
|
||||
- **WHEN** forward mode is active
|
||||
- **THEN** user SHALL be able to switch between "LOT ID" and "工單" input types
|
||||
- **THEN** switching input type SHALL clear the input field and results
|
||||
|
||||
### Requirement: Material Trace page SHALL accept multi-line input
|
||||
The page SHALL accept multiple values separated by newlines or commas.
|
||||
|
||||
#### Scenario: Multi-line input parsing
|
||||
- **WHEN** user enters values separated by newlines, commas, or mixed delimiters
|
||||
- **THEN** the system SHALL parse and deduplicate values using the same logic as `parseMultiLineInput()`
|
||||
|
||||
#### Scenario: Input count display
|
||||
- **WHEN** user enters values
|
||||
- **THEN** the input area SHALL display the parsed count (e.g., "已輸入 5 筆")
|
||||
|
||||
#### Scenario: Forward input limit feedback
|
||||
- **WHEN** user enters more than 200 values in forward mode
|
||||
- **THEN** the page SHALL display an error message "正向查詢上限 200 筆"
|
||||
- **THEN** the query SHALL NOT be sent
|
||||
|
||||
#### Scenario: Reverse input limit feedback
|
||||
- **WHEN** user enters more than 50 values in reverse mode
|
||||
- **THEN** the page SHALL display an error message "反向查詢上限 50 筆"
|
||||
- **THEN** the query SHALL NOT be sent
|
||||
|
||||
### Requirement: Material Trace page SHALL provide workcenter group filter
|
||||
The page SHALL allow filtering results by workcenter group.
|
||||
|
||||
#### Scenario: Workcenter group options
|
||||
- **WHEN** the page loads
|
||||
- **THEN** workcenter group filter SHALL be populated from `filter_cache.get_workcenter_groups()`
|
||||
- **THEN** the filter SHALL support multi-select
|
||||
- **THEN** default SHALL be "全部站點" (no filter)
|
||||
|
||||
#### Scenario: Filter applied to query
|
||||
- **WHEN** user selects workcenter groups and clicks "查詢"
|
||||
- **THEN** the selected groups SHALL be sent as `workcenter_groups` parameter to the API
|
||||
- **THEN** results SHALL only contain records from workcenters in the selected groups
|
||||
|
||||
### Requirement: Material Trace page SHALL display query results in a paginated table
|
||||
The page SHALL display results in a sortable, paginated detail table.
|
||||
|
||||
#### Scenario: Result table columns
|
||||
- **WHEN** query results are loaded
|
||||
- **THEN** the table SHALL display: LOT ID (CONTAINERNAME), 工單 (PJ_WORKORDER), 站群組 (WORKCENTER_GROUP), 站點 (WORKCENTERNAME), 料號 (MATERIALPARTNAME), 物料批號 (MATERIALLOTNAME), 供應商批號 (VENDORLOTNUMBER), 應領量 (QTYREQUIRED), 實際消耗 (QTYCONSUMED), 機台 (EQUIPMENTNAME), 交易日期 (TXNDATE), 主分類 (PRIMARY_CATEGORY), 副分類 (SECONDARY_CATEGORY)
|
||||
|
||||
#### Scenario: Pagination controls
|
||||
- **WHEN** results exceed per-page size
|
||||
- **THEN** pagination controls SHALL display "上一頁" / "下一頁" buttons and page info in Chinese
|
||||
- **THEN** default per-page size SHALL be 50
|
||||
|
||||
#### Scenario: Empty results
|
||||
- **WHEN** query returns no matching records
|
||||
- **THEN** the table area SHALL display "查無資料" message
|
||||
|
||||
#### Scenario: Unresolved LOT IDs warning
|
||||
- **WHEN** the API response contains `meta.unresolved` array
|
||||
- **THEN** a warning banner SHALL display listing the unresolvable LOT names
|
||||
|
||||
#### Scenario: Result truncation warning
|
||||
- **WHEN** the API response contains `meta.truncated: true`
|
||||
- **THEN** an amber warning banner SHALL display "查詢結果超過 10,000 筆上限,請縮小查詢範圍"
|
||||
|
||||
### Requirement: Material Trace page SHALL support CSV export
|
||||
The page SHALL allow exporting current query results to CSV.
|
||||
|
||||
#### Scenario: Export button
|
||||
- **WHEN** query results are loaded
|
||||
- **THEN** an "匯出 CSV" button SHALL be visible
|
||||
- **WHEN** user clicks "匯出 CSV"
|
||||
- **THEN** the export request SHALL use the same query parameters as the current query
|
||||
|
||||
#### Scenario: Export disabled without results
|
||||
- **WHEN** no query has been executed or results are empty
|
||||
- **THEN** the "匯出 CSV" button SHALL be disabled
|
||||
|
||||
### Requirement: Material Trace page SHALL provide loading and error states
|
||||
The page SHALL provide clear feedback during loading and error conditions.
|
||||
|
||||
#### Scenario: Loading state
|
||||
- **WHEN** a query is in progress
|
||||
- **THEN** a loading indicator SHALL be visible
|
||||
- **THEN** the query button SHALL be disabled
|
||||
|
||||
#### Scenario: API error
|
||||
- **WHEN** the API returns an error
|
||||
- **THEN** a red error banner SHALL display the error message
|
||||
|
||||
#### Scenario: Error cleared on new query
|
||||
- **WHEN** user initiates a new query
|
||||
- **THEN** previous error and warning banners SHALL be cleared
|
||||
|
||||
### Requirement: Material Trace page SHALL use Chinese labels
|
||||
The page SHALL display all UI text in Traditional Chinese consistent with the rest of the application.
|
||||
|
||||
#### Scenario: Page title
|
||||
- **WHEN** the page is rendered
|
||||
- **THEN** the page title SHALL be "原物料追溯查詢"
|
||||
|
||||
#### Scenario: Button labels
|
||||
- **WHEN** the page is rendered
|
||||
- **THEN** the query button SHALL display "查詢"
|
||||
- **THEN** the export button SHALL display "匯出 CSV"
|
||||
- **THEN** the clear button SHALL display "清除"
|
||||
@@ -0,0 +1,63 @@
|
||||
## 1. SQL 查詢檔
|
||||
|
||||
- [x] 1.1 建立 `src/mes_dashboard/sql/material_trace/` 目錄
|
||||
- [x] 1.2 新增 `forward_by_lot.sql`:以 CONTAINERID IN (:ids) 查詢 `DW_MES_LOTMATERIALSHISTORY`,LEFT JOIN `DW_MES_CONTAINER` 取 CONTAINERNAME,含可選 WORKCENTERNAME IN 篩選
|
||||
- [x] 1.3 新增 `forward_by_workorder.sql`:以 PJ_WORKORDER IN (:ids) 查詢,結構與 forward_by_lot 相同
|
||||
- [x] 1.4 新增 `reverse_by_material_lot.sql`:以 MATERIALLOTNAME IN (:ids) 查詢,LEFT JOIN `DW_MES_CONTAINER` 取 CONTAINERNAME,含 FETCH FIRST 10001 ROWS ONLY 上限,含可選 WORKCENTERNAME IN 篩選
|
||||
- [x] 1.5 新增 `resolve_container_ids.sql`:批次將 CONTAINERNAME 轉換為 CONTAINERID
|
||||
|
||||
## 2. 後端 Service
|
||||
|
||||
- [x] 2.1 新增 `src/mes_dashboard/services/material_trace_service.py`,包含 `forward_query(mode, values, workcenter_groups, page, per_page)` 函式
|
||||
- [x] 2.2 在 `forward_query` 中實作 LOT ID 模式:呼叫 `resolve_container_ids.sql` 將 CONTAINERNAME 批次轉換為 CONTAINERID,記錄未解析的名稱到 `meta.unresolved`
|
||||
- [x] 2.3 在 `forward_query` 中實作工單模式:直接以 PJ_WORKORDER 查詢
|
||||
- [x] 2.4 實作 `reverse_query(values, workcenter_groups, page, per_page)` 函式,以 MATERIALLOTNAME 查詢,檢查結果是否超過 10,000 筆並設定 `meta.truncated`
|
||||
- [x] 2.5 實作共用 `_enrich_workcenter_group(df)` 函式:使用 `filter_cache.get_workcenter_mapping()` 對 DataFrame 添加 WORKCENTER_GROUP 欄位
|
||||
- [x] 2.6 實作共用 `_apply_workcenter_group_filter(workcenter_groups)` 函式:透過 `filter_cache.get_workcenter_mapping()` 將站群組名稱解析為 WORKCENTERNAME 清單,供 SQL WHERE 使用
|
||||
- [x] 2.7 實作 `export_csv(mode, values, workcenter_groups)` 函式,結果上限 50,000 筆,回傳 UTF-8 BOM CSV
|
||||
|
||||
## 3. 後端 Route
|
||||
|
||||
- [x] 3.1 新增 `src/mes_dashboard/routes/material_trace_routes.py`,建立 `material_trace_bp` Blueprint,prefix `/api/material-trace`
|
||||
- [x] 3.2 實作 `POST /query` 端點:驗證 mode(lot/workorder/material_lot)、values 非空、筆數上限(正向 200 / 反向 50);根據 mode 呼叫 `forward_query` 或 `reverse_query`;回傳分頁結果
|
||||
- [x] 3.3 實作 `POST /export` 端點:與 query 相同參數驗證,呼叫 `export_csv`,回傳 CSV response
|
||||
- [x] 3.4 實作 `GET /filter-options` 端點:回傳 `filter_cache.get_workcenter_groups()` 供前端站群組下拉選單使用
|
||||
- [x] 3.5 加入 rate limiting:query 30/60s,export 10/60s
|
||||
- [x] 3.6 在 `routes/__init__.py` 註冊 `material_trace_bp`
|
||||
|
||||
## 4. 前端頁面基礎
|
||||
|
||||
- [x] 4.1 新增 `frontend/material-trace.html` Vite entry point
|
||||
- [x] 4.2 新增 `frontend/src/material-trace/main.js` 初始化 Vue app
|
||||
- [x] 4.3 新增 `frontend/src/material-trace/App.vue` 主元件:包含 queryMode(forward/reverse)、forwardInputType(lot/workorder)、inputText、workcenterGroups、results、pagination、loading、error 等 reactive state
|
||||
- [x] 4.4 新增 `frontend/src/material-trace/style.css`,沿用 reject-history 的表格/banner 樣式基礎
|
||||
- [x] 4.5 在 `vite.config.js` 加入 `material-trace` entry
|
||||
- [x] 4.6 在 Flask 後端 `templates/` 新增頁面路由(或 Jinja template),確認頁面可存取
|
||||
|
||||
## 5. 前端元件
|
||||
|
||||
- [x] 5.1 實作查詢模式切換 tab(正向查詢 / 反向查詢),切換時清空輸入和結果
|
||||
- [x] 5.2 實作正向模式的輸入類型選擇(LOT ID / 工單),切換時清空輸入
|
||||
- [x] 5.3 實作多筆輸入 textarea,使用 `parseMultiLineInput()` 解析,顯示已輸入筆數
|
||||
- [x] 5.4 實作前端輸入筆數驗證(正向 200 筆 / 反向 50 筆),超過時顯示 error banner 並阻止查詢
|
||||
- [x] 5.5 實作站群組多選篩選下拉(options 從 `/api/material-trace/filter-options` 載入)
|
||||
- [x] 5.6 實作 `executePrimaryQuery()` 函式:呼叫 `/api/material-trace/query` API,處理結果、分頁、error、unresolved、truncated 警告
|
||||
- [x] 5.7 實作結果表格,含 13 個欄位(CONTAINERNAME、PJ_WORKORDER、WORKCENTER_GROUP、WORKCENTERNAME、MATERIALPARTNAME、MATERIALLOTNAME、VENDORLOTNUMBER、QTYREQUIRED、QTYCONSUMED、EQUIPMENTNAME、TXNDATE、PRIMARY_CATEGORY、SECONDARY_CATEGORY)
|
||||
- [x] 5.8 實作分頁控制(上一頁/下一頁/頁碼顯示),server-side 分頁
|
||||
- [x] 5.9 實作匯出 CSV 按鈕,呼叫 `/api/material-trace/export`,無結果時 disabled
|
||||
- [x] 5.10 實作 loading overlay、error banner、warning banner(unresolved LOT / 結果截斷)
|
||||
|
||||
## 6. 導覽整合
|
||||
|
||||
- [x] 6.1 在 sidebar/drawer 導覽列新增「原物料追溯查詢」頁面入口
|
||||
|
||||
## 7. 測試
|
||||
|
||||
- [x] 7.1 新增 `tests/test_material_trace_service.py`:測試正向 LOT 模式查詢(mock Oracle 回傳),驗證 CONTAINERID resolve + 結果 enrichment
|
||||
- [x] 7.2 測試正向工單模式查詢,驗證 PJ_WORKORDER 直接查詢
|
||||
- [x] 7.3 測試反向查詢,驗證結果上限 10,000 筆截斷邏輯
|
||||
- [x] 7.4 測試站群組篩選:mock `get_workcenter_mapping()` 回傳 mapping,驗證 WORKCENTERNAME IN 過濾
|
||||
- [x] 7.5 測試未解析 LOT ID 的 `meta.unresolved` 回傳
|
||||
- [x] 7.6 新增 `tests/test_material_trace_routes.py`:測試輸入驗證(mode 無效、values 空、超過筆數上限)回傳 HTTP 400
|
||||
- [x] 7.7 測試 query 端點回傳正確分頁結構
|
||||
- [x] 7.8 測試 export 端點回傳 CSV content-type 和 UTF-8 BOM
|
||||
120
openspec/specs/material-trace-api/spec.md
Normal file
120
openspec/specs/material-trace-api/spec.md
Normal file
@@ -0,0 +1,120 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Material Trace API SHALL provide forward query endpoint
|
||||
The API SHALL accept LOT IDs or work order numbers and return corresponding material consumption records from `DW_MES_LOTMATERIALSHISTORY`.
|
||||
|
||||
#### Scenario: Forward query by LOT ID
|
||||
- **WHEN** `POST /api/material-trace/query` is called with `mode: "lot"` and `values: ["GA25060001-A01", "GA25060502"]`
|
||||
- **THEN** the API SHALL resolve LOT names to CONTAINERIDs via `DW_MES_CONTAINER`
|
||||
- **THEN** the API SHALL return material consumption records matching those CONTAINERIDs
|
||||
- **THEN** each record SHALL include CONTAINERID, CONTAINERNAME, PJ_WORKORDER, WORKCENTERNAME, WORKCENTER_GROUP, MATERIALPARTNAME, MATERIALLOTNAME, VENDORLOTNUMBER, QTYREQUIRED, QTYCONSUMED, EQUIPMENTNAME, TXNDATE, PRIMARY_CATEGORY, SECONDARY_CATEGORY
|
||||
|
||||
#### Scenario: Forward query by work order
|
||||
- **WHEN** `POST /api/material-trace/query` is called with `mode: "workorder"` and `values: ["WO-2025-001", "WO-2025-002"]`
|
||||
- **THEN** the API SHALL query `DW_MES_LOTMATERIALSHISTORY` using `PJ_WORKORDER` index directly
|
||||
- **THEN** the response format SHALL be identical to LOT ID mode
|
||||
|
||||
#### Scenario: Forward query with workcenter group filter
|
||||
- **WHEN** `POST /api/material-trace/query` includes `workcenter_groups: ["焊接_DB"]`
|
||||
- **THEN** the API SHALL resolve group names to WORKCENTERNAME list via `filter_cache.get_workcenter_mapping()`
|
||||
- **THEN** the SQL query SHALL include `AND WORKCENTERNAME IN (...)` filter
|
||||
- **THEN** results SHALL only contain records from workcenters belonging to the selected groups
|
||||
|
||||
#### Scenario: Forward query input limit
|
||||
- **WHEN** `POST /api/material-trace/query` with `mode: "lot"` or `mode: "workorder"` contains more than 200 values
|
||||
- **THEN** the API SHALL return HTTP 400 with error message indicating the 200-value limit
|
||||
|
||||
### Requirement: Material Trace API SHALL provide reverse query endpoint
|
||||
The API SHALL accept material lot names and return LOTs that consumed those materials.
|
||||
|
||||
#### Scenario: Reverse query by material lot name
|
||||
- **WHEN** `POST /api/material-trace/query` is called with `mode: "material_lot"` and `values: ["WIRE-LOT-20250101-A"]`
|
||||
- **THEN** the API SHALL query `DW_MES_LOTMATERIALSHISTORY` using `MATERIALLOTNAME` index
|
||||
- **THEN** each record SHALL include the same fields as forward query results
|
||||
|
||||
#### Scenario: Reverse query with workcenter group filter
|
||||
- **WHEN** reverse query includes `workcenter_groups` parameter
|
||||
- **THEN** the same workcenter group filtering logic as forward query SHALL apply
|
||||
|
||||
#### Scenario: Reverse query input limit
|
||||
- **WHEN** `POST /api/material-trace/query` with `mode: "material_lot"` contains more than 50 values
|
||||
- **THEN** the API SHALL return HTTP 400 with error message indicating the 50-value limit
|
||||
|
||||
#### Scenario: Reverse query result limit
|
||||
- **WHEN** reverse query results exceed 10,000 rows
|
||||
- **THEN** the API SHALL return exactly 10,000 rows
|
||||
- **THEN** the response `meta` SHALL include `truncated: true` and `max_rows: 10000`
|
||||
|
||||
### Requirement: Material Trace API SHALL validate query parameters
|
||||
The API SHALL validate input parameters before executing database queries.
|
||||
|
||||
#### Scenario: Missing required fields
|
||||
- **WHEN** `POST /api/material-trace/query` is called without `mode` or `values`
|
||||
- **THEN** the API SHALL return HTTP 400 with descriptive validation error
|
||||
|
||||
#### Scenario: Invalid mode
|
||||
- **WHEN** `mode` is not one of `lot`, `workorder`, `material_lot`
|
||||
- **THEN** the API SHALL return HTTP 400
|
||||
|
||||
#### Scenario: Empty values
|
||||
- **WHEN** `values` is an empty array or all values are blank after trimming
|
||||
- **THEN** the API SHALL return HTTP 400 with error message "請輸入至少一筆查詢條件"
|
||||
|
||||
#### Scenario: Unresolvable LOT IDs
|
||||
- **WHEN** some LOT names cannot be resolved to CONTAINERIDs
|
||||
- **THEN** the API SHALL proceed with the resolved subset
|
||||
- **THEN** the response `meta` SHALL include `unresolved` array listing unresolvable LOT names
|
||||
|
||||
### Requirement: Material Trace API SHALL support paginated results
|
||||
The API SHALL support server-side pagination for query results.
|
||||
|
||||
#### Scenario: Pagination parameters
|
||||
- **WHEN** `POST /api/material-trace/query` includes `page` and `per_page`
|
||||
- **THEN** results SHALL be paginated accordingly
|
||||
- **THEN** response SHALL include `pagination: { page, per_page, total, total_pages }`
|
||||
|
||||
#### Scenario: Default pagination
|
||||
- **WHEN** `page` or `per_page` is not provided
|
||||
- **THEN** `page` SHALL default to 1
|
||||
- **THEN** `per_page` SHALL default to 50
|
||||
|
||||
#### Scenario: Per-page cap
|
||||
- **WHEN** `per_page` exceeds 200
|
||||
- **THEN** `per_page` SHALL be capped at 200
|
||||
|
||||
### Requirement: Material Trace API SHALL provide CSV export endpoint
|
||||
The API SHALL provide CSV export using the same query parameters as the query endpoint.
|
||||
|
||||
#### Scenario: Export request
|
||||
- **WHEN** `POST /api/material-trace/export` is called with the same parameters as query
|
||||
- **THEN** the response SHALL be a CSV file with UTF-8 BOM encoding
|
||||
- **THEN** CSV headers SHALL be in Chinese
|
||||
- **THEN** all matching records SHALL be included (no pagination, subject to result limits)
|
||||
|
||||
#### Scenario: Export result limit
|
||||
- **WHEN** export results exceed 50,000 rows
|
||||
- **THEN** the export SHALL be truncated at 50,000 rows
|
||||
- **THEN** a warning header SHALL indicate truncation
|
||||
|
||||
### Requirement: Material Trace API SHALL enrich results with workcenter group
|
||||
The API SHALL add WORKCENTER_GROUP to each result row based on `filter_cache.get_workcenter_mapping()`.
|
||||
|
||||
#### Scenario: Workcenter group enrichment
|
||||
- **WHEN** query results are returned
|
||||
- **THEN** each row SHALL include a `WORKCENTER_GROUP` field
|
||||
- **THEN** the value SHALL be resolved from `filter_cache.get_workcenter_mapping()` using the row's `WORKCENTERNAME`
|
||||
|
||||
#### Scenario: Unknown workcenter
|
||||
- **WHEN** a row's WORKCENTERNAME has no mapping in the workcenter cache
|
||||
- **THEN** `WORKCENTER_GROUP` SHALL be empty string
|
||||
|
||||
### Requirement: Material Trace API SHALL apply rate limiting
|
||||
The API SHALL rate-limit query and export endpoints to protect Oracle resources.
|
||||
|
||||
#### Scenario: Query rate limit
|
||||
- **WHEN** `/api/material-trace/query` receives excessive requests
|
||||
- **THEN** requests beyond 30 per 60 seconds SHALL be rejected with HTTP 429
|
||||
|
||||
#### Scenario: Export rate limit
|
||||
- **WHEN** `/api/material-trace/export` receives excessive requests
|
||||
- **THEN** requests beyond 10 per 60 seconds SHALL be rejected with HTTP 429
|
||||
120
openspec/specs/material-trace-page/spec.md
Normal file
120
openspec/specs/material-trace-page/spec.md
Normal file
@@ -0,0 +1,120 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Material Trace page SHALL provide bidirectional query mode switching
|
||||
The page SHALL provide two query directions with explicit tab switching.
|
||||
|
||||
#### Scenario: Forward query mode (default)
|
||||
- **WHEN** the page loads
|
||||
- **THEN** "正向查詢:LOT/工單 → 原物料" tab SHALL be active by default
|
||||
- **THEN** the input area SHALL show input type selector (LOT ID / 工單) and a multi-line text input
|
||||
|
||||
#### Scenario: Reverse query mode
|
||||
- **WHEN** user clicks "反向查詢:原物料 → LOT" tab
|
||||
- **THEN** the input area SHALL switch to material lot name multi-line input
|
||||
- **THEN** query results and pagination SHALL be cleared
|
||||
|
||||
#### Scenario: Forward input type switching
|
||||
- **WHEN** forward mode is active
|
||||
- **THEN** user SHALL be able to switch between "LOT ID" and "工單" input types
|
||||
- **THEN** switching input type SHALL clear the input field and results
|
||||
|
||||
### Requirement: Material Trace page SHALL accept multi-line input
|
||||
The page SHALL accept multiple values separated by newlines or commas.
|
||||
|
||||
#### Scenario: Multi-line input parsing
|
||||
- **WHEN** user enters values separated by newlines, commas, or mixed delimiters
|
||||
- **THEN** the system SHALL parse and deduplicate values using the same logic as `parseMultiLineInput()`
|
||||
|
||||
#### Scenario: Input count display
|
||||
- **WHEN** user enters values
|
||||
- **THEN** the input area SHALL display the parsed count (e.g., "已輸入 5 筆")
|
||||
|
||||
#### Scenario: Forward input limit feedback
|
||||
- **WHEN** user enters more than 200 values in forward mode
|
||||
- **THEN** the page SHALL display an error message "正向查詢上限 200 筆"
|
||||
- **THEN** the query SHALL NOT be sent
|
||||
|
||||
#### Scenario: Reverse input limit feedback
|
||||
- **WHEN** user enters more than 50 values in reverse mode
|
||||
- **THEN** the page SHALL display an error message "反向查詢上限 50 筆"
|
||||
- **THEN** the query SHALL NOT be sent
|
||||
|
||||
### Requirement: Material Trace page SHALL provide workcenter group filter
|
||||
The page SHALL allow filtering results by workcenter group.
|
||||
|
||||
#### Scenario: Workcenter group options
|
||||
- **WHEN** the page loads
|
||||
- **THEN** workcenter group filter SHALL be populated from `filter_cache.get_workcenter_groups()`
|
||||
- **THEN** the filter SHALL support multi-select
|
||||
- **THEN** default SHALL be "全部站點" (no filter)
|
||||
|
||||
#### Scenario: Filter applied to query
|
||||
- **WHEN** user selects workcenter groups and clicks "查詢"
|
||||
- **THEN** the selected groups SHALL be sent as `workcenter_groups` parameter to the API
|
||||
- **THEN** results SHALL only contain records from workcenters in the selected groups
|
||||
|
||||
### Requirement: Material Trace page SHALL display query results in a paginated table
|
||||
The page SHALL display results in a sortable, paginated detail table.
|
||||
|
||||
#### Scenario: Result table columns
|
||||
- **WHEN** query results are loaded
|
||||
- **THEN** the table SHALL display: LOT ID (CONTAINERNAME), 工單 (PJ_WORKORDER), 站群組 (WORKCENTER_GROUP), 站點 (WORKCENTERNAME), 料號 (MATERIALPARTNAME), 物料批號 (MATERIALLOTNAME), 供應商批號 (VENDORLOTNUMBER), 應領量 (QTYREQUIRED), 實際消耗 (QTYCONSUMED), 機台 (EQUIPMENTNAME), 交易日期 (TXNDATE), 主分類 (PRIMARY_CATEGORY), 副分類 (SECONDARY_CATEGORY)
|
||||
|
||||
#### Scenario: Pagination controls
|
||||
- **WHEN** results exceed per-page size
|
||||
- **THEN** pagination controls SHALL display "上一頁" / "下一頁" buttons and page info in Chinese
|
||||
- **THEN** default per-page size SHALL be 50
|
||||
|
||||
#### Scenario: Empty results
|
||||
- **WHEN** query returns no matching records
|
||||
- **THEN** the table area SHALL display "查無資料" message
|
||||
|
||||
#### Scenario: Unresolved LOT IDs warning
|
||||
- **WHEN** the API response contains `meta.unresolved` array
|
||||
- **THEN** a warning banner SHALL display listing the unresolvable LOT names
|
||||
|
||||
#### Scenario: Result truncation warning
|
||||
- **WHEN** the API response contains `meta.truncated: true`
|
||||
- **THEN** an amber warning banner SHALL display "查詢結果超過 10,000 筆上限,請縮小查詢範圍"
|
||||
|
||||
### Requirement: Material Trace page SHALL support CSV export
|
||||
The page SHALL allow exporting current query results to CSV.
|
||||
|
||||
#### Scenario: Export button
|
||||
- **WHEN** query results are loaded
|
||||
- **THEN** an "匯出 CSV" button SHALL be visible
|
||||
- **WHEN** user clicks "匯出 CSV"
|
||||
- **THEN** the export request SHALL use the same query parameters as the current query
|
||||
|
||||
#### Scenario: Export disabled without results
|
||||
- **WHEN** no query has been executed or results are empty
|
||||
- **THEN** the "匯出 CSV" button SHALL be disabled
|
||||
|
||||
### Requirement: Material Trace page SHALL provide loading and error states
|
||||
The page SHALL provide clear feedback during loading and error conditions.
|
||||
|
||||
#### Scenario: Loading state
|
||||
- **WHEN** a query is in progress
|
||||
- **THEN** a loading indicator SHALL be visible
|
||||
- **THEN** the query button SHALL be disabled
|
||||
|
||||
#### Scenario: API error
|
||||
- **WHEN** the API returns an error
|
||||
- **THEN** a red error banner SHALL display the error message
|
||||
|
||||
#### Scenario: Error cleared on new query
|
||||
- **WHEN** user initiates a new query
|
||||
- **THEN** previous error and warning banners SHALL be cleared
|
||||
|
||||
### Requirement: Material Trace page SHALL use Chinese labels
|
||||
The page SHALL display all UI text in Traditional Chinese consistent with the rest of the application.
|
||||
|
||||
#### Scenario: Page title
|
||||
- **WHEN** the page is rendered
|
||||
- **THEN** the page title SHALL be "原物料追溯查詢"
|
||||
|
||||
#### Scenario: Button labels
|
||||
- **WHEN** the page is rendered
|
||||
- **THEN** the query button SHALL display "查詢"
|
||||
- **THEN** the export button SHALL display "匯出 CSV"
|
||||
- **THEN** the clear button SHALL display "清除"
|
||||
@@ -931,6 +931,27 @@ def create_app(config_name: str | None = None) -> Flask:
|
||||
200,
|
||||
))
|
||||
|
||||
@app.route('/material-trace')
|
||||
def material_trace_page():
|
||||
"""Material trace query page served as pure Vite HTML output."""
|
||||
canonical_redirect = maybe_redirect_to_canonical_shell('/material-trace')
|
||||
if canonical_redirect is not None:
|
||||
return canonical_redirect
|
||||
|
||||
dist_dir = os.path.join(app.static_folder or "", "dist")
|
||||
dist_html = os.path.join(dist_dir, "material-trace.html")
|
||||
if os.path.exists(dist_html):
|
||||
return send_from_directory(dist_dir, 'material-trace.html')
|
||||
|
||||
return missing_in_scope_asset_response('/material-trace', (
|
||||
"<!doctype html><html lang=\"zh-Hant\"><head><meta charset=\"UTF-8\">"
|
||||
"<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">"
|
||||
"<title>原物料追溯查詢</title>"
|
||||
"<script type=\"module\" src=\"/static/dist/material-trace.js\"></script>"
|
||||
"</head><body><div id='app'></div></body></html>",
|
||||
200,
|
||||
))
|
||||
|
||||
# ========================================================
|
||||
# Table Query APIs (for table_data_viewer)
|
||||
# ========================================================
|
||||
|
||||
@@ -20,6 +20,7 @@ from .qc_gate_routes import qc_gate_bp
|
||||
from .mid_section_defect_routes import mid_section_defect_bp
|
||||
from .trace_routes import trace_bp
|
||||
from .reject_history_routes import reject_history_bp
|
||||
from .material_trace_routes import material_trace_bp
|
||||
|
||||
|
||||
def register_routes(app) -> None:
|
||||
@@ -38,6 +39,7 @@ def register_routes(app) -> None:
|
||||
app.register_blueprint(mid_section_defect_bp)
|
||||
app.register_blueprint(trace_bp)
|
||||
app.register_blueprint(reject_history_bp)
|
||||
app.register_blueprint(material_trace_bp)
|
||||
|
||||
__all__ = [
|
||||
'wip_bp',
|
||||
@@ -56,5 +58,6 @@ __all__ = [
|
||||
'mid_section_defect_bp',
|
||||
'trace_bp',
|
||||
'reject_history_bp',
|
||||
'material_trace_bp',
|
||||
'register_routes',
|
||||
]
|
||||
|
||||
185
src/mes_dashboard/routes/material_trace_routes.py
Normal file
185
src/mes_dashboard/routes/material_trace_routes.py
Normal file
@@ -0,0 +1,185 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Material trace API routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from flask import Blueprint, Response, jsonify
|
||||
|
||||
from mes_dashboard.core.rate_limit import configured_rate_limit
|
||||
from mes_dashboard.core.request_validation import parse_json_payload
|
||||
from mes_dashboard.services.container_resolution_policy import (
|
||||
validate_resolution_request,
|
||||
)
|
||||
from mes_dashboard.services.filter_cache import get_workcenter_groups
|
||||
from mes_dashboard.services.material_trace_service import (
|
||||
export_csv,
|
||||
forward_query,
|
||||
reverse_query,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("mes_dashboard.material_trace")
|
||||
|
||||
material_trace_bp = Blueprint("material_trace", __name__)
|
||||
|
||||
# ============================================================
|
||||
# Constants
|
||||
# ============================================================
|
||||
|
||||
_VALID_MODES = {"lot", "workorder", "material_lot"}
|
||||
_FORWARD_MODES = {"lot", "workorder"}
|
||||
_FORWARD_INPUT_LIMIT = 200
|
||||
_REVERSE_INPUT_LIMIT = 50
|
||||
_MAX_PER_PAGE = 200
|
||||
|
||||
# ============================================================
|
||||
# Rate Limiting
|
||||
# ============================================================
|
||||
|
||||
_QUERY_RATE_LIMIT = configured_rate_limit(
|
||||
bucket="material-trace-query",
|
||||
max_attempts_env="MATERIAL_TRACE_QUERY_RATE_LIMIT_MAX_REQUESTS",
|
||||
window_seconds_env="MATERIAL_TRACE_QUERY_RATE_LIMIT_WINDOW_SECONDS",
|
||||
default_max_attempts=30,
|
||||
default_window_seconds=60,
|
||||
)
|
||||
|
||||
_EXPORT_RATE_LIMIT = configured_rate_limit(
|
||||
bucket="material-trace-export",
|
||||
max_attempts_env="MATERIAL_TRACE_EXPORT_RATE_LIMIT_MAX_REQUESTS",
|
||||
window_seconds_env="MATERIAL_TRACE_EXPORT_RATE_LIMIT_WINDOW_SECONDS",
|
||||
default_max_attempts=10,
|
||||
default_window_seconds=60,
|
||||
)
|
||||
|
||||
# ============================================================
|
||||
# Helpers
|
||||
# ============================================================
|
||||
|
||||
|
||||
def _validate_query_params(body: dict) -> tuple[str | None, str, list[str], list[str] | None, int, int]:
|
||||
"""Validate and extract query parameters.
|
||||
|
||||
Returns:
|
||||
(error_message, mode, values, workcenter_groups, page, per_page)
|
||||
"""
|
||||
mode = str(body.get("mode", "")).strip()
|
||||
if mode not in _VALID_MODES:
|
||||
return f"無效的查詢模式,可用值: {', '.join(sorted(_VALID_MODES))}", "", [], None, 1, 50
|
||||
|
||||
raw_values = body.get("values")
|
||||
if not isinstance(raw_values, list):
|
||||
return "values 必須為陣列", mode, [], None, 1, 50
|
||||
|
||||
values = [str(v).strip() for v in raw_values if str(v).strip()]
|
||||
if not values:
|
||||
return "請輸入至少一筆查詢條件", mode, [], None, 1, 50
|
||||
|
||||
# Input count limits
|
||||
if mode in _FORWARD_MODES and len(values) > _FORWARD_INPUT_LIMIT:
|
||||
return f"正向查詢上限 {_FORWARD_INPUT_LIMIT} 筆", mode, values, None, 1, 50
|
||||
if mode == "material_lot" and len(values) > _REVERSE_INPUT_LIMIT:
|
||||
return f"反向查詢上限 {_REVERSE_INPUT_LIMIT} 筆", mode, values, None, 1, 50
|
||||
|
||||
# Wildcard prefix safety (reuse container_resolution_policy guardrails)
|
||||
_INPUT_TYPE_LABELS = {"lot": "LOT ID", "workorder": "工單", "material_lot": "原物料批號"}
|
||||
wildcard_error = validate_resolution_request(_INPUT_TYPE_LABELS.get(mode, mode), values)
|
||||
if wildcard_error:
|
||||
return wildcard_error, mode, values, None, 1, 50
|
||||
|
||||
# Optional workcenter groups
|
||||
raw_groups = body.get("workcenter_groups")
|
||||
workcenter_groups = None
|
||||
if isinstance(raw_groups, list) and raw_groups:
|
||||
workcenter_groups = [str(g).strip() for g in raw_groups if str(g).strip()]
|
||||
if not workcenter_groups:
|
||||
workcenter_groups = None
|
||||
|
||||
page = max(1, int(body.get("page", 1) or 1))
|
||||
per_page = min(max(1, int(body.get("per_page", 50) or 50)), _MAX_PER_PAGE)
|
||||
|
||||
return None, mode, values, workcenter_groups, page, per_page
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Routes
|
||||
# ============================================================
|
||||
|
||||
|
||||
@material_trace_bp.route("/api/material-trace/query", methods=["POST"])
|
||||
@_QUERY_RATE_LIMIT
|
||||
def api_material_trace_query():
|
||||
"""Execute material trace query (forward or reverse)."""
|
||||
body, payload_error = parse_json_payload(require_non_empty_object=True)
|
||||
if payload_error is not None:
|
||||
return jsonify({"success": False, "error": payload_error.message}), payload_error.status_code
|
||||
|
||||
error, mode, values, workcenter_groups, page, per_page = _validate_query_params(body)
|
||||
if error:
|
||||
return jsonify({"success": False, "error": error}), 400
|
||||
|
||||
try:
|
||||
if mode in _FORWARD_MODES:
|
||||
result = forward_query(mode, values, workcenter_groups, page, per_page)
|
||||
else:
|
||||
result = reverse_query(values, workcenter_groups, page, per_page)
|
||||
|
||||
return jsonify({"success": True, **result})
|
||||
|
||||
except MemoryError as exc:
|
||||
logger.warning("Material trace query memory guard: %s", exc)
|
||||
return jsonify({"success": False, "error": str(exc)}), 400
|
||||
except Exception:
|
||||
logger.exception("Material trace query failed: mode=%s", mode)
|
||||
return jsonify({"success": False, "error": "查詢失敗,請稍後再試"}), 500
|
||||
|
||||
|
||||
@material_trace_bp.route("/api/material-trace/export", methods=["POST"])
|
||||
@_EXPORT_RATE_LIMIT
|
||||
def api_material_trace_export():
|
||||
"""Export material trace query results as CSV."""
|
||||
body, payload_error = parse_json_payload(require_non_empty_object=True)
|
||||
if payload_error is not None:
|
||||
return jsonify({"success": False, "error": payload_error.message}), payload_error.status_code
|
||||
|
||||
error, mode, values, workcenter_groups, _page, _per_page = _validate_query_params(body)
|
||||
if error:
|
||||
return jsonify({"success": False, "error": error}), 400
|
||||
|
||||
try:
|
||||
csv_bytes, meta = export_csv(mode, values, workcenter_groups)
|
||||
|
||||
response = Response(
|
||||
csv_bytes,
|
||||
mimetype="text/csv; charset=utf-8",
|
||||
headers={
|
||||
"Content-Disposition": "attachment; filename=material_trace.csv",
|
||||
},
|
||||
)
|
||||
if meta.get("truncated"):
|
||||
response.headers["X-Truncated"] = "true"
|
||||
response.headers["X-Max-Rows"] = str(meta.get("export_max_rows", ""))
|
||||
return response
|
||||
|
||||
except MemoryError as exc:
|
||||
logger.warning("Material trace export memory guard: %s", exc)
|
||||
return jsonify({"success": False, "error": str(exc)}), 400
|
||||
except Exception:
|
||||
logger.exception("Material trace export failed: mode=%s", mode)
|
||||
return jsonify({"success": False, "error": "匯出失敗,請稍後再試"}), 500
|
||||
|
||||
|
||||
@material_trace_bp.route("/api/material-trace/filter-options", methods=["GET"])
|
||||
def api_material_trace_filter_options():
|
||||
"""Return workcenter group options for filter dropdown."""
|
||||
groups = get_workcenter_groups()
|
||||
if groups is None:
|
||||
return jsonify({"success": False, "error": "站群組資料載入中"}), 503
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"data": {
|
||||
"workcenter_groups": [g["name"] for g in groups],
|
||||
},
|
||||
})
|
||||
366
src/mes_dashboard/services/material_trace_service.py
Normal file
366
src/mes_dashboard/services/material_trace_service.py
Normal file
@@ -0,0 +1,366 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Material trace service — bidirectional LOT/material query."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from mes_dashboard.core.database import read_sql_df, read_sql_df_slow
|
||||
from mes_dashboard.services.container_resolution_policy import (
|
||||
validate_resolution_request,
|
||||
)
|
||||
from mes_dashboard.services.filter_cache import (
|
||||
get_workcenter_mapping,
|
||||
get_workcenters_for_groups,
|
||||
)
|
||||
from mes_dashboard.sql import QueryBuilder, SQLLoader
|
||||
|
||||
logger = logging.getLogger("mes_dashboard.material_trace")
|
||||
|
||||
_REVERSE_MAX_ROWS = 10_000
|
||||
_EXPORT_MAX_ROWS = 50_000
|
||||
|
||||
# Safeguard: max DataFrame memory (MB) before aborting — same pattern as batch_query_engine
|
||||
_MAX_RESULT_MB = int(os.getenv("MATERIAL_TRACE_MAX_RESULT_MB", "256"))
|
||||
|
||||
# Safeguard: IN-clause batch size — Oracle has practical limits on large IN lists
|
||||
_IN_BATCH_SIZE = 1000
|
||||
|
||||
_CSV_COLUMNS = {
|
||||
"CONTAINERNAME": "LOT ID",
|
||||
"PJ_WORKORDER": "工單",
|
||||
"WORKCENTER_GROUP": "站群組",
|
||||
"WORKCENTERNAME": "站點",
|
||||
"MATERIALPARTNAME": "料號",
|
||||
"MATERIALLOTNAME": "物料批號",
|
||||
"VENDORLOTNUMBER": "供應商批號",
|
||||
"QTYREQUIRED": "應領量",
|
||||
"QTYCONSUMED": "實際消耗",
|
||||
"EQUIPMENTNAME": "機台",
|
||||
"TXNDATE": "交易日期",
|
||||
"PRIMARY_CATEGORY": "主分類",
|
||||
"SECONDARY_CATEGORY": "副分類",
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Wildcard helpers (same pattern as query_tool_service)
|
||||
# ============================================================
|
||||
|
||||
|
||||
def _normalize_wildcard_token(value: str) -> str:
|
||||
"""Normalize user wildcard syntax: * → %."""
|
||||
return str(value or "").replace("*", "%")
|
||||
|
||||
|
||||
def _is_pattern_token(value: str) -> bool:
|
||||
token = _normalize_wildcard_token(value)
|
||||
return "%" in token or "_" in token
|
||||
|
||||
|
||||
def _add_exact_or_pattern_condition(
|
||||
builder: QueryBuilder,
|
||||
column: str,
|
||||
values: List[str],
|
||||
) -> None:
|
||||
"""Add IN + LIKE mixed condition supporting exact and wildcard tokens.
|
||||
|
||||
Replicates the proven pattern from query_tool_service.
|
||||
"""
|
||||
if not values:
|
||||
return
|
||||
|
||||
col_expr = f"NVL({column}, '')"
|
||||
conditions: List[str] = []
|
||||
|
||||
exact_tokens = [v for v in values if not _is_pattern_token(v)]
|
||||
pattern_tokens = [v for v in values if _is_pattern_token(v)]
|
||||
|
||||
if exact_tokens:
|
||||
placeholders: List[str] = []
|
||||
for token in exact_tokens:
|
||||
param = builder._next_param()
|
||||
placeholders.append(f":{param}")
|
||||
builder.params[param] = token
|
||||
conditions.append(f"{col_expr} IN ({', '.join(placeholders)})")
|
||||
|
||||
for token in pattern_tokens:
|
||||
param = builder._next_param()
|
||||
builder.params[param] = _normalize_wildcard_token(token)
|
||||
conditions.append(f"{col_expr} LIKE :{param} ESCAPE '\\'")
|
||||
|
||||
if conditions:
|
||||
builder.add_condition(f"({' OR '.join(conditions)})")
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Shared helpers
|
||||
# ============================================================
|
||||
|
||||
|
||||
def _enrich_workcenter_group(df: pd.DataFrame) -> pd.DataFrame:
|
||||
"""Add WORKCENTER_GROUP column based on filter_cache mapping."""
|
||||
df = df.copy()
|
||||
mapping = get_workcenter_mapping()
|
||||
if mapping and "WORKCENTERNAME" in df.columns:
|
||||
df["WORKCENTER_GROUP"] = df["WORKCENTERNAME"].map(
|
||||
lambda wc: (mapping.get(wc) or {}).get("group", "")
|
||||
)
|
||||
else:
|
||||
df["WORKCENTER_GROUP"] = ""
|
||||
return df
|
||||
|
||||
|
||||
def _resolve_workcenter_names(workcenter_groups: Optional[List[str]]) -> Optional[List[str]]:
|
||||
"""Resolve group names to a flat list of WORKCENTERNAME values."""
|
||||
if not workcenter_groups:
|
||||
return None
|
||||
names = get_workcenters_for_groups(workcenter_groups)
|
||||
return names or None
|
||||
|
||||
|
||||
def _resolve_container_ids(
|
||||
lot_names: List[str],
|
||||
) -> tuple[List[str], Dict[str, str], List[str]]:
|
||||
"""Batch-resolve CONTAINERNAME → CONTAINERID (supports wildcards).
|
||||
|
||||
Returns:
|
||||
(container_ids, name_to_id_map, unresolved_names)
|
||||
Note: wildcard tokens are never reported as "unresolved".
|
||||
"""
|
||||
builder = QueryBuilder(base_sql=SQLLoader.load("material_trace/resolve_container_ids"))
|
||||
_add_exact_or_pattern_condition(builder, "c.CONTAINERNAME", lot_names)
|
||||
sql, params = builder.build()
|
||||
|
||||
df = read_sql_df(sql, params)
|
||||
if df is None or df.empty:
|
||||
# Only exact tokens can be "unresolved"
|
||||
exact_unresolved = [n for n in lot_names if not _is_pattern_token(n)]
|
||||
return [], {}, exact_unresolved
|
||||
|
||||
name_to_id: Dict[str, str] = {}
|
||||
for _, row in df.iterrows():
|
||||
name_to_id[str(row["CONTAINERNAME"])] = str(row["CONTAINERID"])
|
||||
|
||||
resolved_ids = list(name_to_id.values())
|
||||
# Only report unresolved for exact tokens (wildcards can match 0 rows legitimately)
|
||||
unresolved = [n for n in lot_names if not _is_pattern_token(n) and n not in name_to_id]
|
||||
return resolved_ids, name_to_id, unresolved
|
||||
|
||||
|
||||
def _check_memory_guard(df: pd.DataFrame) -> None:
|
||||
"""Raise if DataFrame exceeds memory threshold."""
|
||||
mem_mb = df.memory_usage(deep=True).sum() / (1024 * 1024)
|
||||
if mem_mb > _MAX_RESULT_MB:
|
||||
raise MemoryError(
|
||||
f"查詢結果佔用 {mem_mb:.0f} MB,超過 {_MAX_RESULT_MB} MB 上限,請縮小查詢範圍"
|
||||
)
|
||||
|
||||
|
||||
def _execute_batched_query(
|
||||
sql_name: str,
|
||||
column: str,
|
||||
values: List[str],
|
||||
wc_names: Optional[List[str]] = None,
|
||||
*,
|
||||
allow_patterns: bool = True,
|
||||
) -> pd.DataFrame:
|
||||
"""Execute query in batches, using slow-query channel.
|
||||
|
||||
When allow_patterns=True, values containing * or % are sent as LIKE clauses.
|
||||
When allow_patterns=False (e.g. resolved CONTAINERIDs), all values are treated
|
||||
as exact IN matches regardless of content.
|
||||
"""
|
||||
base_sql = SQLLoader.load(sql_name)
|
||||
chunks: list[pd.DataFrame] = []
|
||||
|
||||
if allow_patterns:
|
||||
exact_tokens = [v for v in values if not _is_pattern_token(v)]
|
||||
pattern_tokens = [v for v in values if _is_pattern_token(v)]
|
||||
else:
|
||||
exact_tokens = list(values)
|
||||
pattern_tokens = []
|
||||
|
||||
# Batch exact tokens; include pattern tokens once in the first batch
|
||||
for i in range(0, max(len(exact_tokens), 1), _IN_BATCH_SIZE):
|
||||
batch = exact_tokens[i : i + _IN_BATCH_SIZE]
|
||||
combined = batch + (pattern_tokens if i == 0 else [])
|
||||
if not combined:
|
||||
continue
|
||||
|
||||
builder = QueryBuilder(base_sql=base_sql)
|
||||
_add_exact_or_pattern_condition(builder, column, combined)
|
||||
if wc_names:
|
||||
builder.add_in_condition("m.WORKCENTERNAME", wc_names)
|
||||
|
||||
sql, params = builder.build()
|
||||
df = read_sql_df_slow(sql, params)
|
||||
if df is not None and not df.empty:
|
||||
chunks.append(df)
|
||||
|
||||
if not chunks:
|
||||
return pd.DataFrame()
|
||||
|
||||
result = pd.concat(chunks, ignore_index=True) if len(chunks) > 1 else chunks[0]
|
||||
# Deduplicate — wildcards across batches may produce overlapping rows
|
||||
if len(chunks) > 1 and "CONTAINERID" in result.columns:
|
||||
result = result.drop_duplicates(subset=["CONTAINERID", "MATERIALLOTNAME", "WORKCENTERNAME", "TXNDATE"], ignore_index=True)
|
||||
_check_memory_guard(result)
|
||||
return result
|
||||
|
||||
|
||||
def _paginate(df: pd.DataFrame, page: int, per_page: int) -> Dict[str, Any]:
|
||||
"""Apply pagination to a DataFrame and return paginated dict."""
|
||||
total = len(df)
|
||||
total_pages = max(1, (total + per_page - 1) // per_page)
|
||||
page = min(page, total_pages)
|
||||
start = (page - 1) * per_page
|
||||
end = start + per_page
|
||||
page_df = df.iloc[start:end]
|
||||
|
||||
# Replace NaN/NaT with None so JSON serialization produces null (not NaN).
|
||||
# Must convert to object dtype first — float64 columns coerce None back to NaN.
|
||||
page_df = page_df.astype(object).where(page_df.notna(), None)
|
||||
|
||||
return {
|
||||
"rows": page_df.to_dict("records"),
|
||||
"pagination": {
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"total": total,
|
||||
"total_pages": total_pages,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Forward query (LOT ID / Work Order → Materials)
|
||||
# ============================================================
|
||||
|
||||
|
||||
def forward_query(
|
||||
mode: str,
|
||||
values: List[str],
|
||||
workcenter_groups: Optional[List[str]] = None,
|
||||
page: int = 1,
|
||||
per_page: int = 50,
|
||||
) -> Dict[str, Any]:
|
||||
"""Execute forward material trace query."""
|
||||
meta: Dict[str, Any] = {}
|
||||
wc_names = _resolve_workcenter_names(workcenter_groups)
|
||||
|
||||
if mode == "lot":
|
||||
container_ids, _name_map, unresolved = _resolve_container_ids(values)
|
||||
if unresolved:
|
||||
meta["unresolved"] = unresolved
|
||||
if not container_ids:
|
||||
return {"rows": [], "pagination": {"page": 1, "per_page": per_page, "total": 0, "total_pages": 0}, "meta": meta}
|
||||
|
||||
df = _execute_batched_query("material_trace/forward_by_lot", "m.CONTAINERID", container_ids, wc_names, allow_patterns=False)
|
||||
|
||||
else: # workorder
|
||||
df = _execute_batched_query("material_trace/forward_by_workorder", "m.PJ_WORKORDER", values, wc_names)
|
||||
|
||||
if df.empty:
|
||||
return {"rows": [], "pagination": {"page": 1, "per_page": per_page, "total": 0, "total_pages": 0}, "meta": meta}
|
||||
|
||||
df = _enrich_workcenter_group(df)
|
||||
result = _paginate(df, page, per_page)
|
||||
result["meta"] = meta
|
||||
return result
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Reverse query (Material Lot → LOTs)
|
||||
# ============================================================
|
||||
|
||||
|
||||
def reverse_query(
|
||||
values: List[str],
|
||||
workcenter_groups: Optional[List[str]] = None,
|
||||
page: int = 1,
|
||||
per_page: int = 50,
|
||||
) -> Dict[str, Any]:
|
||||
"""Execute reverse material trace query."""
|
||||
meta: Dict[str, Any] = {}
|
||||
wc_names = _resolve_workcenter_names(workcenter_groups)
|
||||
|
||||
df = _execute_batched_query("material_trace/reverse_by_material_lot", "m.MATERIALLOTNAME", values, wc_names)
|
||||
|
||||
if df.empty:
|
||||
return {"rows": [], "pagination": {"page": 1, "per_page": per_page, "total": 0, "total_pages": 0}, "meta": meta}
|
||||
|
||||
# Check truncation (SQL fetches 10001 rows to detect overflow)
|
||||
if len(df) > _REVERSE_MAX_ROWS:
|
||||
df = df.iloc[:_REVERSE_MAX_ROWS]
|
||||
meta["truncated"] = True
|
||||
meta["max_rows"] = _REVERSE_MAX_ROWS
|
||||
|
||||
df = _enrich_workcenter_group(df)
|
||||
result = _paginate(df, page, per_page)
|
||||
result["meta"] = meta
|
||||
return result
|
||||
|
||||
|
||||
# ============================================================
|
||||
# CSV Export
|
||||
# ============================================================
|
||||
|
||||
|
||||
def export_csv(
|
||||
mode: str,
|
||||
values: List[str],
|
||||
workcenter_groups: Optional[List[str]] = None,
|
||||
) -> tuple[bytes, Dict[str, Any]]:
|
||||
"""Export query results as UTF-8 BOM CSV."""
|
||||
meta: Dict[str, Any] = {}
|
||||
wc_names = _resolve_workcenter_names(workcenter_groups)
|
||||
|
||||
if mode == "lot":
|
||||
container_ids, _name_map, unresolved = _resolve_container_ids(values)
|
||||
if unresolved:
|
||||
meta["unresolved"] = unresolved
|
||||
if not container_ids:
|
||||
return _empty_csv(), meta
|
||||
|
||||
df = _execute_batched_query("material_trace/forward_by_lot", "m.CONTAINERID", container_ids, wc_names, allow_patterns=False)
|
||||
|
||||
elif mode == "workorder":
|
||||
df = _execute_batched_query("material_trace/forward_by_workorder", "m.PJ_WORKORDER", values, wc_names)
|
||||
|
||||
else: # material_lot
|
||||
df = _execute_batched_query("material_trace/reverse_by_material_lot", "m.MATERIALLOTNAME", values, wc_names)
|
||||
|
||||
if df.empty:
|
||||
return _empty_csv(), meta
|
||||
|
||||
# Truncate if over export limit
|
||||
if len(df) > _EXPORT_MAX_ROWS:
|
||||
df = df.iloc[:_EXPORT_MAX_ROWS]
|
||||
meta["truncated"] = True
|
||||
meta["export_max_rows"] = _EXPORT_MAX_ROWS
|
||||
|
||||
df = _enrich_workcenter_group(df)
|
||||
|
||||
# Select and rename columns for CSV
|
||||
available_cols = [c for c in _CSV_COLUMNS if c in df.columns]
|
||||
export_df = df[available_cols].rename(columns=_CSV_COLUMNS)
|
||||
|
||||
buf = io.BytesIO()
|
||||
buf.write(b"\xef\xbb\xbf") # UTF-8 BOM
|
||||
buf.write(export_df.fillna("").to_csv(index=False).encode("utf-8"))
|
||||
return buf.getvalue(), meta
|
||||
|
||||
|
||||
def _empty_csv() -> bytes:
|
||||
"""Return an empty CSV with headers only."""
|
||||
buf = io.BytesIO()
|
||||
buf.write(b"\xef\xbb\xbf")
|
||||
headers = ",".join(_CSV_COLUMNS.values()) + "\n"
|
||||
buf.write(headers.encode("utf-8"))
|
||||
return buf.getvalue()
|
||||
29
src/mes_dashboard/sql/material_trace/forward_by_lot.sql
Normal file
29
src/mes_dashboard/sql/material_trace/forward_by_lot.sql
Normal file
@@ -0,0 +1,29 @@
|
||||
-- Forward Material Trace by LOT (CONTAINERID)
|
||||
-- Retrieves material consumption records for given CONTAINERIDs
|
||||
--
|
||||
-- Parameters:
|
||||
-- Bind variables generated by QueryBuilder.add_in_condition()
|
||||
-- for CONTAINERID IN (:p0, :p1, ...)
|
||||
--
|
||||
-- Template slots:
|
||||
-- {{ WHERE_CLAUSE }} - Dynamic WHERE with CONTAINERID IN + optional WORKCENTERNAME IN
|
||||
|
||||
SELECT
|
||||
m.CONTAINERID,
|
||||
c.CONTAINERNAME,
|
||||
m.PJ_WORKORDER,
|
||||
m.WORKCENTERNAME,
|
||||
m.MATERIALPARTNAME,
|
||||
m.MATERIALLOTNAME,
|
||||
m.VENDORLOTNUMBER,
|
||||
m.QTYREQUIRED,
|
||||
m.QTYCONSUMED,
|
||||
m.EQUIPMENTNAME,
|
||||
m.TXNDATE,
|
||||
m.PRIMARY_CATEGORY,
|
||||
m.SECONDARY_CATEGORY
|
||||
FROM DWH.DW_MES_LOTMATERIALSHISTORY m
|
||||
LEFT JOIN DWH.DW_MES_CONTAINER c
|
||||
ON c.CONTAINERID = m.CONTAINERID
|
||||
{{ WHERE_CLAUSE }}
|
||||
ORDER BY m.TXNDATE DESC
|
||||
@@ -0,0 +1,29 @@
|
||||
-- Forward Material Trace by Work Order (PJ_WORKORDER)
|
||||
-- Retrieves material consumption records for given work orders
|
||||
--
|
||||
-- Parameters:
|
||||
-- Bind variables generated by QueryBuilder.add_in_condition()
|
||||
-- for PJ_WORKORDER IN (:p0, :p1, ...)
|
||||
--
|
||||
-- Template slots:
|
||||
-- {{ WHERE_CLAUSE }} - Dynamic WHERE with PJ_WORKORDER IN + optional WORKCENTERNAME IN
|
||||
|
||||
SELECT
|
||||
m.CONTAINERID,
|
||||
c.CONTAINERNAME,
|
||||
m.PJ_WORKORDER,
|
||||
m.WORKCENTERNAME,
|
||||
m.MATERIALPARTNAME,
|
||||
m.MATERIALLOTNAME,
|
||||
m.VENDORLOTNUMBER,
|
||||
m.QTYREQUIRED,
|
||||
m.QTYCONSUMED,
|
||||
m.EQUIPMENTNAME,
|
||||
m.TXNDATE,
|
||||
m.PRIMARY_CATEGORY,
|
||||
m.SECONDARY_CATEGORY
|
||||
FROM DWH.DW_MES_LOTMATERIALSHISTORY m
|
||||
LEFT JOIN DWH.DW_MES_CONTAINER c
|
||||
ON c.CONTAINERID = m.CONTAINERID
|
||||
{{ WHERE_CLAUSE }}
|
||||
ORDER BY m.TXNDATE DESC
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Resolve CONTAINERNAME to CONTAINERID
|
||||
-- Batch lookup for LOT ID -> CONTAINERID conversion
|
||||
--
|
||||
-- Parameters:
|
||||
-- Bind variables generated by QueryBuilder.add_in_condition()
|
||||
-- for CONTAINERNAME IN (:p0, :p1, ...)
|
||||
|
||||
SELECT
|
||||
c.CONTAINERID,
|
||||
c.CONTAINERNAME
|
||||
FROM DWH.DW_MES_CONTAINER c
|
||||
{{ WHERE_CLAUSE }}
|
||||
@@ -0,0 +1,32 @@
|
||||
-- Reverse Material Trace by Material Lot Name (MATERIALLOTNAME)
|
||||
-- Retrieves LOTs that consumed given material lot names
|
||||
--
|
||||
-- Parameters:
|
||||
-- Bind variables generated by QueryBuilder.add_in_condition()
|
||||
-- for MATERIALLOTNAME IN (:p0, :p1, ...)
|
||||
--
|
||||
-- Template slots:
|
||||
-- {{ WHERE_CLAUSE }} - Dynamic WHERE with MATERIALLOTNAME IN + optional WORKCENTERNAME IN
|
||||
--
|
||||
-- Note: FETCH FIRST 10001 ROWS ONLY to detect truncation (> 10000)
|
||||
|
||||
SELECT
|
||||
m.CONTAINERID,
|
||||
c.CONTAINERNAME,
|
||||
m.PJ_WORKORDER,
|
||||
m.WORKCENTERNAME,
|
||||
m.MATERIALPARTNAME,
|
||||
m.MATERIALLOTNAME,
|
||||
m.VENDORLOTNUMBER,
|
||||
m.QTYREQUIRED,
|
||||
m.QTYCONSUMED,
|
||||
m.EQUIPMENTNAME,
|
||||
m.TXNDATE,
|
||||
m.PRIMARY_CATEGORY,
|
||||
m.SECONDARY_CATEGORY
|
||||
FROM DWH.DW_MES_LOTMATERIALSHISTORY m
|
||||
LEFT JOIN DWH.DW_MES_CONTAINER c
|
||||
ON c.CONTAINERID = m.CONTAINERID
|
||||
{{ WHERE_CLAUSE }}
|
||||
ORDER BY m.TXNDATE DESC
|
||||
FETCH FIRST 10001 ROWS ONLY
|
||||
276
tests/test_material_trace_routes.py
Normal file
276
tests/test_material_trace_routes.py
Normal file
@@ -0,0 +1,276 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Integration tests for Material Trace API routes.
|
||||
|
||||
Tests input validation, pagination structure, and CSV export.
|
||||
"""
|
||||
|
||||
import json
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from mes_dashboard import create_app
|
||||
from mes_dashboard.core.cache import NoOpCache
|
||||
from mes_dashboard.core.rate_limit import reset_rate_limits_for_tests
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
"""Create test Flask application."""
|
||||
app = create_app()
|
||||
app.config["TESTING"] = True
|
||||
app.extensions["cache"] = NoOpCache()
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(app):
|
||||
"""Create test client."""
|
||||
return app.test_client()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_rate_limits():
|
||||
reset_rate_limits_for_tests()
|
||||
yield
|
||||
reset_rate_limits_for_tests()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 7.6 Input validation → HTTP 400
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestQueryValidation:
|
||||
def test_missing_mode_returns_400(self, client):
|
||||
response = client.post(
|
||||
"/api/material-trace/query",
|
||||
data=json.dumps({"values": ["LOT-A"]}),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == 400
|
||||
payload = response.get_json()
|
||||
assert payload["success"] is False
|
||||
assert "無效的查詢模式" in payload["error"]
|
||||
|
||||
def test_invalid_mode_returns_400(self, client):
|
||||
response = client.post(
|
||||
"/api/material-trace/query",
|
||||
data=json.dumps({"mode": "invalid", "values": ["LOT-A"]}),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "無效的查詢模式" in response.get_json()["error"]
|
||||
|
||||
def test_empty_values_returns_400(self, client):
|
||||
response = client.post(
|
||||
"/api/material-trace/query",
|
||||
data=json.dumps({"mode": "lot", "values": []}),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "請輸入至少一筆" in response.get_json()["error"]
|
||||
|
||||
def test_blank_values_returns_400(self, client):
|
||||
response = client.post(
|
||||
"/api/material-trace/query",
|
||||
data=json.dumps({"mode": "lot", "values": ["", " "]}),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "請輸入至少一筆" in response.get_json()["error"]
|
||||
|
||||
def test_forward_over_200_returns_400(self, client):
|
||||
values = [f"LOT-{i}" for i in range(201)]
|
||||
response = client.post(
|
||||
"/api/material-trace/query",
|
||||
data=json.dumps({"mode": "lot", "values": values}),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "正向查詢上限 200 筆" in response.get_json()["error"]
|
||||
|
||||
def test_workorder_over_200_returns_400(self, client):
|
||||
values = [f"WO-{i}" for i in range(201)]
|
||||
response = client.post(
|
||||
"/api/material-trace/query",
|
||||
data=json.dumps({"mode": "workorder", "values": values}),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "正向查詢上限 200 筆" in response.get_json()["error"]
|
||||
|
||||
def test_reverse_over_50_returns_400(self, client):
|
||||
values = [f"MLOT-{i}" for i in range(51)]
|
||||
response = client.post(
|
||||
"/api/material-trace/query",
|
||||
data=json.dumps({"mode": "material_lot", "values": values}),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "反向查詢上限 50 筆" in response.get_json()["error"]
|
||||
|
||||
def test_non_json_returns_415(self, client):
|
||||
response = client.post(
|
||||
"/api/material-trace/query",
|
||||
data="plain text",
|
||||
content_type="text/plain",
|
||||
)
|
||||
assert response.status_code == 415
|
||||
|
||||
def test_empty_body_returns_400(self, client):
|
||||
response = client.post(
|
||||
"/api/material-trace/query",
|
||||
data=json.dumps({}),
|
||||
content_type="application/json",
|
||||
)
|
||||
# Empty object triggers require_non_empty_object
|
||||
assert response.status_code in (400, 415)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 7.7 Query endpoint — correct pagination structure
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestQueryPagination:
|
||||
@patch("mes_dashboard.routes.material_trace_routes.forward_query")
|
||||
def test_query_returns_pagination_structure(self, mock_fwd, client):
|
||||
mock_fwd.return_value = {
|
||||
"rows": [{"CONTAINERNAME": "LOT-1", "PJ_WORKORDER": "WO-1"}],
|
||||
"pagination": {
|
||||
"page": 1,
|
||||
"per_page": 50,
|
||||
"total": 100,
|
||||
"total_pages": 2,
|
||||
},
|
||||
"meta": {},
|
||||
}
|
||||
|
||||
response = client.post(
|
||||
"/api/material-trace/query",
|
||||
data=json.dumps({"mode": "workorder", "values": ["WO-001"]}),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.get_json()
|
||||
assert payload["success"] is True
|
||||
assert "pagination" in payload
|
||||
pag = payload["pagination"]
|
||||
assert pag["page"] == 1
|
||||
assert pag["per_page"] == 50
|
||||
assert pag["total"] == 100
|
||||
assert pag["total_pages"] == 2
|
||||
assert len(payload["rows"]) == 1
|
||||
|
||||
@patch("mes_dashboard.routes.material_trace_routes.forward_query")
|
||||
def test_query_passes_page_param(self, mock_fwd, client):
|
||||
mock_fwd.return_value = {
|
||||
"rows": [],
|
||||
"pagination": {"page": 3, "per_page": 50, "total": 200, "total_pages": 4},
|
||||
"meta": {},
|
||||
}
|
||||
|
||||
response = client.post(
|
||||
"/api/material-trace/query",
|
||||
data=json.dumps({"mode": "workorder", "values": ["WO-001"], "page": 3}),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
mock_fwd.assert_called_once()
|
||||
call_kwargs = mock_fwd.call_args
|
||||
# page should be 3
|
||||
assert call_kwargs[0][3] == 3 or call_kwargs.kwargs.get("page") == 3
|
||||
|
||||
@patch("mes_dashboard.routes.material_trace_routes.reverse_query")
|
||||
def test_reverse_mode_dispatches_correctly(self, mock_rev, client):
|
||||
mock_rev.return_value = {
|
||||
"rows": [],
|
||||
"pagination": {"page": 1, "per_page": 50, "total": 0, "total_pages": 0},
|
||||
"meta": {},
|
||||
}
|
||||
|
||||
response = client.post(
|
||||
"/api/material-trace/query",
|
||||
data=json.dumps({"mode": "material_lot", "values": ["MLOT-A"]}),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
mock_rev.assert_called_once()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 7.8 Export endpoint — CSV content-type and UTF-8 BOM
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestExportEndpoint:
|
||||
@patch("mes_dashboard.routes.material_trace_routes.export_csv")
|
||||
def test_export_returns_csv_content_type(self, mock_export, client):
|
||||
csv_content = b"\xef\xbb\xbfLOT ID,\xe5\xb7\xa5\xe5\x96\xae\n"
|
||||
mock_export.return_value = (csv_content, {})
|
||||
|
||||
response = client.post(
|
||||
"/api/material-trace/export",
|
||||
data=json.dumps({"mode": "workorder", "values": ["WO-001"]}),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "text/csv" in response.content_type
|
||||
# Check UTF-8 BOM
|
||||
assert response.data[:3] == b"\xef\xbb\xbf"
|
||||
|
||||
@patch("mes_dashboard.routes.material_trace_routes.export_csv")
|
||||
def test_export_truncated_sets_header(self, mock_export, client):
|
||||
csv_content = b"\xef\xbb\xbfheader\nrow\n"
|
||||
mock_export.return_value = (csv_content, {"truncated": True, "export_max_rows": 50000})
|
||||
|
||||
response = client.post(
|
||||
"/api/material-trace/export",
|
||||
data=json.dumps({"mode": "workorder", "values": ["WO-001"]}),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers.get("X-Truncated") == "true"
|
||||
|
||||
def test_export_validation_same_as_query(self, client):
|
||||
"""Export should reject invalid mode same as query."""
|
||||
response = client.post(
|
||||
"/api/material-trace/export",
|
||||
data=json.dumps({"mode": "invalid", "values": ["X"]}),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Filter options endpoint
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestFilterOptions:
|
||||
@patch("mes_dashboard.routes.material_trace_routes.get_workcenter_groups")
|
||||
def test_filter_options_returns_groups(self, mock_groups, client):
|
||||
mock_groups.return_value = [
|
||||
{"name": "焊接_DB", "sequence": 1},
|
||||
{"name": "焊線_WB", "sequence": 2},
|
||||
]
|
||||
|
||||
response = client.get("/api/material-trace/filter-options")
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.get_json()
|
||||
assert payload["success"] is True
|
||||
assert payload["data"]["workcenter_groups"] == ["焊接_DB", "焊線_WB"]
|
||||
|
||||
@patch("mes_dashboard.routes.material_trace_routes.get_workcenter_groups")
|
||||
def test_filter_options_unavailable_returns_503(self, mock_groups, client):
|
||||
mock_groups.return_value = None
|
||||
|
||||
response = client.get("/api/material-trace/filter-options")
|
||||
|
||||
assert response.status_code == 503
|
||||
350
tests/test_material_trace_service.py
Normal file
350
tests/test_material_trace_service.py
Normal file
@@ -0,0 +1,350 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Unit tests for material_trace_service.
|
||||
|
||||
Tests cover forward/reverse query logic, CONTAINERID resolution,
|
||||
workcenter group enrichment/filtering, truncation, CSV export,
|
||||
safeguards (memory guard, batched queries), and wildcard support.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock, call
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from mes_dashboard.services.material_trace_service import (
|
||||
_add_exact_or_pattern_condition,
|
||||
_enrich_workcenter_group,
|
||||
_is_pattern_token,
|
||||
_resolve_container_ids,
|
||||
_resolve_workcenter_names,
|
||||
_check_memory_guard,
|
||||
_IN_BATCH_SIZE,
|
||||
export_csv,
|
||||
forward_query,
|
||||
reverse_query,
|
||||
)
|
||||
from mes_dashboard.sql import QueryBuilder
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Fixtures
|
||||
# ============================================================
|
||||
|
||||
MOCK_WORKCENTER_MAPPING = {
|
||||
"WC_DB_1": {"group": "焊接_DB", "sequence": 1},
|
||||
"WC_DB_2": {"group": "焊接_DB", "sequence": 1},
|
||||
"WC_WB_1": {"group": "焊線_WB", "sequence": 2},
|
||||
"WC_MOLD_1": {"group": "封膠_Mold", "sequence": 3},
|
||||
}
|
||||
|
||||
|
||||
def _make_material_df(n=3, workcenter="WC_DB_1"):
|
||||
"""Create a sample material consumption DataFrame."""
|
||||
return pd.DataFrame(
|
||||
{
|
||||
"CONTAINERID": [f"CID{i:016d}" for i in range(n)],
|
||||
"CONTAINERNAME": [f"LOT-{i:04d}" for i in range(n)],
|
||||
"PJ_WORKORDER": [f"WO-{i}" for i in range(n)],
|
||||
"WORKCENTERNAME": [workcenter] * n,
|
||||
"MATERIALPARTNAME": [f"MAT-{i}" for i in range(n)],
|
||||
"MATERIALLOTNAME": [f"MLOT-{i}" for i in range(n)],
|
||||
"VENDORLOTNUMBER": [f"VL-{i}" for i in range(n)],
|
||||
"QTYREQUIRED": [10.0] * n,
|
||||
"QTYCONSUMED": [9.5] * n,
|
||||
"EQUIPMENTNAME": [f"EQ-{i}" for i in range(n)],
|
||||
"TXNDATE": ["2025-06-01"] * n,
|
||||
"PRIMARY_CATEGORY": ["CAT_A"] * n,
|
||||
"SECONDARY_CATEGORY": ["SUB_1"] * n,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _make_resolve_df(lot_names):
|
||||
"""Create a DataFrame simulating DW_MES_CONTAINER resolve result."""
|
||||
rows = []
|
||||
for name in lot_names:
|
||||
rows.append({"CONTAINERID": f"CID_{name}", "CONTAINERNAME": name})
|
||||
return pd.DataFrame(rows)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 7.1 Forward LOT mode — resolve + enrichment
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestForwardLotQuery:
|
||||
@patch("mes_dashboard.services.material_trace_service.read_sql_df_slow")
|
||||
@patch("mes_dashboard.services.material_trace_service.read_sql_df")
|
||||
@patch("mes_dashboard.services.material_trace_service.get_workcenter_mapping")
|
||||
def test_forward_lot_resolves_and_enriches(self, mock_mapping, mock_sql, mock_sql_slow):
|
||||
mock_mapping.return_value = MOCK_WORKCENTER_MAPPING
|
||||
mock_sql.return_value = _make_resolve_df(["LOT-A", "LOT-B"])
|
||||
mock_sql_slow.return_value = _make_material_df(5)
|
||||
|
||||
result = forward_query("lot", ["LOT-A", "LOT-B"], page=1, per_page=50)
|
||||
|
||||
assert result["pagination"]["total"] == 5
|
||||
assert len(result["rows"]) == 5
|
||||
assert result["rows"][0]["WORKCENTER_GROUP"] == "焊接_DB"
|
||||
assert result["meta"] == {}
|
||||
assert mock_sql.call_count == 1
|
||||
assert mock_sql_slow.call_count == 1
|
||||
|
||||
@patch("mes_dashboard.services.material_trace_service.read_sql_df_slow")
|
||||
@patch("mes_dashboard.services.material_trace_service.read_sql_df")
|
||||
@patch("mes_dashboard.services.material_trace_service.get_workcenter_mapping")
|
||||
def test_forward_lot_all_unresolved_returns_empty(self, mock_mapping, mock_sql, mock_sql_slow):
|
||||
mock_mapping.return_value = MOCK_WORKCENTER_MAPPING
|
||||
mock_sql.return_value = pd.DataFrame()
|
||||
|
||||
result = forward_query("lot", ["UNKNOWN-LOT"], page=1, per_page=50)
|
||||
|
||||
assert result["rows"] == []
|
||||
assert result["pagination"]["total"] == 0
|
||||
assert result["meta"]["unresolved"] == ["UNKNOWN-LOT"]
|
||||
mock_sql_slow.assert_not_called()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 7.2 Forward work order mode
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestForwardWorkorderQuery:
|
||||
@patch("mes_dashboard.services.material_trace_service.read_sql_df_slow")
|
||||
@patch("mes_dashboard.services.material_trace_service.get_workcenter_mapping")
|
||||
def test_forward_workorder_queries_directly(self, mock_mapping, mock_sql_slow):
|
||||
mock_mapping.return_value = MOCK_WORKCENTER_MAPPING
|
||||
mock_sql_slow.return_value = _make_material_df(3)
|
||||
|
||||
result = forward_query("workorder", ["WO-2025-001"], page=1, per_page=50)
|
||||
|
||||
assert result["pagination"]["total"] == 3
|
||||
assert len(result["rows"]) == 3
|
||||
assert mock_sql_slow.call_count == 1
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 7.3 Reverse query — truncation logic
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestReverseQuery:
|
||||
@patch("mes_dashboard.services.material_trace_service.read_sql_df_slow")
|
||||
@patch("mes_dashboard.services.material_trace_service.get_workcenter_mapping")
|
||||
def test_reverse_truncation_at_10000(self, mock_mapping, mock_sql_slow):
|
||||
mock_mapping.return_value = MOCK_WORKCENTER_MAPPING
|
||||
mock_sql_slow.return_value = _make_material_df(10001)
|
||||
|
||||
result = reverse_query(["MLOT-A"], page=1, per_page=50)
|
||||
|
||||
assert result["meta"]["truncated"] is True
|
||||
assert result["meta"]["max_rows"] == 10000
|
||||
assert result["pagination"]["total"] == 10000
|
||||
|
||||
@patch("mes_dashboard.services.material_trace_service.read_sql_df_slow")
|
||||
@patch("mes_dashboard.services.material_trace_service.get_workcenter_mapping")
|
||||
def test_reverse_no_truncation_under_limit(self, mock_mapping, mock_sql_slow):
|
||||
mock_mapping.return_value = MOCK_WORKCENTER_MAPPING
|
||||
mock_sql_slow.return_value = _make_material_df(500)
|
||||
|
||||
result = reverse_query(["MLOT-A"], page=1, per_page=50)
|
||||
|
||||
assert "truncated" not in result["meta"]
|
||||
assert result["pagination"]["total"] == 500
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 7.4 Workcenter group filtering
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestWorkcenterGroupFilter:
|
||||
@patch("mes_dashboard.services.material_trace_service.read_sql_df_slow")
|
||||
@patch("mes_dashboard.services.material_trace_service.get_workcenters_for_groups")
|
||||
@patch("mes_dashboard.services.material_trace_service.get_workcenter_mapping")
|
||||
def test_workcenter_group_resolves_to_names(self, mock_mapping, mock_for_groups, mock_sql_slow):
|
||||
mock_mapping.return_value = MOCK_WORKCENTER_MAPPING
|
||||
mock_for_groups.return_value = ["WC_DB_1", "WC_DB_2"]
|
||||
mock_sql_slow.return_value = _make_material_df(3)
|
||||
|
||||
result = forward_query(
|
||||
"workorder", ["WO-001"], workcenter_groups=["焊接_DB"], page=1, per_page=50
|
||||
)
|
||||
|
||||
mock_for_groups.assert_called_once_with(["焊接_DB"])
|
||||
sql_call = mock_sql_slow.call_args
|
||||
sql_text = sql_call[0][0]
|
||||
assert "WORKCENTERNAME IN" in sql_text
|
||||
assert result["pagination"]["total"] == 3
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 7.5 Unresolved LOT IDs
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestUnresolvedLots:
|
||||
@patch("mes_dashboard.services.material_trace_service.read_sql_df_slow")
|
||||
@patch("mes_dashboard.services.material_trace_service.read_sql_df")
|
||||
@patch("mes_dashboard.services.material_trace_service.get_workcenter_mapping")
|
||||
def test_partial_resolve_reports_unresolved(self, mock_mapping, mock_sql, mock_sql_slow):
|
||||
mock_mapping.return_value = MOCK_WORKCENTER_MAPPING
|
||||
resolve_df = pd.DataFrame(
|
||||
[{"CONTAINERID": "CID_LOT_A", "CONTAINERNAME": "LOT-A"}]
|
||||
)
|
||||
mock_sql.return_value = resolve_df
|
||||
mock_sql_slow.return_value = _make_material_df(2)
|
||||
|
||||
result = forward_query("lot", ["LOT-A", "LOT-B"], page=1, per_page=50)
|
||||
|
||||
assert result["meta"]["unresolved"] == ["LOT-B"]
|
||||
assert result["pagination"]["total"] == 2
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Enrichment helper
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestEnrichWorkcenterGroup:
|
||||
def test_enrich_maps_correctly(self):
|
||||
df = pd.DataFrame({"WORKCENTERNAME": ["WC_DB_1", "WC_WB_1", "UNKNOWN"]})
|
||||
with patch(
|
||||
"mes_dashboard.services.material_trace_service.get_workcenter_mapping"
|
||||
) as mock:
|
||||
mock.return_value = MOCK_WORKCENTER_MAPPING
|
||||
result = _enrich_workcenter_group(df)
|
||||
|
||||
assert list(result["WORKCENTER_GROUP"]) == ["焊接_DB", "焊線_WB", ""]
|
||||
|
||||
|
||||
# ============================================================
|
||||
# CSV export
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestExportCsv:
|
||||
@patch("mes_dashboard.services.material_trace_service.read_sql_df_slow")
|
||||
@patch("mes_dashboard.services.material_trace_service.get_workcenter_mapping")
|
||||
def test_export_returns_utf8_bom_csv(self, mock_mapping, mock_sql_slow):
|
||||
mock_mapping.return_value = MOCK_WORKCENTER_MAPPING
|
||||
mock_sql_slow.return_value = _make_material_df(3)
|
||||
|
||||
csv_bytes, meta = export_csv("workorder", ["WO-001"])
|
||||
|
||||
assert csv_bytes[:3] == b"\xef\xbb\xbf"
|
||||
csv_text = csv_bytes.decode("utf-8-sig")
|
||||
assert "LOT ID" in csv_text
|
||||
assert "料號" in csv_text
|
||||
lines = csv_text.strip().split("\n")
|
||||
assert len(lines) == 4
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Safeguards: memory guard
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestMemoryGuard:
|
||||
def test_memory_guard_raises_on_large_df(self):
|
||||
with patch("mes_dashboard.services.material_trace_service._MAX_RESULT_MB", 0):
|
||||
df = _make_material_df(10)
|
||||
with pytest.raises(MemoryError, match="超過.*上限"):
|
||||
_check_memory_guard(df)
|
||||
|
||||
def test_memory_guard_passes_small_df(self):
|
||||
df = _make_material_df(5)
|
||||
_check_memory_guard(df)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Safeguards: IN-clause batching
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestInClauseBatching:
|
||||
@patch("mes_dashboard.services.material_trace_service.read_sql_df_slow")
|
||||
@patch("mes_dashboard.services.material_trace_service.get_workcenter_mapping")
|
||||
def test_large_input_is_batched(self, mock_mapping, mock_sql_slow):
|
||||
mock_mapping.return_value = MOCK_WORKCENTER_MAPPING
|
||||
mock_sql_slow.return_value = _make_material_df(3)
|
||||
|
||||
values = [f"WO-{i}" for i in range(1500)]
|
||||
result = forward_query("workorder", values, page=1, per_page=50)
|
||||
|
||||
assert mock_sql_slow.call_count == 2
|
||||
assert result["pagination"]["total"] > 0
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Wildcard support
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestWildcardHelpers:
|
||||
def test_is_pattern_token_with_star(self):
|
||||
assert _is_pattern_token("GA250605*") is True
|
||||
|
||||
def test_is_pattern_token_with_percent(self):
|
||||
assert _is_pattern_token("GA250605%") is True
|
||||
|
||||
def test_is_pattern_token_exact(self):
|
||||
assert _is_pattern_token("GA25060001-A01") is False
|
||||
|
||||
def test_add_exact_or_pattern_mixed(self):
|
||||
"""Mixed exact + wildcard values produce IN + LIKE conditions."""
|
||||
builder = QueryBuilder(base_sql="SELECT 1 FROM t {{ WHERE_CLAUSE }}")
|
||||
_add_exact_or_pattern_condition(builder, "col", ["EXACT-1", "WILD*"])
|
||||
sql, params = builder.build()
|
||||
assert "IN" in sql
|
||||
assert "LIKE" in sql
|
||||
# Wildcard normalized: * → %
|
||||
like_params = [v for v in params.values() if "%" in str(v)]
|
||||
assert len(like_params) == 1
|
||||
assert like_params[0] == "WILD%"
|
||||
|
||||
|
||||
class TestWildcardResolve:
|
||||
@patch("mes_dashboard.services.material_trace_service.read_sql_df")
|
||||
def test_wildcard_resolve_generates_like(self, mock_sql):
|
||||
"""Wildcard LOT names produce LIKE clause in resolve SQL."""
|
||||
mock_sql.return_value = _make_resolve_df(["LOT-A001"])
|
||||
|
||||
_resolve_container_ids(["LOT-A*"])
|
||||
|
||||
sql_text = mock_sql.call_args[0][0]
|
||||
assert "LIKE" in sql_text
|
||||
|
||||
@patch("mes_dashboard.services.material_trace_service.read_sql_df")
|
||||
def test_wildcard_not_reported_as_unresolved(self, mock_sql):
|
||||
"""Wildcard tokens that match 0 rows should NOT appear in unresolved."""
|
||||
mock_sql.return_value = pd.DataFrame()
|
||||
|
||||
_, _, unresolved = _resolve_container_ids(["WILD*"])
|
||||
|
||||
# Wildcard tokens are not counted as unresolved
|
||||
assert unresolved == []
|
||||
|
||||
@patch("mes_dashboard.services.material_trace_service.read_sql_df")
|
||||
def test_exact_unresolved_still_reported(self, mock_sql):
|
||||
"""Exact tokens that don't resolve ARE reported as unresolved."""
|
||||
mock_sql.return_value = pd.DataFrame()
|
||||
|
||||
_, _, unresolved = _resolve_container_ids(["EXACT-MISSING"])
|
||||
|
||||
assert unresolved == ["EXACT-MISSING"]
|
||||
|
||||
|
||||
class TestWildcardWorkorder:
|
||||
@patch("mes_dashboard.services.material_trace_service.read_sql_df_slow")
|
||||
@patch("mes_dashboard.services.material_trace_service.get_workcenter_mapping")
|
||||
def test_workorder_wildcard_generates_like(self, mock_mapping, mock_sql_slow):
|
||||
"""Wildcard work orders produce LIKE clause in query SQL."""
|
||||
mock_mapping.return_value = MOCK_WORKCENTER_MAPPING
|
||||
mock_sql_slow.return_value = _make_material_df(3)
|
||||
|
||||
forward_query("workorder", ["WO-2025*"], page=1, per_page=50)
|
||||
|
||||
sql_text = mock_sql_slow.call_args[0][0]
|
||||
assert "LIKE" in sql_text
|
||||
Reference in New Issue
Block a user