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) | ✅ 已完成 |
|
| WIP 三頁 Vue 3 遷移(Overview/Detail/Hold Detail) | ✅ 已完成 |
|
||||||
| 設備雙頁 Vue 3 遷移(Status/History) | ✅ 已完成 |
|
| 設備雙頁 Vue 3 遷移(Status/History) | ✅ 已完成 |
|
||||||
| 設備快取 DataFrame TTL 一致性修復 | ✅ 已完成 |
|
| 設備快取 DataFrame TTL 一致性修復 | ✅ 已完成 |
|
||||||
|
| 中段製程不良追溯分析(TMTT → 上游) | ✅ 已完成 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 開發歷史(Vite 重構後)
|
## 開發歷史(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:完成設備雙頁 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:完成 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 元件。
|
- 2026-02-09:完成數據表查詢頁面(`/tables`)Vue 3 遷移 — 第二個純 Vite 頁面,建立 `apiPost` POST 請求模式,237 行 vanilla JS 重寫為 Vue 3 SFC 元件。
|
||||||
@@ -460,7 +462,7 @@ A: 請確認瀏覽器允許下載檔案,並檢查查詢結果是否有資料
|
|||||||
|
|
||||||
透過側邊欄抽屜分組導覽切換各功能模組:
|
透過側邊欄抽屜分組導覽切換各功能模組:
|
||||||
- **報表類**:WIP 即時概況、設備即時概況、設備歷史績效、QC-GATE 即時狀態
|
- **報表類**:WIP 即時概況、設備即時概況、設備歷史績效、QC-GATE 即時狀態
|
||||||
- **查詢類**:設備維修查詢、批次追蹤工具、TMTT 不良分析
|
- **查詢類**:設備維修查詢、批次追蹤工具、TMTT 不良分析、中段製程不良追溯
|
||||||
- **開發工具**(admin only):數據表查詢、Excel 批次查詢、頁面管理、效能監控
|
- **開發工具**(admin only):數據表查詢、Excel 批次查詢、頁面管理、效能監控
|
||||||
- 抽屜/頁面配置可由管理員動態管理(新增、重排、刪除)
|
- 抽屜/頁面配置可由管理員動態管理(新增、重排、刪除)
|
||||||
|
|
||||||
@@ -523,6 +525,21 @@ A: 請確認瀏覽器允許下載檔案,並檢查查詢結果是否有資料
|
|||||||
- 站點排序依 DW_MES_SPEC_WORKCENTER_V 製程順序
|
- 站點排序依 DW_MES_SPEC_WORKCENTER_V 製程順序
|
||||||
- **技術架構**:第一個純 Vue 3 + Vite 頁面,完全脫離 Jinja2
|
- **技術架構**:第一個純 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 表格的分類卡片目錄(即時數據表/現況快照表/歷史累積表/輔助表)
|
- 顯示所有 DWH 表格的分類卡片目錄(即時數據表/現況快照表/歷史累積表/輔助表)
|
||||||
@@ -588,8 +605,8 @@ CIRCUIT_BREAKER_RECOVERY_TIMEOUT=30
|
|||||||
| 技術 | 用途 |
|
| 技術 | 用途 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| Jinja2 | 模板引擎(既有頁面) |
|
| Jinja2 | 模板引擎(既有頁面) |
|
||||||
| Vue 3 | UI 框架(QC-GATE、Tables、WIP 三頁、設備雙頁已遷移,漸進式擴展中) |
|
| Vue 3 | UI 框架(QC-GATE、Tables、WIP 三頁、設備雙頁、中段不良追溯已遷移,漸進式擴展中) |
|
||||||
| vue-echarts | ECharts Vue 封裝(QC-GATE、WIP Overview Pareto、Resource History 4 圖表) |
|
| vue-echarts | ECharts Vue 封裝(QC-GATE、WIP Overview Pareto、Resource History 4 圖表、Mid-Section Defect 7 圖表) |
|
||||||
| Vite 6 | 前端多頁模組打包(含 Vue SFC + HTML entry) |
|
| Vite 6 | 前端多頁模組打包(含 Vue SFC + HTML entry) |
|
||||||
| ECharts | 圖表庫(npm tree-shaking + 舊版靜態檔案並存) |
|
| ECharts | 圖表庫(npm tree-shaking + 舊版靜態檔案並存) |
|
||||||
| Vanilla JS Modules | 互動功能與頁面邏輯(既有頁面) |
|
| Vanilla JS Modules | 互動功能與頁面邏輯(既有頁面) |
|
||||||
@@ -640,7 +657,8 @@ DashBoard_vite/
|
|||||||
│ │ ├── dashboard/ # 儀表板查詢
|
│ │ ├── dashboard/ # 儀表板查詢
|
||||||
│ │ ├── resource/ # 設備查詢
|
│ │ ├── resource/ # 設備查詢
|
||||||
│ │ ├── wip/ # WIP 查詢
|
│ │ ├── wip/ # WIP 查詢
|
||||||
│ │ └── resource_history/ # 設備歷史查詢
|
│ │ ├── resource_history/ # 設備歷史查詢
|
||||||
|
│ │ └── mid_section_defect/ # 中段不良追溯查詢
|
||||||
│ └── templates/ # HTML 模板
|
│ └── templates/ # HTML 模板
|
||||||
├── frontend/ # Vite 前端專案
|
├── frontend/ # Vite 前端專案
|
||||||
│ ├── src/core/ # 共用 API/欄位契約/計算 helper
|
│ ├── src/core/ # 共用 API/欄位契約/計算 helper
|
||||||
@@ -655,7 +673,8 @@ DashBoard_vite/
|
|||||||
│ ├── src/wip-shared/ # WIP 三頁共用 CSS/常數/元件
|
│ ├── src/wip-shared/ # WIP 三頁共用 CSS/常數/元件
|
||||||
│ ├── src/wip-overview/ # WIP 即時概況 (Vue 3 SFC)
|
│ ├── src/wip-overview/ # WIP 即時概況 (Vue 3 SFC)
|
||||||
│ ├── src/wip-detail/ # 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/
|
├── shared/
|
||||||
│ └── field_contracts.json # 前後端共用欄位契約
|
│ └── field_contracts.json # 前後端共用欄位契約
|
||||||
├── scripts/ # 腳本
|
├── 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
|
### 2026-02-09
|
||||||
|
|
||||||
- 完成設備雙頁 Vue 3 遷移(`/resource`、`/resource-history`):
|
- 完成設備雙頁 Vue 3 遷移(`/resource`、`/resource-history`):
|
||||||
@@ -879,5 +914,5 @@ conda run -n mes-dashboard python scripts/run_cache_benchmarks.py --enforce
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**文檔版本**: 5.3
|
**文檔版本**: 5.4
|
||||||
**最後更新**: 2026-02-09
|
**最後更新**: 2026-02-10
|
||||||
|
|||||||
@@ -78,6 +78,13 @@
|
|||||||
"drawer_id": "queries",
|
"drawer_id": "queries",
|
||||||
"order": 5
|
"order": 5
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"route": "/mid-section-defect",
|
||||||
|
"name": "中段製程不良追溯",
|
||||||
|
"status": "dev",
|
||||||
|
"drawer_id": "queries",
|
||||||
|
"order": 6
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"route": "/admin/pages",
|
"route": "/admin/pages",
|
||||||
"name": "頁面管理",
|
"name": "頁面管理",
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host",
|
"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"
|
"test": "node --test tests/*.test.js"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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'),
|
tables: resolve(__dirname, 'src/tables/index.html'),
|
||||||
'query-tool': resolve(__dirname, 'src/query-tool/main.js'),
|
'query-tool': resolve(__dirname, 'src/query-tool/main.js'),
|
||||||
'tmtt-defect': resolve(__dirname, 'src/tmtt-defect/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: {
|
output: {
|
||||||
entryFileNames: '[name].js',
|
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")
|
dist_dir = os.path.join(app.static_folder or "", "dist")
|
||||||
return send_from_directory(dist_dir, 'qc-gate.html')
|
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)
|
# 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
|
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
|
# Table Utilities
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from .job_query_routes import job_query_bp
|
|||||||
from .query_tool_routes import query_tool_bp
|
from .query_tool_routes import query_tool_bp
|
||||||
from .tmtt_defect_routes import tmtt_defect_bp
|
from .tmtt_defect_routes import tmtt_defect_bp
|
||||||
from .qc_gate_routes import qc_gate_bp
|
from .qc_gate_routes import qc_gate_bp
|
||||||
|
from .mid_section_defect_routes import mid_section_defect_bp
|
||||||
|
|
||||||
|
|
||||||
def register_routes(app) -> None:
|
def register_routes(app) -> None:
|
||||||
@@ -30,6 +31,7 @@ def register_routes(app) -> None:
|
|||||||
app.register_blueprint(query_tool_bp)
|
app.register_blueprint(query_tool_bp)
|
||||||
app.register_blueprint(tmtt_defect_bp)
|
app.register_blueprint(tmtt_defect_bp)
|
||||||
app.register_blueprint(qc_gate_bp)
|
app.register_blueprint(qc_gate_bp)
|
||||||
|
app.register_blueprint(mid_section_defect_bp)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'wip_bp',
|
'wip_bp',
|
||||||
@@ -44,5 +46,6 @@ __all__ = [
|
|||||||
'query_tool_bp',
|
'query_tool_bp',
|
||||||
'tmtt_defect_bp',
|
'tmtt_defect_bp',
|
||||||
'qc_gate_bp',
|
'qc_gate_bp',
|
||||||
|
'mid_section_defect_bp',
|
||||||
'register_routes',
|
'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