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:
49
README.md
49
README.md
@@ -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-echarts,Status 頁複用 `useAutoRefresh` composable(5 分鐘自動刷新)。
|
||||
- 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 + Vite,Pareto/趨勢圖使用 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 + Vite,vue-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
|
||||
|
||||
@@ -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": "頁面管理",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
263
frontend/src/mid-section-defect/App.vue
Normal file
263
frontend/src/mid-section-defect/App.vue
Normal 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>
|
||||
139
frontend/src/mid-section-defect/components/DetailTable.vue
Normal file
139
frontend/src/mid-section-defect/components/DetailTable.vue
Normal 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>
|
||||
77
frontend/src/mid-section-defect/components/FilterBar.vue
Normal file
77
frontend/src/mid-section-defect/components/FilterBar.vue
Normal 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>
|
||||
83
frontend/src/mid-section-defect/components/KpiCards.vue
Normal file
83
frontend/src/mid-section-defect/components/KpiCards.vue
Normal 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>
|
||||
154
frontend/src/mid-section-defect/components/MultiSelect.vue
Normal file
154
frontend/src/mid-section-defect/components/MultiSelect.vue
Normal 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>
|
||||
132
frontend/src/mid-section-defect/components/ParetoChart.vue
Normal file
132
frontend/src/mid-section-defect/components/ParetoChart.vue
Normal 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>
|
||||
111
frontend/src/mid-section-defect/components/TrendChart.vue
Normal file
111
frontend/src/mid-section-defect/components/TrendChart.vue
Normal 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>
|
||||
12
frontend/src/mid-section-defect/index.html
Normal file
12
frontend/src/mid-section-defect/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>
|
||||
6
frontend/src/mid-section-defect/main.js
Normal file
6
frontend/src/mid-section-defect/main.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createApp } from 'vue';
|
||||
|
||||
import App from './App.vue';
|
||||
import './style.css';
|
||||
|
||||
createApp(App).mount('#app');
|
||||
498
frontend/src/mid-section-defect/style.css
Normal file
498
frontend/src/mid-section-defect/style.css
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
# ========================================================
|
||||
|
||||
@@ -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
|
||||
# ============================================================
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
160
src/mes_dashboard/routes/mid_section_defect_routes.py
Normal file
160
src/mes_dashboard/routes/mid_section_defect_routes.py
Normal 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'
|
||||
}
|
||||
)
|
||||
1163
src/mes_dashboard/services/mid_section_defect_service.py
Normal file
1163
src/mes_dashboard/services/mid_section_defect_service.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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
|
||||
21
src/mes_dashboard/sql/mid_section_defect/merge_lookup.sql
Normal file
21
src/mes_dashboard/sql/mid_section_defect/merge_lookup.sql
Normal 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 }}
|
||||
23
src/mes_dashboard/sql/mid_section_defect/split_chain.sql
Normal file
23
src/mes_dashboard/sql/mid_section_defect/split_chain.sql
Normal 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 }}
|
||||
93
src/mes_dashboard/sql/mid_section_defect/tmtt_detection.sql
Normal file
93
src/mes_dashboard/sql/mid_section_defect/tmtt_detection.sql
Normal 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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user