feat(mid-section-defect): add TMTT reverse traceability analysis with paginated detail API

New page for tracing TMTT test station defects back to upstream machines,
stations, and workflows. Three-stage data pipeline (TMTT detection →
SPLITFROMID BFS + COMBINEDASSYLOTS merge expansion → upstream history),
6 KPI cards, 6 Pareto charts, daily trend, paginated LOT detail table.

Summary/detail API separation reduces response from 72 MB to ~16 KB summary
+ ~110 KB/page detail. Loss reasons cached in Redis with 24h TTL (205 types).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
egg
2026-02-10 08:24:04 +08:00
parent 720e190bc6
commit 8b1b8da59b
25 changed files with 3157 additions and 9 deletions

View File

@@ -40,11 +40,13 @@
| WIP 三頁 Vue 3 遷移Overview/Detail/Hold Detail | ✅ 已完成 |
| 設備雙頁 Vue 3 遷移Status/History | ✅ 已完成 |
| 設備快取 DataFrame TTL 一致性修復 | ✅ 已完成 |
| 中段製程不良追溯分析TMTT → 上游) | ✅ 已完成 |
---
## 開發歷史Vite 重構後)
- 2026-02-10完成中段製程不良追溯分析`/mid-section-defect`)— TMTT 測試站不良回溯至上游機台/站點/製程。三段式資料管線TMTT 偵測 → SPLITFROMID BFS + COMBINEDASSYLOTS 合批展開 → 上游製程歷史),支援 205 種不良原因篩選、6 張 Pareto 圖表、日趨勢、LOT 明細分頁200 筆/頁。Loss reasons 24h Redis 快取、分析結果 5 分鐘快取、Detail API 分離summary ~16KB + detail ~110KB/page原 72MB 單次回應)。
- 2026-02-09完成設備雙頁 Vue 3 遷移(`/resource``/resource-history`)— 兩頁共 1,697 行 vanilla JS + 3,200 行 Jinja2 模板重寫為 Vue 3 SFC。抽取 `resource-shared/` 共用模組CSS 基底、E10 狀態常數、HierarchyTable 三層展開樹表元件History 頁 4 個 ECharts 圖表改用 vue-echartsStatus 頁複用 `useAutoRefresh` composable5 分鐘自動刷新)。
- 2026-02-09完成 WIP 三頁 Vue 3 遷移(`/wip-overview``/wip-detail``/hold-detail`)— 三頁共 1,941 行 vanilla JS + Jinja2 重寫為 Vue 3 SFC。抽取共用 CSS/常數/元件至 `wip-shared/`Pareto 圖改用 vue-echarts與 QC-GATE 一致Hold Detail 新增前端 URL params 判斷取代 Jinja2 注入。
- 2026-02-09完成數據表查詢頁面`/tables`Vue 3 遷移 — 第二個純 Vite 頁面,建立 `apiPost` POST 請求模式237 行 vanilla JS 重寫為 Vue 3 SFC 元件。
@@ -460,7 +462,7 @@ A: 請確認瀏覽器允許下載檔案,並檢查查詢結果是否有資料
透過側邊欄抽屜分組導覽切換各功能模組:
- **報表類**WIP 即時概況、設備即時概況、設備歷史績效、QC-GATE 即時狀態
- **查詢類**設備維修查詢、批次追蹤工具、TMTT 不良分析
- **查詢類**設備維修查詢、批次追蹤工具、TMTT 不良分析、中段製程不良追溯
- **開發工具**admin only數據表查詢、Excel 批次查詢、頁面管理、效能監控
- 抽屜/頁面配置可由管理員動態管理(新增、重排、刪除)
@@ -523,6 +525,21 @@ A: 請確認瀏覽器允許下載檔案,並檢查查詢結果是否有資料
- 站點排序依 DW_MES_SPEC_WORKCENTER_V 製程順序
- **技術架構**:第一個純 Vue 3 + Vite 頁面,完全脫離 Jinja2
### 中段製程不良追溯分析
- TMTT 測試站不良回溯至上游機台 / 站點 / 製程的反向追蹤分析
- 三段式資料管線:
1. TMTT 偵測LOTWIPHISTORY + LOTREJECTHISTORY
2. LOT 族譜解析CONTAINER.SPLITFROMID BFS 分批鏈 + PJ_COMBINEDASSYLOTS 合批展開)
3. 上游製程歷史LOTWIPHISTORY by ancestor CIDs
- 6 張 KPI 卡片 + 6 張 Pareto 圖表(依站點/不良原因/上游機台/TMTT 機台/製程/封裝歸因)
- 日趨勢折線圖 + LOT 明細分頁表200 筆/頁,伺服器端 DEFECT_RATE 降序排序)
- 不良原因多選篩選205 種24h Redis 快取)
- 分析結果 5 分鐘 Redis 快取summary API (~16 KB) 與 detail API (~110 KB/page) 分離
- CSV 串流匯出UTF-8 BOM完整明細
- 5 分鐘自動刷新 + visibilitychange 即時刷新
- **技術架構**Vue 3 + VitePareto/趨勢圖使用 vue-echarts複用 `wip-shared/` 的 Pagination/useAutoRefresh
### 數據表查詢工具
- 顯示所有 DWH 表格的分類卡片目錄(即時數據表/現況快照表/歷史累積表/輔助表)
@@ -588,8 +605,8 @@ CIRCUIT_BREAKER_RECOVERY_TIMEOUT=30
| 技術 | 用途 |
|------|------|
| Jinja2 | 模板引擎(既有頁面) |
| Vue 3 | UI 框架QC-GATE、Tables、WIP 三頁、設備雙頁已遷移,漸進式擴展中) |
| vue-echarts | ECharts Vue 封裝QC-GATE、WIP Overview Pareto、Resource History 4 圖表) |
| Vue 3 | UI 框架QC-GATE、Tables、WIP 三頁、設備雙頁、中段不良追溯已遷移,漸進式擴展中) |
| vue-echarts | ECharts Vue 封裝QC-GATE、WIP Overview Pareto、Resource History 4 圖表、Mid-Section Defect 7 圖表 |
| Vite 6 | 前端多頁模組打包(含 Vue SFC + HTML entry |
| ECharts | 圖表庫npm tree-shaking + 舊版靜態檔案並存) |
| Vanilla JS Modules | 互動功能與頁面邏輯(既有頁面) |
@@ -640,7 +657,8 @@ DashBoard_vite/
│ │ ├── dashboard/ # 儀表板查詢
│ │ ├── resource/ # 設備查詢
│ │ ├── wip/ # WIP 查詢
│ │ ── resource_history/ # 設備歷史查詢
│ │ ── resource_history/ # 設備歷史查詢
│ │ └── mid_section_defect/ # 中段不良追溯查詢
│ └── templates/ # HTML 模板
├── frontend/ # Vite 前端專案
│ ├── src/core/ # 共用 API/欄位契約/計算 helper
@@ -655,7 +673,8 @@ DashBoard_vite/
│ ├── src/wip-shared/ # WIP 三頁共用 CSS/常數/元件
│ ├── src/wip-overview/ # WIP 即時概況 (Vue 3 SFC)
│ ├── src/wip-detail/ # WIP 明細查詢 (Vue 3 SFC)
── src/hold-detail/ # Hold 狀態分析 (Vue 3 SFC)
── src/hold-detail/ # Hold 狀態分析 (Vue 3 SFC)
│ └── src/mid-section-defect/ # 中段不良追溯分析 (Vue 3 SFC)
├── shared/
│ └── field_contracts.json # 前後端共用欄位契約
├── scripts/ # 腳本
@@ -739,6 +758,22 @@ conda run -n mes-dashboard python scripts/run_cache_benchmarks.py --enforce
## 變更日誌
### 2026-02-10
- 新增中段製程不良追溯分析頁面(`/mid-section-defect`
- TMTT 測試站偵測到的不良,反向追蹤至上游機台/站點/製程的歸因分析
- 三段式資料管線TMTT 偵測 → LOT 族譜解析SPLITFROMID BFS 分批鏈 + COMBINEDASSYLOTS 合批展開)→ 上游製程歷史
- 6 張 KPI 卡片(投入/LOT數/不良數/不良率/首要原因/影響機台數)
- 6 張 Pareto 圖表(站點/不良原因/上游機台/TMTT 機台/製程/封裝歸因)+ 日趨勢折線
- 不良原因多選篩選205 種,全站 24h Redis 快取,`/api/mid-section-defect/loss-reasons`
- Detail API 分頁分離(`/api/mid-section-defect/analysis` summary ~16 KB + `/api/mid-section-defect/analysis/detail` ~110 KB/page原 72 MB 單次回應
- 伺服器端 DEFECT_RATE 降序排序 + 前端頁內欄位排序
- CSV 串流匯出UTF-8 BOM完整明細
- 進入頁面不自動查詢,點擊「查詢」後才執行;首次查詢後啟動 5 分鐘自動刷新
- Summary + Detail page 1 平行載入(`Promise.all`
- NaN 安全防護Oracle NULL → pandas NaN`isinstance(val, str)` 過濾)
- 技術架構Vue 3 + Vitevue-echarts 7 圖表,複用 `wip-shared/` Pagination/useAutoRefresh
### 2026-02-09
- 完成設備雙頁 Vue 3 遷移(`/resource`、`/resource-history`
@@ -879,5 +914,5 @@ conda run -n mes-dashboard python scripts/run_cache_benchmarks.py --enforce
---
**文檔版本**: 5.3
**最後更新**: 2026-02-09
**文檔版本**: 5.4
**最後更新**: 2026-02-10

View File

@@ -78,6 +78,13 @@
"drawer_id": "queries",
"order": 5
},
{
"route": "/mid-section-defect",
"name": "中段製程不良追溯",
"status": "dev",
"drawer_id": "queries",
"order": 6
},
{
"route": "/admin/pages",
"name": "頁面管理",

View File

@@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"dev": "vite --host",
"build": "vite build && cp ../src/mes_dashboard/static/dist/src/tables/index.html ../src/mes_dashboard/static/dist/tables.html && cp ../src/mes_dashboard/static/dist/src/qc-gate/index.html ../src/mes_dashboard/static/dist/qc-gate.html && cp ../src/mes_dashboard/static/dist/src/wip-overview/index.html ../src/mes_dashboard/static/dist/wip-overview.html && cp ../src/mes_dashboard/static/dist/src/wip-detail/index.html ../src/mes_dashboard/static/dist/wip-detail.html && cp ../src/mes_dashboard/static/dist/src/hold-detail/index.html ../src/mes_dashboard/static/dist/hold-detail.html && cp ../src/mes_dashboard/static/dist/src/resource-status/index.html ../src/mes_dashboard/static/dist/resource-status.html && cp ../src/mes_dashboard/static/dist/src/resource-history/index.html ../src/mes_dashboard/static/dist/resource-history.html",
"build": "vite build && cp ../src/mes_dashboard/static/dist/src/tables/index.html ../src/mes_dashboard/static/dist/tables.html && cp ../src/mes_dashboard/static/dist/src/qc-gate/index.html ../src/mes_dashboard/static/dist/qc-gate.html && cp ../src/mes_dashboard/static/dist/src/wip-overview/index.html ../src/mes_dashboard/static/dist/wip-overview.html && cp ../src/mes_dashboard/static/dist/src/wip-detail/index.html ../src/mes_dashboard/static/dist/wip-detail.html && cp ../src/mes_dashboard/static/dist/src/hold-detail/index.html ../src/mes_dashboard/static/dist/hold-detail.html && cp ../src/mes_dashboard/static/dist/src/resource-status/index.html ../src/mes_dashboard/static/dist/resource-status.html && cp ../src/mes_dashboard/static/dist/src/resource-history/index.html ../src/mes_dashboard/static/dist/resource-history.html && cp ../src/mes_dashboard/static/dist/src/mid-section-defect/index.html ../src/mes_dashboard/static/dist/mid-section-defect.html",
"test": "node --test tests/*.test.js"
},
"devDependencies": {

View File

@@ -0,0 +1,263 @@
<script setup>
import { reactive, ref } from 'vue';
import { apiGet, ensureMesApiAvailable } from '../core/api.js';
import { useAutoRefresh } from '../wip-shared/composables/useAutoRefresh.js';
import FilterBar from './components/FilterBar.vue';
import KpiCards from './components/KpiCards.vue';
import ParetoChart from './components/ParetoChart.vue';
import TrendChart from './components/TrendChart.vue';
import DetailTable from './components/DetailTable.vue';
ensureMesApiAvailable();
const API_TIMEOUT = 120000; // 2min (genealogy can be slow)
const PAGE_SIZE = 200;
const filters = reactive({
startDate: '',
endDate: '',
lossReasons: [],
});
const availableLossReasons = ref([]);
const analysisData = ref({
kpi: {},
charts: {},
daily_trend: [],
genealogy_status: 'ready',
detail_total_count: 0,
});
const detailData = ref([]);
const detailPagination = ref({
page: 1,
page_size: PAGE_SIZE,
total_count: 0,
total_pages: 1,
});
const detailLoading = ref(false);
const loading = reactive({
initial: false,
querying: false,
});
const hasQueried = ref(false);
const queryError = ref('');
function setDefaultDates() {
const today = new Date();
const endDate = new Date(today);
endDate.setDate(endDate.getDate() - 1);
const startDate = new Date(endDate);
startDate.setDate(startDate.getDate() - 6);
filters.startDate = toDateString(startDate);
filters.endDate = toDateString(endDate);
}
function toDateString(value) {
return value.toISOString().slice(0, 10);
}
function unwrapApiResult(result, fallbackMessage) {
if (result?.success === true) {
return result;
}
throw new Error(result?.error || fallbackMessage);
}
function buildFilterParams() {
const params = {
start_date: filters.startDate,
end_date: filters.endDate,
};
if (filters.lossReasons.length) {
params.loss_reasons = filters.lossReasons.join(',');
}
return params;
}
async function loadLossReasons() {
try {
const result = await apiGet('/api/mid-section-defect/loss-reasons');
const unwrapped = unwrapApiResult(result, '載入不良原因失敗');
availableLossReasons.value = unwrapped.data?.loss_reasons || [];
} catch {
// Non-blocking — dropdown will be empty until first query
}
}
async function loadDetail(page = 1) {
detailLoading.value = true;
try {
const params = {
...buildFilterParams(),
page,
page_size: PAGE_SIZE,
};
const result = await apiGet('/api/mid-section-defect/analysis/detail', {
params,
timeout: API_TIMEOUT,
});
const unwrapped = unwrapApiResult(result, '載入明細失敗');
detailData.value = unwrapped.data?.detail || [];
detailPagination.value = unwrapped.data?.pagination || {
page: 1, page_size: PAGE_SIZE, total_count: 0, total_pages: 1,
};
} catch (err) {
console.error('Detail load failed:', err.message);
detailData.value = [];
} finally {
detailLoading.value = false;
}
}
async function loadAnalysis() {
queryError.value = '';
loading.querying = true;
try {
const params = buildFilterParams();
// Fire summary and detail page 1 in parallel
const [summaryResult] = await Promise.all([
apiGet('/api/mid-section-defect/analysis', {
params,
timeout: API_TIMEOUT,
}),
loadDetail(1),
]);
const unwrapped = unwrapApiResult(summaryResult, '查詢失敗');
analysisData.value = unwrapped.data;
hasQueried.value = true;
// Start auto-refresh after first successful query
if (!autoRefreshStarted) {
autoRefreshStarted = true;
startAutoRefresh();
}
} catch (err) {
queryError.value = err.message || '查詢失敗,請稍後再試';
} finally {
loading.querying = false;
}
}
function handleUpdateFilters(updated) {
Object.assign(filters, updated);
}
function handleQuery() {
loadAnalysis();
}
function prevPage() {
if (detailPagination.value.page <= 1) return;
loadDetail(detailPagination.value.page - 1);
}
function nextPage() {
if (detailPagination.value.page >= detailPagination.value.total_pages) return;
loadDetail(detailPagination.value.page + 1);
}
function exportCsv() {
const params = new URLSearchParams({
start_date: filters.startDate,
end_date: filters.endDate,
});
if (filters.lossReasons.length) {
params.set('loss_reasons', filters.lossReasons.join(','));
}
const link = document.createElement('a');
link.href = `/api/mid-section-defect/export?${params}`;
link.download = `mid_section_defect_${filters.startDate}_to_${filters.endDate}.csv`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
let autoRefreshStarted = false;
const { startAutoRefresh } = useAutoRefresh({
onRefresh: () => loadAnalysis(),
intervalMs: 5 * 60 * 1000,
autoStart: false,
refreshOnVisible: true,
});
function initPage() {
setDefaultDates();
loadLossReasons();
}
void initPage();
</script>
<template>
<div class="page-container">
<header class="page-header">
<h1>中段製程不良追溯分析</h1>
<p class="header-desc">TMTT 測試站不良回溯至上游機台 / 站點 / 製程</p>
</header>
<FilterBar
:filters="filters"
:loading="loading.querying"
:available-loss-reasons="availableLossReasons"
@update-filters="handleUpdateFilters"
@query="handleQuery"
/>
<div v-if="queryError" class="error-banner">{{ queryError }}</div>
<template v-if="hasQueried">
<div v-if="analysisData.genealogy_status === 'error'" class="warning-banner">
追溯分析未完成genealogy 查詢失敗圖表僅顯示 TMTT 站點數據
</div>
<KpiCards :kpi="analysisData.kpi" :loading="loading.querying" />
<div class="charts-section">
<div class="charts-row">
<ParetoChart title="依站點歸因" :data="analysisData.charts?.by_station" />
<ParetoChart title="依不良原因" :data="analysisData.charts?.by_loss_reason" />
</div>
<div class="charts-row">
<ParetoChart title="依上游機台歸因" :data="analysisData.charts?.by_machine" />
<ParetoChart title="依 TMTT 機台" :data="analysisData.charts?.by_tmtt_machine" />
</div>
<div class="charts-row">
<ParetoChart title="依製程 (WORKFLOW)" :data="analysisData.charts?.by_workflow" />
<ParetoChart title="依封裝 (PACKAGE)" :data="analysisData.charts?.by_package" />
</div>
<div class="charts-row charts-row-full">
<TrendChart :data="analysisData.daily_trend" />
</div>
</div>
<DetailTable
:data="detailData"
:loading="detailLoading"
:pagination="detailPagination"
@export-csv="exportCsv"
@prev-page="prevPage"
@next-page="nextPage"
/>
</template>
<div v-else-if="!loading.querying" class="empty-state">
<p>請選擇日期範圍與不良原因點擊查詢開始分析</p>
</div>
<div class="loading-overlay" :class="{ hidden: !loading.querying }">
<div class="loading-spinner"></div>
</div>
</div>
</template>

View File

@@ -0,0 +1,139 @@
<script setup>
import { computed, ref } from 'vue';
import Pagination from '../../wip-shared/components/Pagination.vue';
const props = defineProps({
data: {
type: Array,
default: () => [],
},
loading: {
type: Boolean,
default: false,
},
pagination: {
type: Object,
default: () => ({ page: 1, page_size: 200, total_count: 0, total_pages: 1 }),
},
});
const emit = defineEmits(['export-csv', 'prev-page', 'next-page']);
const sortField = ref('DEFECT_RATE');
const sortAsc = ref(false);
const COLUMNS = [
{ key: 'CONTAINERNAME', label: 'LOT ID', width: '140px' },
{ key: 'PJ_TYPE', label: 'TYPE', width: '80px' },
{ key: 'PRODUCTLINENAME', label: 'PACKAGE', width: '90px' },
{ key: 'WORKFLOW', label: 'WORKFLOW', width: '100px' },
{ key: 'TMTT_EQUIPMENTNAME', label: 'TMTT 設備', width: '110px' },
{ key: 'INPUT_QTY', label: '投入數', width: '70px', numeric: true },
{ key: 'LOSS_REASON', label: '不良原因', width: '130px' },
{ key: 'DEFECT_QTY', label: '不良數', width: '70px', numeric: true },
{ key: 'DEFECT_RATE', label: '不良率(%)', width: '90px', numeric: true },
{ key: 'ANCESTOR_COUNT', label: '上游LOT數', width: '80px', numeric: true },
{ key: 'UPSTREAM_MACHINES', label: '上游機台', width: '200px' },
];
const sortedData = computed(() => {
if (!props.data || !props.data.length) return [];
const field = sortField.value;
const asc = sortAsc.value;
return [...props.data].sort((a, b) => {
const va = a[field] ?? '';
const vb = b[field] ?? '';
if (typeof va === 'number' && typeof vb === 'number') {
return asc ? va - vb : vb - va;
}
const sa = String(va);
const sb = String(vb);
return asc ? sa.localeCompare(sb) : sb.localeCompare(sa);
});
});
const tableInfo = computed(() => {
const p = props.pagination;
const total = Number(p.total_count || 0);
if (total <= 0) return '暫無資料';
const page = Number(p.page || 1);
const pageSize = Number(p.page_size || 200);
const start = (page - 1) * pageSize + 1;
const end = Math.min(page * pageSize, total);
return `顯示 ${start} - ${end} 筆,共 ${total.toLocaleString()}`;
});
function toggleSort(field) {
if (sortField.value === field) {
sortAsc.value = !sortAsc.value;
} else {
sortField.value = field;
sortAsc.value = false;
}
}
function sortIcon(field) {
if (sortField.value !== field) return '';
return sortAsc.value ? ' ▲' : ' ▼';
}
function formatCell(value, col) {
if (value == null || value === '') return '-';
if (col.key === 'DEFECT_RATE') return Number(value).toFixed(2);
if (col.numeric) return Number(value).toLocaleString();
return value;
}
</script>
<template>
<section class="section-card">
<div class="section-inner">
<div class="detail-header">
<h3 class="section-title">LOT 明細</h3>
<div class="detail-actions">
<span class="detail-count">{{ tableInfo }}</span>
<button type="button" class="btn-sm" :disabled="loading" @click="$emit('export-csv')">
匯出 CSV
</button>
</div>
</div>
<div class="table-wrapper">
<table class="detail-table">
<thead>
<tr>
<th
v-for="col in COLUMNS"
:key="col.key"
:style="{ width: col.width }"
class="sortable"
@click="toggleSort(col.key)"
>
{{ col.label }}{{ sortIcon(col.key) }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, idx) in sortedData" :key="idx">
<td v-for="col in COLUMNS" :key="col.key" :class="{ numeric: col.numeric }">
{{ formatCell(row[col.key], col) }}
</td>
</tr>
<tr v-if="!sortedData.length">
<td :colspan="COLUMNS.length" class="empty-row">暫無資料</td>
</tr>
</tbody>
</table>
</div>
<Pagination
:visible="Number(pagination.total_pages || 1) > 1"
:page="Number(pagination.page || 1)"
:total-pages="Number(pagination.total_pages || 1)"
@prev="emit('prev-page')"
@next="emit('next-page')"
/>
</div>
</section>
</template>

View File

@@ -0,0 +1,77 @@
<script setup>
import MultiSelect from './MultiSelect.vue';
const props = defineProps({
filters: {
type: Object,
required: true,
},
loading: {
type: Boolean,
default: false,
},
availableLossReasons: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['update-filters', 'query']);
function updateFilters(patch) {
emit('update-filters', {
...props.filters,
...patch,
});
}
</script>
<template>
<section class="section-card">
<div class="section-inner">
<div class="filter-row">
<div class="filter-field">
<label for="msd-start-date">開始</label>
<input
id="msd-start-date"
type="date"
:value="filters.startDate"
:disabled="loading"
@input="updateFilters({ startDate: $event.target.value })"
/>
</div>
<div class="filter-field">
<label for="msd-end-date">結束</label>
<input
id="msd-end-date"
type="date"
:value="filters.endDate"
:disabled="loading"
@input="updateFilters({ endDate: $event.target.value })"
/>
</div>
<div class="filter-field">
<label>不良原因</label>
<MultiSelect
:model-value="filters.lossReasons"
:options="availableLossReasons"
:disabled="loading"
placeholder="全部原因"
@update:model-value="updateFilters({ lossReasons: $event })"
/>
</div>
<button
type="button"
class="btn btn-primary"
:disabled="loading"
@click="$emit('query')"
>
查詢
</button>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,83 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
kpi: {
type: Object,
default: () => ({}),
},
loading: {
type: Boolean,
default: false,
},
});
const cards = computed(() => [
{
label: 'TMTT 投入數',
value: formatNumber(props.kpi.total_input),
unit: 'pcs',
color: '#3b82f6',
},
{
label: 'LOT 數量',
value: formatNumber(props.kpi.lot_count),
unit: 'lots',
color: '#6366f1',
},
{
label: '不良總數',
value: formatNumber(props.kpi.total_defect_qty),
unit: 'pcs',
color: '#ef4444',
},
{
label: '不良率',
value: formatRate(props.kpi.total_defect_rate),
unit: '%',
color: '#f59e0b',
},
{
label: '首要不良原因',
value: props.kpi.top_loss_reason || '-',
unit: '',
color: '#8b5cf6',
isText: true,
},
{
label: '上游關聯機台',
value: formatNumber(props.kpi.affected_machine_count),
unit: '台',
color: '#10b981',
},
]);
function formatNumber(v) {
if (v == null || v === 0) return '0';
return Number(v).toLocaleString();
}
function formatRate(v) {
if (v == null) return '0.00';
return Number(v).toFixed(2);
}
</script>
<template>
<section class="kpi-section">
<div class="kpi-grid">
<div
v-for="(card, idx) in cards"
:key="idx"
class="kpi-card"
:style="{ borderTopColor: card.color }"
>
<div class="kpi-label">{{ card.label }}</div>
<div class="kpi-value" :class="{ 'kpi-text': card.isText }">
<span>{{ card.value }}</span>
<span v-if="card.unit && !card.isText" class="kpi-unit">{{ card.unit }}</span>
</div>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,154 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
const props = defineProps({
modelValue: {
type: Array,
default: () => [],
},
options: {
type: Array,
default: () => [],
},
placeholder: {
type: String,
default: '請選擇',
},
disabled: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['update:modelValue']);
const rootRef = ref(null);
const isOpen = ref(false);
const normalizedOptions = computed(() => {
return props.options.map((option) => {
if (option && typeof option === 'object') {
const value = option.value ?? option.name ?? option.label ?? '';
const label = option.label ?? option.name ?? option.value ?? '';
return {
label: String(label),
value: String(value),
};
}
return {
label: String(option),
value: String(option),
};
});
});
const selectedSet = computed(() => new Set((props.modelValue || []).map((value) => String(value))));
const selectedText = computed(() => {
if (!props.modelValue.length) {
return props.placeholder;
}
if (props.modelValue.length === 1) {
const found = normalizedOptions.value.find(
(option) => option.value === String(props.modelValue[0])
);
return found?.label || String(props.modelValue[0]);
}
return `已選 ${props.modelValue.length}`;
});
function closeDropdown() {
isOpen.value = false;
}
function toggleDropdown() {
if (props.disabled) {
return;
}
isOpen.value = !isOpen.value;
}
function isSelected(value) {
return selectedSet.value.has(String(value));
}
function toggleOption(value) {
const next = new Set(selectedSet.value);
const key = String(value);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
emit('update:modelValue', [...next]);
}
function selectAll() {
emit(
'update:modelValue',
normalizedOptions.value.map((option) => option.value)
);
}
function clearAll() {
emit('update:modelValue', []);
}
function handleOutsideClick(event) {
if (!isOpen.value || !rootRef.value) {
return;
}
if (!rootRef.value.contains(event.target)) {
isOpen.value = false;
}
}
onMounted(() => {
document.addEventListener('click', handleOutsideClick, true);
});
onBeforeUnmount(() => {
document.removeEventListener('click', handleOutsideClick, true);
});
</script>
<template>
<div ref="rootRef" class="multi-select">
<button
type="button"
class="multi-select-trigger"
:disabled="disabled"
@click="toggleDropdown"
>
<span class="multi-select-text">{{ selectedText }}</span>
<span class="multi-select-arrow">{{ isOpen ? '▲' : '▼' }}</span>
</button>
<div v-if="isOpen" class="multi-select-dropdown">
<div class="multi-select-options">
<button
v-for="option in normalizedOptions"
:key="option.value"
type="button"
class="multi-select-option"
@click="toggleOption(option.value)"
>
<input type="checkbox" :checked="isSelected(option.value)" tabindex="-1" />
<span>{{ option.label }}</span>
</button>
</div>
<div class="multi-select-actions">
<button type="button" class="btn-sm" @click="selectAll">全選</button>
<button type="button" class="btn-sm" @click="clearAll">清除</button>
<button type="button" class="btn-sm" @click="closeDropdown">關閉</button>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,132 @@
<script setup>
import { computed } from 'vue';
import VChart from 'vue-echarts';
import { use } from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
import { BarChart, LineChart } from 'echarts/charts';
import {
GridComponent,
LegendComponent,
TooltipComponent,
} from 'echarts/components';
use([CanvasRenderer, BarChart, LineChart, GridComponent, LegendComponent, TooltipComponent]);
const props = defineProps({
title: {
type: String,
default: '',
},
data: {
type: Array,
default: () => [],
},
});
const chartOption = computed(() => {
if (!props.data || !props.data.length) return null;
const names = props.data.map((d) => d.name);
const defectQty = props.data.map((d) => d.defect_qty);
const cumulativePct = props.data.map((d) => d.cumulative_pct);
const defectRate = props.data.map((d) => d.defect_rate);
return {
animationDuration: 350,
tooltip: {
trigger: 'axis',
axisPointer: { type: 'cross' },
formatter(params) {
const idx = params[0]?.dataIndex;
if (idx == null) return '';
const item = props.data[idx];
let html = `<b>${item.name}</b><br/>`;
html += `不良數: ${(item.defect_qty || 0).toLocaleString()}<br/>`;
html += `投入數: ${(item.input_qty || 0).toLocaleString()}<br/>`;
html += `不良率: ${(item.defect_rate || 0).toFixed(2)}%<br/>`;
html += `累計占比: ${(item.cumulative_pct || 0).toFixed(1)}%`;
return html;
},
},
legend: {
data: ['不良數', '不良率', '累計占比'],
bottom: 0,
textStyle: { fontSize: 11 },
},
grid: {
top: 40,
right: 60,
bottom: 50,
left: 50,
containLabel: true,
},
xAxis: {
type: 'category',
data: names,
axisLabel: {
rotate: names.length > 6 ? 30 : 0,
fontSize: 11,
overflow: 'truncate',
width: 80,
},
},
yAxis: [
{
type: 'value',
name: '不良數',
nameTextStyle: { fontSize: 11 },
axisLabel: { fontSize: 11 },
},
{
type: 'value',
name: '%',
max: 100,
nameTextStyle: { fontSize: 11 },
axisLabel: { fontSize: 11, formatter: '{value}%' },
},
],
series: [
{
name: '不良數',
type: 'bar',
data: defectQty,
itemStyle: { color: '#6366f1', borderRadius: [3, 3, 0, 0] },
barMaxWidth: 40,
},
{
name: '不良率',
type: 'line',
yAxisIndex: 1,
data: defectRate,
symbol: 'circle',
symbolSize: 6,
lineStyle: { color: '#f59e0b', width: 2 },
itemStyle: { color: '#f59e0b' },
},
{
name: '累計占比',
type: 'line',
yAxisIndex: 1,
data: cumulativePct,
symbol: 'diamond',
symbolSize: 6,
lineStyle: { color: '#ef4444', width: 2, type: 'dashed' },
itemStyle: { color: '#ef4444' },
},
],
};
});
</script>
<template>
<div class="chart-card">
<h3 class="chart-title">{{ title }}</h3>
<VChart
v-if="chartOption"
class="chart-canvas"
:option="chartOption"
autoresize
/>
<div v-else class="chart-empty">暫無資料</div>
</div>
</template>

View File

@@ -0,0 +1,111 @@
<script setup>
import { computed } from 'vue';
import VChart from 'vue-echarts';
import { use } from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
import { BarChart, LineChart } from 'echarts/charts';
import {
GridComponent,
LegendComponent,
TooltipComponent,
} from 'echarts/components';
use([CanvasRenderer, BarChart, LineChart, GridComponent, LegendComponent, TooltipComponent]);
const props = defineProps({
data: {
type: Array,
default: () => [],
},
});
const chartOption = computed(() => {
if (!props.data || !props.data.length) return null;
const dates = props.data.map((d) => d.date);
const inputQty = props.data.map((d) => d.input_qty);
const defectQty = props.data.map((d) => d.defect_qty);
const defectRate = props.data.map((d) => d.defect_rate);
return {
animationDuration: 350,
tooltip: {
trigger: 'axis',
axisPointer: { type: 'cross' },
},
legend: {
data: ['投入數', '不良數', '不良率'],
bottom: 0,
textStyle: { fontSize: 11 },
},
grid: {
top: 40,
right: 60,
bottom: 50,
left: 50,
containLabel: true,
},
xAxis: {
type: 'category',
data: dates,
axisLabel: {
fontSize: 11,
rotate: dates.length > 14 ? 30 : 0,
},
},
yAxis: [
{
type: 'value',
name: '數量',
nameTextStyle: { fontSize: 11 },
axisLabel: { fontSize: 11 },
},
{
type: 'value',
name: '不良率 %',
nameTextStyle: { fontSize: 11 },
axisLabel: { fontSize: 11, formatter: '{value}%' },
},
],
series: [
{
name: '投入數',
type: 'bar',
data: inputQty,
itemStyle: { color: '#93c5fd', borderRadius: [3, 3, 0, 0] },
barMaxWidth: 30,
},
{
name: '不良數',
type: 'bar',
data: defectQty,
itemStyle: { color: '#fca5a5', borderRadius: [3, 3, 0, 0] },
barMaxWidth: 30,
},
{
name: '不良率',
type: 'line',
yAxisIndex: 1,
data: defectRate,
symbol: 'circle',
symbolSize: 5,
lineStyle: { color: '#ef4444', width: 2 },
itemStyle: { color: '#ef4444' },
},
],
};
});
</script>
<template>
<div class="chart-card chart-card-full">
<h3 class="chart-title">每日不良趨勢</h3>
<VChart
v-if="chartOption"
class="chart-canvas chart-canvas-wide"
:option="chartOption"
autoresize
/>
<div v-else class="chart-empty">暫無資料</div>
</div>
</template>

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

View File

@@ -0,0 +1,6 @@
import { createApp } from 'vue';
import App from './App.vue';
import './style.css';
createApp(App).mount('#app');

View File

@@ -0,0 +1,498 @@
:root {
--msd-bg: #f5f7fb;
--msd-card-bg: #ffffff;
--msd-text: #1f2937;
--msd-muted: #64748b;
--msd-border: #dbe4ef;
--msd-shadow: 0 1px 3px rgba(15, 23, 42, 0.08);
--msd-shadow-md: 0 8px 22px rgba(15, 23, 42, 0.1);
--msd-primary: #6366f1;
--msd-primary-dark: #4f46e5;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: 'Microsoft JhengHei', 'PingFang TC', 'Noto Sans TC', sans-serif;
background: var(--msd-bg);
color: var(--msd-text);
}
.page-container {
min-height: 100vh;
padding: 16px;
max-width: 1800px;
margin: 0 auto;
}
/* ====== Header ====== */
.page-header {
display: flex;
flex-direction: column;
gap: 4px;
padding: 16px 20px;
border-radius: 12px;
margin-bottom: 16px;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #a855f7 100%);
box-shadow: var(--msd-shadow-md);
}
.page-header h1 {
margin: 0;
color: #ffffff;
font-size: 24px;
letter-spacing: 0.2px;
}
.header-desc {
margin: 0;
color: rgba(255, 255, 255, 0.8);
font-size: 13px;
}
/* ====== Section Card ====== */
.section-card {
background: var(--msd-card-bg);
border-radius: 12px;
box-shadow: var(--msd-shadow);
margin-bottom: 16px;
}
.section-inner {
padding: 16px 20px;
}
.section-title {
margin: 0 0 12px;
font-size: 16px;
font-weight: 700;
}
/* ====== Filter Bar ====== */
.filter-row {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
}
.filter-field {
display: flex;
align-items: center;
gap: 8px;
}
.filter-field label {
font-size: 13px;
color: #475569;
white-space: nowrap;
}
.filter-field input[type='date'] {
border: 1px solid var(--msd-border);
border-radius: 6px;
padding: 8px 10px;
font-size: 13px;
background: #ffffff;
color: #1f2937;
}
/* ====== Buttons ====== */
.btn {
border: none;
border-radius: 8px;
padding: 8px 20px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.btn-primary {
background: var(--msd-primary);
color: #ffffff;
}
.btn-primary:hover:not(:disabled) {
background: var(--msd-primary-dark);
}
.btn:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.btn-sm {
border: 1px solid var(--msd-border);
border-radius: 6px;
background: #ffffff;
color: #475569;
font-size: 12px;
padding: 4px 10px;
cursor: pointer;
}
.btn-sm:hover:not(:disabled) {
background: #f1f5f9;
}
/* ====== MultiSelect ====== */
.multi-select {
position: relative;
min-width: 200px;
}
.multi-select-trigger {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
border: 1px solid var(--msd-border);
border-radius: 6px;
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;
}
.multi-select-arrow {
flex-shrink: 0;
font-size: 10px;
color: #94a3b8;
}
.multi-select-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 4px;
background: #ffffff;
border: 1px solid var(--msd-border);
border-radius: 8px;
box-shadow: var(--msd-shadow-md);
z-index: 100;
max-height: 300px;
display: flex;
flex-direction: column;
}
.multi-select-options {
overflow-y: auto;
flex: 1;
padding: 4px 0;
}
.multi-select-option {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 6px 12px;
border: none;
background: transparent;
cursor: pointer;
font-size: 13px;
text-align: left;
}
.multi-select-option:hover {
background: #f1f5f9;
}
.multi-select-option input[type='checkbox'] {
pointer-events: none;
}
.multi-select-actions {
display: flex;
gap: 6px;
padding: 8px 12px;
border-top: 1px solid var(--msd-border);
background: #f8fafc;
border-radius: 0 0 8px 8px;
}
/* ====== Error / Warning Banners ====== */
.error-banner {
padding: 12px 16px;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 8px;
color: #dc2626;
font-size: 14px;
margin-bottom: 16px;
}
.warning-banner {
padding: 12px 16px;
background: #fffbeb;
border: 1px solid #fde68a;
border-radius: 8px;
color: #b45309;
font-size: 14px;
margin-bottom: 16px;
}
/* ====== KPI Cards ====== */
.kpi-section {
margin-bottom: 16px;
}
.kpi-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
}
.kpi-card {
background: var(--msd-card-bg);
border-radius: 10px;
box-shadow: var(--msd-shadow);
padding: 16px;
border-top: 3px solid #6366f1;
}
.kpi-label {
font-size: 12px;
color: var(--msd-muted);
margin-bottom: 6px;
}
.kpi-value {
font-size: 24px;
font-weight: 700;
display: flex;
align-items: baseline;
gap: 4px;
}
.kpi-value.kpi-text {
font-size: 14px;
font-weight: 600;
word-break: break-all;
}
.kpi-unit {
font-size: 12px;
font-weight: 400;
color: var(--msd-muted);
}
/* ====== Charts ====== */
.charts-section {
margin-bottom: 16px;
}
.charts-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 12px;
}
.charts-row-full {
grid-template-columns: 1fr;
}
.chart-card {
background: var(--msd-card-bg);
border-radius: 10px;
box-shadow: var(--msd-shadow);
padding: 16px;
}
.chart-card-full {
grid-column: 1 / -1;
}
.chart-title {
margin: 0 0 8px;
font-size: 14px;
font-weight: 600;
color: var(--msd-text);
}
.chart-canvas {
width: 100%;
height: 320px;
}
.chart-canvas-wide {
height: 280px;
}
.chart-empty {
display: flex;
align-items: center;
justify-content: center;
height: 200px;
color: var(--msd-muted);
font-size: 14px;
}
/* ====== Detail Table ====== */
.detail-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.detail-actions {
display: flex;
align-items: center;
gap: 12px;
}
.detail-count {
font-size: 13px;
color: var(--msd-muted);
}
.table-wrapper {
overflow-x: auto;
border: 1px solid var(--msd-border);
border-radius: 8px;
}
.detail-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.detail-table th {
background: #f1f5f9;
padding: 10px 12px;
text-align: left;
font-weight: 600;
font-size: 12px;
color: #475569;
white-space: nowrap;
border-bottom: 2px solid var(--msd-border);
position: sticky;
top: 0;
}
.detail-table th.sortable {
cursor: pointer;
user-select: none;
}
.detail-table th.sortable:hover {
background: #e2e8f0;
}
.detail-table td {
padding: 8px 12px;
border-bottom: 1px solid #f1f5f9;
white-space: nowrap;
}
.detail-table td.numeric {
text-align: right;
font-variant-numeric: tabular-nums;
}
.detail-table tbody tr:hover {
background: #f8fafc;
}
.empty-row {
text-align: center;
padding: 32px;
color: var(--msd-muted);
}
/* ====== Pagination ====== */
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 12px;
padding: 16px;
border-top: 1px solid var(--msd-border);
}
.pagination button {
padding: 8px 16px;
border: 1px solid var(--msd-border);
background: #fff;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
}
.pagination button:hover:not(:disabled) {
border-color: var(--msd-primary);
color: var(--msd-primary);
}
.pagination button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.pagination .page-info {
font-size: 13px;
color: var(--msd-muted);
}
/* ====== Empty State ====== */
.empty-state {
display: flex;
align-items: center;
justify-content: center;
padding: 80px 20px;
color: var(--msd-muted);
font-size: 15px;
}
/* ====== Loading Overlay ====== */
.loading-overlay {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.7);
z-index: 999;
transition: opacity 0.2s;
}
.loading-overlay.hidden {
opacity: 0;
pointer-events: none;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 3px solid #e2e8f0;
border-top-color: var(--msd-primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}

View File

@@ -23,7 +23,8 @@ export default defineConfig(({ mode }) => ({
tables: resolve(__dirname, 'src/tables/index.html'),
'query-tool': resolve(__dirname, 'src/query-tool/main.js'),
'tmtt-defect': resolve(__dirname, 'src/tmtt-defect/main.js'),
'qc-gate': resolve(__dirname, 'src/qc-gate/index.html')
'qc-gate': resolve(__dirname, 'src/qc-gate/index.html'),
'mid-section-defect': resolve(__dirname, 'src/mid-section-defect/index.html')
},
output: {
entryFileNames: '[name].js',

View File

@@ -488,6 +488,12 @@ def create_app(config_name: str | None = None) -> Flask:
dist_dir = os.path.join(app.static_folder or "", "dist")
return send_from_directory(dist_dir, 'qc-gate.html')
@app.route('/mid-section-defect')
def mid_section_defect_page():
"""Mid-section defect traceability analysis page (pure Vite)."""
dist_dir = os.path.join(app.static_folder or "", "dist")
return send_from_directory(dist_dir, 'mid-section-defect.html')
# ========================================================
# Table Query APIs (for table_data_viewer)
# ========================================================

View File

@@ -559,6 +559,75 @@ def read_sql_df(sql: str, params: Optional[Dict[str, Any]] = None) -> pd.DataFra
raise
def read_sql_df_slow(
sql: str,
params: Optional[Dict[str, Any]] = None,
timeout_seconds: int = 120,
) -> pd.DataFrame:
"""Execute a slow SQL query with a custom timeout via direct oracledb connection.
Unlike read_sql_df which uses the pooled engine (55s timeout),
this creates a dedicated connection with a longer call_timeout
for known-slow queries (e.g. full table scans on large tables).
Args:
sql: SQL query string with Oracle bind variables.
params: Optional dict of parameter values.
timeout_seconds: Call timeout in seconds (default: 120).
Returns:
DataFrame with query results, or None on connection failure.
"""
start_time = time.time()
timeout_ms = timeout_seconds * 1000
conn = None
try:
runtime = get_db_runtime_config()
conn = oracledb.connect(
**DB_CONFIG,
tcp_connect_timeout=runtime["tcp_connect_timeout"],
retry_count=runtime["retry_count"],
retry_delay=runtime["retry_delay"],
)
conn.call_timeout = timeout_ms
logger.debug(
"Slow-query connection established (call_timeout_ms=%s)", timeout_ms
)
cursor = conn.cursor()
cursor.execute(sql, params or {})
columns = [desc[0].upper() for desc in cursor.description]
rows = cursor.fetchall()
cursor.close()
df = pd.DataFrame(rows, columns=columns)
elapsed = time.time() - start_time
if elapsed > 1.0:
sql_preview = sql.strip().replace('\n', ' ')[:100]
logger.warning(f"Slow query ({elapsed:.2f}s): {sql_preview}...")
else:
logger.debug(f"Query completed in {elapsed:.3f}s, rows={len(df)}")
return df
except Exception as exc:
elapsed = time.time() - start_time
ora_code = _extract_ora_code(exc)
sql_preview = sql.strip().replace('\n', ' ')[:100]
logger.error(
f"Query failed after {elapsed:.2f}s - ORA-{ora_code}: {exc} | SQL: {sql_preview}..."
)
raise
finally:
if conn:
try:
conn.close()
except Exception:
pass
# ============================================================
# Table Utilities
# ============================================================

View File

@@ -16,6 +16,7 @@ from .job_query_routes import job_query_bp
from .query_tool_routes import query_tool_bp
from .tmtt_defect_routes import tmtt_defect_bp
from .qc_gate_routes import qc_gate_bp
from .mid_section_defect_routes import mid_section_defect_bp
def register_routes(app) -> None:
@@ -30,6 +31,7 @@ def register_routes(app) -> None:
app.register_blueprint(query_tool_bp)
app.register_blueprint(tmtt_defect_bp)
app.register_blueprint(qc_gate_bp)
app.register_blueprint(mid_section_defect_bp)
__all__ = [
'wip_bp',
@@ -44,5 +46,6 @@ __all__ = [
'query_tool_bp',
'tmtt_defect_bp',
'qc_gate_bp',
'mid_section_defect_bp',
'register_routes',
]

View File

@@ -0,0 +1,160 @@
# -*- coding: utf-8 -*-
"""Mid-Section Defect Traceability Analysis API routes.
Reverse traceability from TMTT (test) station back to upstream production stations.
"""
from flask import Blueprint, jsonify, request, Response
from mes_dashboard.services.mid_section_defect_service import (
query_analysis,
query_analysis_detail,
query_all_loss_reasons,
export_csv,
)
mid_section_defect_bp = Blueprint(
'mid_section_defect',
__name__,
url_prefix='/api/mid-section-defect'
)
@mid_section_defect_bp.route('/analysis', methods=['GET'])
def api_analysis():
"""API: Get mid-section defect traceability analysis (summary).
Returns kpi, charts, daily_trend, available_loss_reasons, genealogy_status,
and detail_total_count. Does NOT include the detail array — use
/analysis/detail for paginated detail data.
Query Parameters:
start_date: Start date (YYYY-MM-DD), required
end_date: End date (YYYY-MM-DD), required
loss_reasons: Comma-separated loss reason names, optional
"""
start_date = request.args.get('start_date')
end_date = request.args.get('end_date')
if not start_date or not end_date:
return jsonify({
'success': False,
'error': '必須提供 start_date 和 end_date 參數'
}), 400
loss_reasons_str = request.args.get('loss_reasons', '')
loss_reasons = [r.strip() for r in loss_reasons_str.split(',') if r.strip()] or None
result = query_analysis(start_date, end_date, loss_reasons)
if result is None:
return jsonify({'success': False, 'error': '查詢失敗,請稍後再試'}), 500
if 'error' in result:
return jsonify({'success': False, 'error': result['error']}), 400
# Return summary only (no detail array) to keep response lightweight
summary = {
'kpi': result.get('kpi'),
'charts': result.get('charts'),
'daily_trend': result.get('daily_trend'),
'available_loss_reasons': result.get('available_loss_reasons'),
'genealogy_status': result.get('genealogy_status'),
'detail_total_count': len(result.get('detail', [])),
}
return jsonify({'success': True, 'data': summary})
@mid_section_defect_bp.route('/analysis/detail', methods=['GET'])
def api_analysis_detail():
"""API: Get paginated detail table for mid-section defect analysis.
Query Parameters:
start_date: Start date (YYYY-MM-DD), required
end_date: End date (YYYY-MM-DD), required
loss_reasons: Comma-separated loss reason names, optional
page: Page number (default 1)
page_size: Records per page (default 200, max 500)
"""
start_date = request.args.get('start_date')
end_date = request.args.get('end_date')
if not start_date or not end_date:
return jsonify({
'success': False,
'error': '必須提供 start_date 和 end_date 參數'
}), 400
loss_reasons_str = request.args.get('loss_reasons', '')
loss_reasons = [r.strip() for r in loss_reasons_str.split(',') if r.strip()] or None
page = max(request.args.get('page', 1, type=int), 1)
page_size = max(1, min(request.args.get('page_size', 200, type=int), 500))
result = query_analysis_detail(
start_date, end_date, loss_reasons,
page=page, page_size=page_size,
)
if result is None:
return jsonify({'success': False, 'error': '查詢失敗,請稍後再試'}), 500
if 'error' in result:
return jsonify({'success': False, 'error': result['error']}), 400
return jsonify({'success': True, 'data': result})
@mid_section_defect_bp.route('/loss-reasons', methods=['GET'])
def api_loss_reasons():
"""API: Get all TMTT loss reasons (cached daily).
No parameters required — returns all loss reasons from last 180 days,
cached in Redis with 24h TTL for instant dropdown population.
Returns:
JSON with loss_reasons list.
"""
result = query_all_loss_reasons()
if result is None:
return jsonify({'success': False, 'error': '查詢失敗,請稍後再試'}), 500
return jsonify({'success': True, 'data': result})
@mid_section_defect_bp.route('/export', methods=['GET'])
def api_export():
"""API: Export mid-section defect detail data as CSV.
Query Parameters:
start_date: Start date (YYYY-MM-DD), required
end_date: End date (YYYY-MM-DD), required
loss_reasons: Comma-separated loss reason names, optional
Returns:
CSV file download.
"""
start_date = request.args.get('start_date')
end_date = request.args.get('end_date')
if not start_date or not end_date:
return jsonify({
'success': False,
'error': '必須提供 start_date 和 end_date 參數'
}), 400
loss_reasons_str = request.args.get('loss_reasons', '')
loss_reasons = [r.strip() for r in loss_reasons_str.split(',') if r.strip()] or None
filename = f"mid_section_defect_{start_date}_to_{end_date}.csv"
return Response(
export_csv(start_date, end_date, loss_reasons),
mimetype='text/csv',
headers={
'Content-Disposition': f'attachment; filename={filename}',
'Content-Type': 'text/csv; charset=utf-8-sig'
}
)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,16 @@
-- Mid-Section Defect - All Loss Reasons (cached daily)
-- Lightweight query for filter dropdown population.
-- Returns ALL loss reasons across all stations (not just TMTT).
--
-- Tables used:
-- DWH.DW_MES_LOTREJECTHISTORY (TXNDATE indexed)
--
-- Performance:
-- DISTINCT on one column with date filter only.
-- Cached 24h in Redis.
--
SELECT DISTINCT r.LOSSREASONNAME
FROM DWH.DW_MES_LOTREJECTHISTORY r
WHERE r.TXNDATE >= SYSDATE - 180
AND r.LOSSREASONNAME IS NOT NULL
ORDER BY r.LOSSREASONNAME

View File

@@ -0,0 +1,36 @@
-- Mid-Section Defect Traceability - LOT Genealogy Records (Query 2)
-- Batch query for split/merge records related to work orders
--
-- Parameters:
-- MFG_ORDER_FILTER - Dynamic IN clause for MFGORDERNAME (built by QueryBuilder)
--
-- Tables used:
-- DWH.DW_MES_CONTAINER (MFGORDERNAME indexed → get CONTAINERIDs)
-- DWH.DW_MES_HM_LOTMOVEOUT (48M rows, no CONTAINERID index)
--
-- Performance:
-- Full scan on HM_LOTMOVEOUT filtered by CONTAINERIDs from work orders.
-- CDONAME filter reduces result set to only split/merge operations.
-- Estimated 30-120s. Use aggressive caching (30-min TTL).
--
WITH work_order_lots AS (
SELECT CONTAINERID
FROM DWH.DW_MES_CONTAINER
WHERE {{ MFG_ORDER_FILTER }}
)
SELECT
h.CDONAME AS OPERATION_TYPE,
h.CONTAINERID AS TARGET_CID,
h.CONTAINERNAME AS TARGET_LOT,
h.FROMCONTAINERID AS SOURCE_CID,
h.FROMCONTAINERNAME AS SOURCE_LOT,
h.QTY,
h.TXNDATE
FROM DWH.DW_MES_HM_LOTMOVEOUT h
WHERE (
h.CONTAINERID IN (SELECT CONTAINERID FROM work_order_lots)
OR h.FROMCONTAINERID IN (SELECT CONTAINERID FROM work_order_lots)
)
AND h.FROMCONTAINERID IS NOT NULL
AND (UPPER(h.CDONAME) LIKE '%SPLIT%' OR UPPER(h.CDONAME) LIKE '%COMBINE%')
ORDER BY h.TXNDATE

View File

@@ -0,0 +1,21 @@
-- Mid-Section Defect Traceability - Merge Lookup (Query 2b)
-- Find source lots that were merged into finished lots
-- via DW_MES_PJ_COMBINEDASSYLOTS
--
-- Parameters:
-- Dynamically built IN clause for FINISHEDNAME values
--
-- Tables used:
-- DWH.DW_MES_PJ_COMBINEDASSYLOTS (1.97M rows, FINISHEDNAME indexed)
--
-- Performance:
-- FINISHEDNAME has index. Batch IN clause (up to 1000 per query).
-- Each batch <1s.
--
SELECT
ca.CONTAINERID AS SOURCE_CID,
ca.CONTAINERNAME AS SOURCE_NAME,
ca.FINISHEDNAME,
ca.LOTID AS FINISHED_CID
FROM DWH.DW_MES_PJ_COMBINEDASSYLOTS ca
WHERE {{ FINISHED_NAME_FILTER }}

View File

@@ -0,0 +1,23 @@
-- Mid-Section Defect Traceability - Split Chain (Query 2a)
-- Resolve split ancestors via DW_MES_CONTAINER.SPLITFROMID
--
-- Parameters:
-- Dynamically built IN clause for CONTAINERIDs
--
-- Tables used:
-- DWH.DW_MES_CONTAINER (5.2M rows, CONTAINERID UNIQUE index)
--
-- Performance:
-- CONTAINERID has UNIQUE index. Batch IN clause (up to 1000 per query).
-- Each batch <1s.
--
-- Note: SPLITFROMID may be NULL for lots that were not split from another.
-- BFS caller uses SPLITFROMID to walk upward; NULL means chain terminus.
--
SELECT
c.CONTAINERID,
c.SPLITFROMID,
c.ORIGINALCONTAINERID,
c.CONTAINERNAME
FROM DWH.DW_MES_CONTAINER c
WHERE {{ CID_FILTER }}

View File

@@ -0,0 +1,93 @@
-- Mid-Section Defect Traceability - TMTT Detection Data (Query 1)
-- Returns LOT-level data with TMTT input, ALL defects, and lot metadata
--
-- Parameters:
-- :start_date - Start date (YYYY-MM-DD)
-- :end_date - End date (YYYY-MM-DD)
--
-- Tables used:
-- DWH.DW_MES_LOTWIPHISTORY (TMTT station records)
-- DWH.DW_MES_LOTREJECTHISTORY (defect records - ALL loss reasons)
-- DWH.DW_MES_CONTAINER (product info + MFGORDERNAME for genealogy)
-- DWH.DW_MES_WIP (WORKFLOWNAME)
--
-- Changes from tmtt_defect/base_data.sql:
-- 1. Removed hardcoded LOSSREASONNAME filter → fetches ALL loss reasons
-- 2. Added MFGORDERNAME from DW_MES_CONTAINER (needed for genealogy batch)
-- 3. Removed MOLD equipment lookup (upstream tracing done separately)
-- 4. Kept existing dedup logic (ROW_NUMBER by CONTAINERID, latest TRACKINTIMESTAMP)
WITH tmtt_records AS (
SELECT /*+ MATERIALIZE */
h.CONTAINERID,
h.EQUIPMENTID AS TMTT_EQUIPMENTID,
h.EQUIPMENTNAME AS TMTT_EQUIPMENTNAME,
h.TRACKINQTY,
h.TRACKINTIMESTAMP,
h.TRACKOUTTIMESTAMP,
h.FINISHEDRUNCARD,
h.SPECNAME,
h.WORKCENTERNAME,
ROW_NUMBER() OVER (
PARTITION BY h.CONTAINERID
ORDER BY h.TRACKINTIMESTAMP DESC, h.TRACKOUTTIMESTAMP DESC NULLS LAST
) AS rn
FROM DWH.DW_MES_LOTWIPHISTORY h
WHERE h.TRACKINTIMESTAMP >= TO_DATE(:start_date, 'YYYY-MM-DD')
AND h.TRACKINTIMESTAMP < TO_DATE(:end_date, 'YYYY-MM-DD') + 1
AND (UPPER(h.WORKCENTERNAME) LIKE '%TMTT%' OR h.WORKCENTERNAME LIKE '%測試%')
AND h.EQUIPMENTID IS NOT NULL
AND h.TRACKINTIMESTAMP IS NOT NULL
),
tmtt_deduped AS (
SELECT * FROM tmtt_records WHERE rn = 1
),
tmtt_rejects AS (
SELECT /*+ MATERIALIZE */
r.CONTAINERID,
r.LOSSREASONNAME,
SUM(NVL(r.REJECTQTY, 0) + NVL(r.STANDBYQTY, 0) + NVL(r.QTYTOPROCESS, 0)
+ NVL(r.INPROCESSQTY, 0) + NVL(r.PROCESSEDQTY, 0)) AS REJECTQTY
FROM DWH.DW_MES_LOTREJECTHISTORY r
WHERE r.TXNDATE >= TO_DATE(:start_date, 'YYYY-MM-DD')
AND r.TXNDATE < TO_DATE(:end_date, 'YYYY-MM-DD') + 1
AND (UPPER(r.WORKCENTERNAME) LIKE '%TMTT%' OR r.WORKCENTERNAME LIKE '%測試%')
GROUP BY r.CONTAINERID, r.LOSSREASONNAME
),
lot_metadata AS (
SELECT /*+ MATERIALIZE */
c.CONTAINERID,
c.CONTAINERNAME,
c.MFGORDERNAME,
c.PJ_TYPE,
c.PRODUCTLINENAME
FROM DWH.DW_MES_CONTAINER c
WHERE c.CONTAINERID IN (SELECT CONTAINERID FROM tmtt_deduped)
),
workflow_info AS (
SELECT /*+ MATERIALIZE */
DISTINCT w.CONTAINERID,
w.WORKFLOWNAME
FROM DWH.DW_MES_WIP w
WHERE w.CONTAINERID IN (SELECT CONTAINERID FROM tmtt_deduped)
AND w.PRODUCTLINENAME <> '點測'
)
SELECT
t.CONTAINERID,
m.CONTAINERNAME,
m.MFGORDERNAME,
m.PJ_TYPE,
m.PRODUCTLINENAME,
NVL(wf.WORKFLOWNAME, t.SPECNAME) AS WORKFLOW,
t.FINISHEDRUNCARD,
t.TMTT_EQUIPMENTID,
t.TMTT_EQUIPMENTNAME,
t.TRACKINQTY,
t.TRACKINTIMESTAMP,
r.LOSSREASONNAME,
NVL(r.REJECTQTY, 0) AS REJECTQTY
FROM tmtt_deduped t
LEFT JOIN lot_metadata m ON t.CONTAINERID = m.CONTAINERID
LEFT JOIN workflow_info wf ON t.CONTAINERID = wf.CONTAINERID
LEFT JOIN tmtt_rejects r ON t.CONTAINERID = r.CONTAINERID
ORDER BY t.TRACKINTIMESTAMP

View File

@@ -0,0 +1,40 @@
-- Mid-Section Defect Traceability - Upstream Production History (Query 3)
-- Get production history for ancestor LOTs at all stations
--
-- Parameters:
-- Dynamically built IN clause for ancestor CONTAINERIDs
--
-- Tables used:
-- DWH.DW_MES_LOTWIPHISTORY (53M rows, CONTAINERID indexed → fast)
--
-- Performance:
-- CONTAINERID has index. Batch IN clause (up to 1000 per query).
-- Estimated 1-5s per batch.
--
WITH ranked_history AS (
SELECT
h.CONTAINERID,
h.WORKCENTERNAME,
h.EQUIPMENTID,
h.EQUIPMENTNAME,
h.SPECNAME,
h.TRACKINTIMESTAMP,
ROW_NUMBER() OVER (
PARTITION BY h.CONTAINERID, h.WORKCENTERNAME, h.EQUIPMENTNAME
ORDER BY h.TRACKINTIMESTAMP DESC
) AS rn
FROM DWH.DW_MES_LOTWIPHISTORY h
WHERE {{ ANCESTOR_FILTER }}
AND h.EQUIPMENTID IS NOT NULL
AND h.TRACKINTIMESTAMP IS NOT NULL
)
SELECT
CONTAINERID,
WORKCENTERNAME,
EQUIPMENTID,
EQUIPMENTNAME,
SPECNAME,
TRACKINTIMESTAMP
FROM ranked_history
WHERE rn = 1
ORDER BY CONTAINERID, TRACKINTIMESTAMP