diff --git a/data/page_status.json b/data/page_status.json index 3cdbcd5..79cc814 100644 --- a/data/page_status.json +++ b/data/page_status.json @@ -12,6 +12,13 @@ "drawer_id": "reports", "order": 1 }, + { + "route": "/hold-overview", + "name": "Hold 即時概況", + "status": "dev", + "drawer_id": "reports", + "order": 2 + }, { "route": "/wip-detail", "name": "WIP 明細", @@ -27,14 +34,14 @@ "name": "設備歷史績效", "status": "released", "drawer_id": "reports", - "order": 3 + "order": 4 }, { "route": "/qc-gate", "name": "QC-GATE 狀態", "status": "released", "drawer_id": "reports", - "order": 4 + "order": 5 }, { "route": "/tables", @@ -48,7 +55,7 @@ "name": "設備即時概況", "status": "released", "drawer_id": "reports", - "order": 2 + "order": 3 }, { "route": "/excel-query", diff --git a/frontend/package.json b/frontend/package.json index 41d2356..a7ce740 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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 && cp ../src/mes_dashboard/static/dist/src/mid-section-defect/index.html ../src/mes_dashboard/static/dist/mid-section-defect.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/hold-overview/index.html ../src/mes_dashboard/static/dist/hold-overview.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": { diff --git a/frontend/src/hold-overview/App.vue b/frontend/src/hold-overview/App.vue new file mode 100644 index 0000000..24aa534 --- /dev/null +++ b/frontend/src/hold-overview/App.vue @@ -0,0 +1,521 @@ + + + diff --git a/frontend/src/hold-overview/components/FilterBar.vue b/frontend/src/hold-overview/components/FilterBar.vue new file mode 100644 index 0000000..5d32306 --- /dev/null +++ b/frontend/src/hold-overview/components/FilterBar.vue @@ -0,0 +1,99 @@ + + + diff --git a/frontend/src/hold-overview/components/FilterIndicator.vue b/frontend/src/hold-overview/components/FilterIndicator.vue new file mode 100644 index 0000000..ab2acc8 --- /dev/null +++ b/frontend/src/hold-overview/components/FilterIndicator.vue @@ -0,0 +1,77 @@ + + + diff --git a/frontend/src/hold-overview/components/HoldMatrix.vue b/frontend/src/hold-overview/components/HoldMatrix.vue new file mode 100644 index 0000000..974a01a --- /dev/null +++ b/frontend/src/hold-overview/components/HoldMatrix.vue @@ -0,0 +1,149 @@ + + + diff --git a/frontend/src/hold-overview/components/HoldTreeMap.vue b/frontend/src/hold-overview/components/HoldTreeMap.vue new file mode 100644 index 0000000..c4a8b9e --- /dev/null +++ b/frontend/src/hold-overview/components/HoldTreeMap.vue @@ -0,0 +1,225 @@ + + + diff --git a/frontend/src/hold-overview/components/LotTable.vue b/frontend/src/hold-overview/components/LotTable.vue new file mode 100644 index 0000000..3f6b780 --- /dev/null +++ b/frontend/src/hold-overview/components/LotTable.vue @@ -0,0 +1,132 @@ + + + diff --git a/frontend/src/hold-overview/index.html b/frontend/src/hold-overview/index.html new file mode 100644 index 0000000..f182cbb --- /dev/null +++ b/frontend/src/hold-overview/index.html @@ -0,0 +1,12 @@ + + + + + + Hold Overview + + +
+ + + diff --git a/frontend/src/hold-overview/main.js b/frontend/src/hold-overview/main.js new file mode 100644 index 0000000..badc9a1 --- /dev/null +++ b/frontend/src/hold-overview/main.js @@ -0,0 +1,7 @@ +import { createApp } from 'vue'; + +import App from './App.vue'; +import '../wip-shared/styles.css'; +import './style.css'; + +createApp(App).mount('#app'); diff --git a/frontend/src/hold-overview/style.css b/frontend/src/hold-overview/style.css new file mode 100644 index 0000000..092e0a2 --- /dev/null +++ b/frontend/src/hold-overview/style.css @@ -0,0 +1,374 @@ +.hold-overview-page .header h1 { + font-size: 22px; +} + +.hold-type-badge { + display: inline-block; + padding: 4px 10px; + border-radius: 4px; + font-size: 12px; + font-weight: 600; + background: rgba(255, 255, 255, 0.2); + color: #fff; +} + +.error-banner { + margin-bottom: 14px; + padding: 10px 12px; + border-radius: 6px; + background: #fef2f2; + color: #991b1b; + font-size: 13px; +} + +.hold-summary-row { + grid-template-columns: repeat(5, minmax(0, 1fr)); +} + +.card { + background: var(--card-bg); + border-radius: 10px; + box-shadow: var(--shadow); + overflow: hidden; + margin-bottom: 14px; +} + +.card-header { + padding: 14px 20px; + border-bottom: 1px solid var(--border); + background: #fafbfc; +} + +.card-title { + font-size: 15px; + font-weight: 600; + color: var(--text); +} + +.card-body { + padding: 16px; +} + +.filter-bar { + display: flex; + align-items: flex-end; + gap: 20px; + padding: 16px 20px; +} + +.filter-group { + display: flex; + flex-direction: column; + gap: 8px; +} + +.filter-label { + font-size: 12px; + font-weight: 600; + color: var(--muted); +} + +.radio-group { + display: flex; + gap: 12px; + flex-wrap: wrap; +} + +.radio-option { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + border: 1px solid var(--border); + border-radius: 8px; + cursor: pointer; + font-size: 13px; + background: #fff; +} + +.radio-option.active { + border-color: var(--primary); + background: #eef2ff; + color: var(--primary-dark); + font-weight: 600; +} + +.radio-option input { + margin: 0; +} + +.reason-select { + min-width: 240px; + padding: 8px 10px; + border: 1px solid var(--border); + border-radius: 8px; + font-size: 13px; + background: #fff; +} + +.reason-select:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2); +} + +.matrix-container { + padding: 0; + max-height: 460px; + overflow: auto; +} + +.hold-matrix-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; +} + +.hold-matrix-table th, +.hold-matrix-table td { + padding: 8px 10px; + text-align: right; + border: 1px solid #e5e7eb; + white-space: nowrap; +} + +.hold-matrix-table th { + background: #f3f4f6; + font-weight: 600; + position: sticky; + top: 0; + z-index: 1; + border-bottom: 2px solid #cbd5e1; +} + +.hold-matrix-table th:first-child { + text-align: left; + position: sticky; + left: 0; + z-index: 3; + background: #e5e7eb; + border-right: 2px solid #cbd5e1; +} + +.hold-matrix-table td:first-child { + text-align: left; + font-weight: 600; + position: sticky; + left: 0; + z-index: 1; + background: #f9fafb; + border-right: 2px solid #cbd5e1; +} + +.hold-matrix-table .clickable { + cursor: pointer; +} + +.hold-matrix-table .clickable:hover { + background: #eef2ff; +} + +.hold-matrix-table .total-row td, +.hold-matrix-table .total-col { + background: #e5e7eb; + font-weight: 700; +} + +.hold-matrix-table tr.active td { + background: #e0e7ff; +} + +.hold-matrix-table tr.active td:first-child { + background: #c7d2fe; +} + +.hold-matrix-table td.active, +.hold-matrix-table th.active { + background: #c7d2fe; + color: #1e3a8a; + font-weight: 700; +} + +.cascade-filter-indicator { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + margin: 2px 0 14px; +} + +.filter-chip { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + border-radius: 999px; + font-size: 12px; + font-weight: 600; +} + +.filter-chip.matrix { + background: #e0e7ff; + color: #3730a3; +} + +.filter-chip.treemap { + background: #fee2e2; + color: #991b1b; +} + +.chip-clear { + border: none; + background: transparent; + cursor: pointer; + font-size: 16px; + line-height: 1; + color: inherit; +} + +.clear-all-btn { + padding: 6px 12px; + font-size: 12px; +} + +.treemap-body { + padding: 12px 14px 14px; +} + +.treemap-section { + display: flex; + flex-direction: column; + gap: 10px; +} + +.treemap-chart { + width: 100%; + height: clamp(300px, 44vh, 540px); +} + +.treemap-legend { + display: flex; + flex-wrap: wrap; + gap: 10px; + font-size: 12px; + color: #475569; +} + +.legend-color { + width: 10px; + height: 10px; + border-radius: 50%; + display: inline-block; + margin-right: 5px; + background: var(--legend-color); +} + +.treemap-empty { + min-height: 220px; + display: flex; + align-items: center; + justify-content: center; +} + +.table-section { + background: var(--card-bg); + border-radius: 10px; + box-shadow: var(--shadow); + overflow: hidden; +} + +.table-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 14px 20px; + border-bottom: 1px solid var(--border); + background: #fafbfc; + flex-wrap: wrap; + gap: 12px; +} + +.table-title { + font-size: 16px; + font-weight: 600; + color: var(--text); +} + +.table-info { + font-size: 13px; + color: var(--muted); +} + +.table-container { + overflow-x: auto; + max-height: 520px; +} + +.lot-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +.lot-table th, +.lot-table td { + padding: 10px 12px; + text-align: left; + border-bottom: 1px solid #f0f0f0; + white-space: nowrap; +} + +.lot-table th { + background: #f8f9fa; + font-weight: 600; + position: sticky; + top: 0; + z-index: 1; +} + +.lot-table th:nth-child(3), +.lot-table th:nth-child(7), +.lot-table td:nth-child(3), +.lot-table td:nth-child(7) { + text-align: right; +} + +.lot-table tbody tr:hover { + background: #f8f9fc; +} + +@media (max-width: 1450px) { + .hold-summary-row { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +@media (max-width: 1000px) { + .hold-summary-row { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .filter-bar { + flex-direction: column; + align-items: stretch; + } + + .reason-select { + width: 100%; + min-width: 0; + } +} + +@media (max-width: 768px) { + .dashboard.hold-overview-page { + padding: 14px; + } + + .hold-summary-row { + grid-template-columns: 1fr; + } + + .header { + padding: 14px; + } +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js index e97f7e1..d2bb97b 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -16,6 +16,7 @@ export default defineConfig(({ mode }) => ({ 'wip-overview': resolve(__dirname, 'src/wip-overview/index.html'), 'wip-detail': resolve(__dirname, 'src/wip-detail/index.html'), 'hold-detail': resolve(__dirname, 'src/hold-detail/index.html'), + 'hold-overview': resolve(__dirname, 'src/hold-overview/index.html'), 'resource-status': resolve(__dirname, 'src/resource-status/index.html'), 'resource-history': resolve(__dirname, 'src/resource-history/index.html'), 'job-query': resolve(__dirname, 'src/job-query/main.js'), diff --git a/openspec/changes/archive/2026-02-10-hold-lot-overview/.openspec.yaml b/openspec/changes/archive/2026-02-10-hold-lot-overview/.openspec.yaml new file mode 100644 index 0000000..70eb9e0 --- /dev/null +++ b/openspec/changes/archive/2026-02-10-hold-lot-overview/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-02-10 diff --git a/openspec/changes/archive/2026-02-10-hold-lot-overview/design.md b/openspec/changes/archive/2026-02-10-hold-lot-overview/design.md new file mode 100644 index 0000000..58d30df --- /dev/null +++ b/openspec/changes/archive/2026-02-10-hold-lot-overview/design.md @@ -0,0 +1,127 @@ +## Context + +MES Dashboard 已有 WIP Overview(全局 RUN/QUEUE/HOLD 概況)和 Hold Detail(單一 Hold Reason 明細)。主管需要一個專用頁面,聚焦在 Hold Lot 的全局分析。 + +現有架構:Vue 3 SFC + Flask + Oracle(DWH.DW_MES_LOT_V),使用 Redis cache + snapshot indexes 加速查詢。經審計,wip_service.py 中已有大量可複用的函數和 cache 基礎設施: + +**可直接呼叫**: +- `get_wip_matrix(status='HOLD', hold_type=...)` — Matrix 查詢,零改動 +- `_select_with_snapshot_indexes(status='HOLD', hold_type=...)` — 已有 `wip_status['HOLD']` 和 `hold_type['quality'|'non-quality']` snapshot indexes +- `_get_wip_dataframe()` — L1 process cache (30s) → L2 Redis cache,`AGEBYDAYS` 欄位已在 View 中預先計算 + +**可擴充(向後相容)**: +- `get_hold_detail_summary(reason)` — 已有 totalLots/totalQty/avgAge/maxAge/workcenterCount,reason 改 optional 即可 +- `get_hold_detail_lots(reason, ...)` — 已有完整分頁邏輯 + workcenter/package/age_range 過濾,reason 改 optional + 加 hold_type 即可 + +**前端可直接 import**: +- `hold-detail/SummaryCards.vue` — props `{ totalLots, totalQty, avgAge, maxAge, workcenterCount }` 完全相容 +- `wip-shared/Pagination.vue`、`useAutoRefresh`、`core/api.js`、`wip-shared/constants.js` + +## Goals / Non-Goals + +**Goals:** +- 提供主管一覽各站 Hold Lot 情況的獨立頁面 +- TreeMap 視覺化讓嚴重程度一目了然(面積=QTY,顏色=滯留天數) +- Matrix + TreeMap + Table 三層 cascade 篩選,互動流暢 +- 預設品質異常 Hold,可切換 +- 最大化複用現有 service 函數、cache 基礎設施和前端元件 + +**Non-Goals:** +- 不取代或修改現有 Hold Detail 頁面(擴充 service 函數需向後相容) +- 不新增資料庫 view 或 table — 完全複用 DWH.DW_MES_LOT_V +- 不新增 SQL 模板 — 複用現有 summary.sql / matrix.sql / detail.sql + QueryBuilder WHERE clause +- 不實作 autocomplete 搜尋(篩選僅 Hold Type + Reason dropdown) +- 不實作 Lot 點擊展開的 detail panel(明細表為純展示) + +## Decisions + +### D1: 擴充現有 service 函數,非新建 + +**決定**: 擴充 `get_hold_detail_summary()` 和 `get_hold_detail_lots()` 的參數簽名,而非建立新的 `get_hold_overview_summary()` / `get_hold_overview_lots()`。唯一全新的函數是 `get_hold_overview_treemap()`(WC × Reason 聚合邏輯不存在於現有函數)。 + +**擴充方式**: +- `get_hold_detail_summary(reason)` → `get_hold_detail_summary(reason=None, hold_type=None)` + - `reason=None` 時聚合所有 HOLD lots + - `hold_type='quality'|'non-quality'` 進一步過濾 + - 原有 Hold Detail 呼叫 `get_hold_detail_summary(reason='xxx')` 行為不變 +- `get_hold_detail_lots(reason, ...)` → `get_hold_detail_lots(reason=None, hold_type=None, treemap_reason=None, ...)` + - `reason=None` 時返回所有 HOLD lots + - `treemap_reason` 支援 TreeMap 點擊篩選 + - 現有 Hold Detail 呼叫簽名不受影響 + +**理由**: 這兩個函數的核心邏輯(cache path + Oracle fallback + 分頁 + 過濾)完全相同,差異僅在 reason 是否為必填。新建函數會複製 80%+ 相同邏輯,增加維護負擔。 + +**替代方案**: 新建獨立函數 — 但會造成大量重複的 cache 查詢路徑和 Oracle fallback 邏輯。 + +### D2: Matrix API 直接呼叫 get_wip_matrix,僅擴充 reason 參數 + +**決定**: Hold Overview 的 Matrix API 直接呼叫現有 `get_wip_matrix(status='HOLD', hold_type=...)` 函數。唯一需要擴充的是新增 optional `reason` 參數,支援 Filter Bar 的 Reason 篩選。 + +**理由**: `get_wip_matrix` 已支援 `status` 和 `hold_type` 參數,能完全滿足 Hold Overview Matrix 的需求。擴充 reason 參數的改動量極小,且 reason=None 時行為與現有完全一致。 + +**替代方案**: 複製 matrix 邏輯到新函數 — 違反 DRY,且 matrix 排序/分頁邏輯會分散維護。 + +### D3: TreeMap 聚合是唯一需要全新建立的 service 函數 + +**決定**: 新增 `get_hold_overview_treemap()` 函數,後端返回已聚合的 `{ workcenter, reason, qty, lots, avgAge }` 陣列。此函數使用與其他函數相同的 `_select_with_snapshot_indexes()` + Oracle fallback 模式。 + +**理由**: 現有函數都沒有「按 (WORKCENTER_GROUP, HOLDREASONNAME) 二維聚合」的邏輯。這是 TreeMap 視覺化特有的需求,值得獨立建立。Hold Lot 可能數千筆,前端 groupBy 聚合會造成不必要的資料傳輸和 CPU 開銷。 + +**替代方案**: 前端從 lots API 取全部資料後自行聚合 — 資料量大時效能差,且需一次載入所有 lot。 + +### D4: TreeMap 顏色映射使用固定 4 級色階 + +**決定**: 平均滯留天數映射到 4 個顏色等級: +- `< 1 天` → 綠色 (#22c55e) +- `1-3 天` → 黃色 (#eab308) +- `3-7 天` → 橙色 (#f97316) +- `> 7 天` → 紅色 (#ef4444) + +ECharts TreeMap 使用 `visualMap` 組件實現連續色階。 + +**理由**: 與 Hold Detail 的 Age Distribution 分段一致(0-1, 1-3, 3-7, 7+),主管認知模型統一。 + +### D5: Filter cascade 為前端狀態管理,不影響 Summary 和 Matrix 的 API 呼叫 + +**決定**: +- Filter Bar(Hold Type / Reason)變更 → 呼叫全部 4 支 API +- Matrix 點擊 → 前端設定 `matrixFilter`,僅呼叫 treemap + lots API +- TreeMap 點擊 → 前端設定 `treemapFilter`,僅呼叫 lots API + +**理由**: Summary 和 Matrix 反映全局數據,不應被 Matrix/TreeMap 的 drilldown 操作影響。這與 WIP Overview 的 StatusCards 不影響 Summary 的模式一致。 + +### D6: 路由結構與 Blueprint 獨立 + +**決定**: 新建 `hold_overview_routes.py` 作為獨立 Blueprint(`hold_overview_bp`),路由前綴 `/api/hold-overview/`。頁面路由 `GET /hold-overview` 由此 Blueprint 提供。 + +**理由**: 與 `hold_routes.py`(Hold Detail)和 `wip_routes.py`(WIP Overview)平行,職責分離。 + +### D7: 前端元件複用策略 — import > 擴充 > 新建 + +**決定**: + +| 元件 | 策略 | 理由 | +|------|------|------| +| `hold-detail/SummaryCards.vue` | **直接 import** | props 形狀 `{ totalLots, totalQty, avgAge, maxAge, workcenterCount }` 完全相容 | +| `wip-shared/Pagination.vue` | **直接 import** | 已由 hold-detail/LotTable 使用,通用元件 | +| `wip-overview/MatrixTable.vue` | **參考新建 `HoldMatrix.vue`** | 需要 cell click + column click + active highlight — 原有只有 row drilldown,改動幅度大不適合直接修改原件 | +| `hold-detail/LotTable.vue` | **參考新建 `LotTable.vue`** | 需加 Hold Reason 欄位 + 移除 Spec 欄位 — hold-detail 不需要 reason 欄(已在 URL 參數中),直接修改會破壞現有頁面 | +| `HoldTreeMap.vue` | **全新** | 無現有 TreeMap 元件 | +| `FilterBar.vue` | **全新** | Hold Type radio + Reason dropdown 是此頁獨有的 UI | +| `FilterIndicator.vue` | **全新** | cascade filter 指示器是此頁獨有的 UI | + +**理由**: 直接修改跨頁面共用的元件有破壞現有頁面的風險。props 完全相容的元件直接 import;需要結構性改動的元件則基於現有程式碼新建,保留一致的 coding pattern 但避免耦合。 + +### D8: ECharts TreeMap 模組 tree-shaking + +**決定**: 前端使用 `import { TreemapChart } from 'echarts/charts'` 按需導入,搭配現有 `vendor-echarts` chunk。 + +**理由**: 現有 ECharts vendor chunk 已包含 BarChart、LineChart 等。TreemapChart 加入後仍在同一 chunk,不增加額外 HTTP request。 + +## Risks / Trade-offs + +- **[向後相容]** 擴充 `get_hold_detail_summary()` 和 `get_hold_detail_lots()` 簽名時,必須確保 reason 參數的預設行為不變 → 使用 `reason=None` 預設值,現有呼叫端傳入 reason 的行為完全不變;需補充單元測試覆蓋原有 Hold Detail 的呼叫路徑 +- **[TreeMap 資料量]** 如果 Hold Reason 種類很多(>20),TreeMap 小區塊會難以辨識 → 可考慮只顯示 Top N reason,其餘歸為「其他」 +- **[Matrix 與 TreeMap 同時篩選]** 使用者可能忘記已有 matrix 篩選,誤以為 TreeMap 是全局 → 需要明確的 active filter 指示器和一鍵清除功能 +- **[ECharts TreeMap 效能]** 大量區塊時 TreeMap 渲染可能卡頓 → ECharts TreeMap 有內建 leafDepth 限制,測試時注意超過 200 個葉節點的情境 +- **[Cache 一致性]** Hold Overview 與 WIP Overview 共用同一份 cache,auto-refresh 週期相同(10 分鐘),不需調整 cache 策略 diff --git a/openspec/changes/archive/2026-02-10-hold-lot-overview/proposal.md b/openspec/changes/archive/2026-02-10-hold-lot-overview/proposal.md new file mode 100644 index 0000000..921da68 --- /dev/null +++ b/openspec/changes/archive/2026-02-10-hold-lot-overview/proposal.md @@ -0,0 +1,36 @@ +## Why + +主管需要一個獨立頁面,專注於線上 Hold Lot 的全局觀。目前 WIP Overview 的 Pareto 圖混合在所有 WIP 資料中,而 Hold Detail 只能看單一 Hold Reason 的明細。缺少一個可以「一覽各站 Hold Lot 情況」的專用分析頁面,讓主管能快速掌握哪些站別、哪些原因造成最多 Hold,以及滯留嚴重程度。 + +## What Changes + +- 新增 `/hold-overview` 頁面(Vue 3 SFC + ECharts TreeMap),獨立於現有 WIP Overview 和 Hold Detail +- 新增 Flask Blueprint 與 4 支 API endpoints(summary / matrix / treemap / lots) +- 頁面預設只顯示品質異常 Hold,可切換至非品質異常或全部 +- 提供 Workcenter x Package Matrix(如 WIP Overview),數字可點擊篩選下方所有資料 +- 提供 TreeMap 視覺化(WC → Reason 層級,面積=QTY,顏色=平均滯留天數) +- 提供 paginated Hold Lot 明細表 +- 篩選 cascade 機制:Filter Bar → 全部重載;Matrix 點擊 → TreeMap + Table;TreeMap 點擊 → Table +- 新增 Vite multi-entry 設定 + +## Capabilities + +### New Capabilities +- `hold-overview-page`: Hold Lot Overview 頁面的完整功能規格,包含篩選器、Summary Cards、Matrix、TreeMap、明細表及 filter cascade 互動邏輯 +- `hold-overview-api`: Hold Overview 後端 API 端點(summary / matrix / treemap / lots),從 DWH.DW_MES_LOT_V 查詢 Hold Lot 資料 + +### Modified Capabilities +- `vue-vite-page-architecture`: 新增 `hold-overview` 作為 Vite multi-entry HTML entry point + +## Impact + +- **Backend(擴充現有)**: 擴充 `wip_service.py` 中 `get_hold_detail_summary()` 和 `get_hold_detail_lots()` — 將 `reason` 改為 optional 並新增 `hold_type` 參數,向後相容;擴充 `get_wip_matrix()` 新增 optional `reason` 參數 +- **Backend(唯一新增函數)**: `get_hold_overview_treemap()` — WC × Reason 聚合 + avgAge 計算 +- **Backend(新增路由)**: `src/mes_dashboard/routes/hold_overview_routes.py`(Flask Blueprint,4 支 API) +- **Frontend(直接複用)**: `hold-detail/SummaryCards.vue`、`wip-shared/Pagination.vue`、`useAutoRefresh`、`core/api.js`、`wip-shared/constants.js` +- **Frontend(基於現有擴充)**: 基於 `MatrixTable.vue` 建 `HoldMatrix.vue`(加 cell/column click);基於 `hold-detail/LotTable.vue` 建 `LotTable.vue`(加 Hold Reason 欄位) +- **Frontend(全新元件)**: `HoldTreeMap.vue`、`FilterBar.vue`、`FilterIndicator.vue` +- **Vite Config**: `vite.config.js` 新增 `hold-overview` entry +- **Dependencies**: `echarts/charts` 的 `TreemapChart`(進入現有 `vendor-echarts` chunk) +- **Cache**: 完全複用現有 Redis cache + snapshot indexes(已有 `wip_status['HOLD']` 和 `hold_type['quality'|'non-quality']` 索引),零改動 +- **SQL**: 不需新增 SQL 模板 — 複用現有 summary.sql / matrix.sql / detail.sql + QueryBuilder WHERE clause diff --git a/openspec/changes/archive/2026-02-10-hold-lot-overview/specs/hold-overview-api/spec.md b/openspec/changes/archive/2026-02-10-hold-lot-overview/specs/hold-overview-api/spec.md new file mode 100644 index 0000000..caa1cdc --- /dev/null +++ b/openspec/changes/archive/2026-02-10-hold-lot-overview/specs/hold-overview-api/spec.md @@ -0,0 +1,137 @@ +## ADDED Requirements + +### Requirement: Hold Overview API SHALL provide summary statistics +The API SHALL return aggregated summary KPIs for hold lots. + +#### Scenario: Summary endpoint delegates to extended get_hold_detail_summary +- **WHEN** `GET /api/hold-overview/summary` is called with `hold_type=quality` +- **THEN** the route SHALL delegate to the extended `get_hold_detail_summary(reason=None, hold_type='quality')` +- **THEN** the response SHALL return `{ success: true, data: { totalLots, totalQty, workcenterCount, avgAge, maxAge, dataUpdateDate } }` +- **THEN** only lots with EQUIPMENTCOUNT=0 AND CURRENTHOLDCOUNT>0 SHALL be included +- **THEN** hold_type classification SHALL use the same NON_QUALITY_HOLD_REASONS set as existing hold endpoints + +#### Scenario: Summary with reason filter +- **WHEN** `GET /api/hold-overview/summary?hold_type=quality&reason=品質確認` is called +- **THEN** the response SHALL only include lots where HOLDREASONNAME equals the specified reason + +#### Scenario: Summary hold_type=all +- **WHEN** `GET /api/hold-overview/summary?hold_type=all` is called +- **THEN** the response SHALL include both quality and non-quality hold lots + +#### Scenario: Summary error +- **WHEN** the database query fails +- **THEN** the response SHALL return `{ success: false, error: '查詢失敗' }` with HTTP 500 + +### Requirement: Hold Overview API SHALL provide workcenter x package matrix +The API SHALL return a cross-tabulation of workcenters and packages for hold lots. + +#### Scenario: Matrix endpoint +- **WHEN** `GET /api/hold-overview/matrix` is called with `hold_type=quality` +- **THEN** the response SHALL return the same matrix structure as `/api/wip/overview/matrix`: `{ workcenters, packages, matrix, workcenter_totals, package_totals, grand_total }` +- **THEN** workcenters SHALL be sorted by WORKCENTERSEQUENCE_GROUP +- **THEN** packages SHALL be sorted by total QTY descending +- **THEN** only HOLD status lots matching the hold_type SHALL be included + +#### Scenario: Matrix delegates to existing get_wip_matrix +- **WHEN** the matrix endpoint is called +- **THEN** it SHALL delegate to `get_wip_matrix(status='HOLD', hold_type=...)` from wip_service.py + +#### Scenario: Matrix with reason filter +- **WHEN** `GET /api/hold-overview/matrix?hold_type=quality&reason=品質確認` is called +- **THEN** the matrix SHALL only include lots where HOLDREASONNAME equals the specified reason + +### Requirement: Hold Overview API SHALL provide TreeMap aggregation data +The API SHALL return aggregated data suitable for TreeMap visualization. + +#### Scenario: TreeMap endpoint uses new get_hold_overview_treemap function +- **WHEN** `GET /api/hold-overview/treemap` is called with `hold_type=quality` +- **THEN** the route SHALL delegate to `get_hold_overview_treemap()` (the only new service function) +- **THEN** the response SHALL return `{ success: true, data: { items: [...] } }` +- **THEN** each item SHALL contain `{ workcenter, reason, lots, qty, avgAge }` +- **THEN** items SHALL be grouped by (WORKCENTER_GROUP, HOLDREASONNAME) +- **THEN** avgAge SHALL be calculated using the pre-computed AGEBYDAYS column from DW_MES_LOT_V + +#### Scenario: TreeMap with matrix filter +- **WHEN** `GET /api/hold-overview/treemap?hold_type=quality&workcenter=WC-MOLD&package=PKG-A` is called +- **THEN** the response SHALL only include lots matching the workcenter AND package filters + +#### Scenario: TreeMap with reason filter +- **WHEN** `GET /api/hold-overview/treemap?hold_type=quality&reason=品質確認` is called +- **THEN** the response SHALL only include lots where HOLDREASONNAME equals the specified reason + +#### Scenario: TreeMap empty result +- **WHEN** no hold lots match the filters +- **THEN** the response SHALL return `{ success: true, data: { items: [] } }` + +### Requirement: Hold Overview API SHALL provide paginated lot details +The API SHALL return a paginated list of hold lot details. + +#### Scenario: Lots endpoint delegates to extended get_hold_detail_lots +- **WHEN** `GET /api/hold-overview/lots?hold_type=quality&page=1&per_page=50` is called +- **THEN** the route SHALL delegate to the extended `get_hold_detail_lots(reason=None, hold_type='quality', ...)` +- **THEN** the response SHALL return `{ success: true, data: { lots: [...], pagination: { page, perPage, total, totalPages } } }` +- **THEN** each lot SHALL contain: lotId, workorder, qty, package, workcenter, holdReason, age, holdBy, dept, holdComment +- **THEN** lots SHALL be sorted by age descending (longest hold first) + +#### Scenario: Lots with matrix filter +- **WHEN** `GET /api/hold-overview/lots?hold_type=quality&workcenter=WC-MOLD&package=PKG-A` is called +- **THEN** only lots matching the workcenter AND package filters SHALL be returned + +#### Scenario: Lots with treemap filter +- **WHEN** `GET /api/hold-overview/lots?hold_type=quality&workcenter=WC-MOLD&treemap_reason=品質確認` is called +- **THEN** only lots matching the workcenter AND treemap_reason SHALL be returned + +#### Scenario: Lots with all filters combined +- **WHEN** `GET /api/hold-overview/lots?hold_type=quality&reason=品質確認&workcenter=WC-MOLD&package=PKG-A&treemap_reason=品質確認` is called +- **THEN** all filters SHALL be applied as AND conditions + +#### Scenario: Lots pagination bounds +- **WHEN** `page` is less than 1 +- **THEN** page SHALL be treated as 1 +- **WHEN** `per_page` exceeds 200 +- **THEN** per_page SHALL be capped at 200 + +#### Scenario: Lots error +- **WHEN** the database query fails +- **THEN** the response SHALL return `{ success: false, error: '查詢失敗' }` with HTTP 500 + +### Requirement: Hold Overview API SHALL apply rate limiting +The API SHALL apply rate limiting to expensive endpoints. + +#### Scenario: Rate limit on lots endpoint +- **WHEN** the lots endpoint receives excessive requests +- **THEN** rate limiting SHALL be applied using `configured_rate_limit` with a default of 90 requests per 60 seconds + +#### Scenario: Rate limit on matrix endpoint +- **WHEN** the matrix endpoint receives excessive requests +- **THEN** rate limiting SHALL be applied using `configured_rate_limit` with a default of 120 requests per 60 seconds + +### Requirement: Hold Overview page route SHALL serve static Vite HTML +The Flask route SHALL serve the pre-built Vite HTML file. + +#### Scenario: Page route +- **WHEN** user navigates to `/hold-overview` +- **THEN** Flask SHALL serve the pre-built HTML file from `static/dist/hold-overview.html` via `send_from_directory` +- **THEN** the HTML SHALL NOT pass through Jinja2 template rendering + +#### Scenario: Fallback HTML +- **WHEN** the pre-built HTML file does not exist +- **THEN** Flask SHALL return a minimal HTML page with the correct script tag and module import + +### Requirement: Extended service functions SHALL maintain backward compatibility +The extended `get_hold_detail_summary()`, `get_hold_detail_lots()`, and `get_wip_matrix()` SHALL not break existing callers. + +#### Scenario: Hold Detail summary backward compatibility +- **WHEN** existing Hold Detail code calls `get_hold_detail_summary(reason='xxx')` +- **THEN** the result SHALL be identical to the pre-extension behavior +- **THEN** the new `hold_type` parameter SHALL default to None (no additional filtering) + +#### Scenario: Hold Detail lots backward compatibility +- **WHEN** existing Hold Detail code calls `get_hold_detail_lots(reason='xxx', workcenter=..., package=..., age_range=...)` +- **THEN** the result SHALL be identical to the pre-extension behavior +- **THEN** the new `hold_type` and `treemap_reason` parameters SHALL default to None + +#### Scenario: WIP Overview matrix backward compatibility +- **WHEN** existing WIP Overview code calls `get_wip_matrix(status='HOLD', hold_type='quality')` +- **THEN** the result SHALL be identical to the pre-extension behavior +- **THEN** the new `reason` parameter SHALL default to None (no HOLDREASONNAME filtering) diff --git a/openspec/changes/archive/2026-02-10-hold-lot-overview/specs/hold-overview-page/spec.md b/openspec/changes/archive/2026-02-10-hold-lot-overview/specs/hold-overview-page/spec.md new file mode 100644 index 0000000..f95ee22 --- /dev/null +++ b/openspec/changes/archive/2026-02-10-hold-lot-overview/specs/hold-overview-page/spec.md @@ -0,0 +1,196 @@ +## ADDED Requirements + +### Requirement: Hold Overview page SHALL display a filter bar with Hold Type and Reason +The page SHALL provide a filter bar for selecting hold type and hold reason. + +#### Scenario: Hold Type radio default +- **WHEN** the page loads +- **THEN** the Hold Type filter SHALL default to "品質異常" +- **THEN** three radio options SHALL display: 品質異常, 非品質異常, 全部 + +#### Scenario: Hold Type change reloads all data +- **WHEN** user changes the Hold Type selection +- **THEN** all four API calls (summary, matrix, treemap, lots) SHALL reload with the new hold_type parameter +- **THEN** any active matrix and treemap filters SHALL be cleared + +#### Scenario: Reason dropdown populated from current data +- **WHEN** summary data is loaded +- **THEN** the Reason dropdown SHALL display "全部" plus all distinct hold reasons from the treemap data +- **THEN** selecting a specific reason SHALL reload all four API calls filtered by that reason +- **THEN** any active matrix and treemap filters SHALL be cleared + +### Requirement: Hold Overview page SHALL display summary KPI cards +The page SHALL show summary statistics for all hold lots matching the current filter. + +#### Scenario: Summary cards rendering +- **WHEN** summary data is loaded from `GET /api/hold-overview/summary` +- **THEN** five cards SHALL display: Hold Lots, Hold QTY, 站別數, 平均滯留天數, 最大滯留天數 +- **THEN** lot and QTY values SHALL use zh-TW number formatting +- **THEN** age values SHALL display with "天" suffix and one decimal place + +#### Scenario: Summary reflects filter bar only +- **WHEN** user clicks a matrix cell or treemap block +- **THEN** summary cards SHALL NOT change (they only respond to filter bar changes) + +### Requirement: Hold Overview page SHALL display a Workcenter x Package matrix +The page SHALL display a cross-tabulation matrix of workcenters vs packages for hold lots. + +#### Scenario: Matrix table rendering +- **WHEN** matrix data is loaded from `GET /api/hold-overview/matrix` +- **THEN** the table SHALL display workcenters as rows and packages as columns +- **THEN** cell values SHALL show QTY with zh-TW number formatting +- **THEN** the first column (Workcenter) SHALL be sticky on horizontal scroll +- **THEN** a Total row and Total column SHALL be displayed +- **THEN** cells with zero value SHALL display "-" + +#### Scenario: Matrix cell click filters TreeMap and lot table +- **WHEN** user clicks a QTY cell in the matrix (intersection of workcenter and package) +- **THEN** `matrixFilter` SHALL be set to `{ workcenter, package }` +- **THEN** TreeMap SHALL reload showing only data for that workcenter + package combination +- **THEN** lot table SHALL reload filtered by that workcenter + package +- **THEN** the clicked cell SHALL show an active highlight + +#### Scenario: Matrix workcenter row click +- **WHEN** user clicks a workcenter name or its Total cell +- **THEN** `matrixFilter` SHALL be set to `{ workcenter }` (all packages) +- **THEN** TreeMap and lot table SHALL reload filtered by that workcenter + +#### Scenario: Matrix package column click +- **WHEN** user clicks a package column header or its Total cell +- **THEN** `matrixFilter` SHALL be set to `{ package }` (all workcenters) +- **THEN** TreeMap and lot table SHALL reload filtered by that package + +#### Scenario: Matrix click toggle +- **WHEN** user clicks the same cell/row/column that is already active +- **THEN** `matrixFilter` SHALL be cleared +- **THEN** TreeMap and lot table SHALL reload without matrix filter + +#### Scenario: Matrix reflects filter bar only +- **WHEN** user clicks a treemap block +- **THEN** matrix SHALL NOT change (it only responds to filter bar changes) + +### Requirement: Hold Overview page SHALL display active filter indicators +The page SHALL show a clear indicator of active cascade filters. + +#### Scenario: Matrix filter indicator +- **WHEN** a matrix filter is active +- **THEN** a filter indicator SHALL display between the matrix and TreeMap sections +- **THEN** the indicator SHALL show the active workcenter and/or package name +- **THEN** a clear button (✕) SHALL remove the matrix filter + +#### Scenario: TreeMap filter indicator +- **WHEN** a treemap filter is active +- **THEN** a filter indicator SHALL display between the TreeMap and lot table sections +- **THEN** the indicator SHALL show the active workcenter and reason name +- **THEN** a clear button (✕) SHALL remove the treemap filter + +#### Scenario: Clear all filters +- **WHEN** user clicks a "清除所有篩選" button +- **THEN** both matrixFilter and treemapFilter SHALL be cleared +- **THEN** TreeMap and lot table SHALL reload without cascade filters + +### Requirement: Hold Overview page SHALL display a TreeMap visualization +The page SHALL display a TreeMap chart showing hold lot distribution by workcenter and reason. + +#### Scenario: TreeMap rendering +- **WHEN** treemap data is loaded from `GET /api/hold-overview/treemap` +- **THEN** the TreeMap SHALL display with two levels: Workcenter (parent) → Hold Reason (child) +- **THEN** block area SHALL represent QTY +- **THEN** block color SHALL represent average age at current station using a 4-level color scale +- **THEN** the color scale legend SHALL display: 綠(<1天), 黃(1-3天), 橙(3-7天), 紅(>7天) + +#### Scenario: TreeMap tooltip +- **WHEN** user hovers over a TreeMap block +- **THEN** a tooltip SHALL display: Workcenter, Reason, Lots count, QTY, and average age + +#### Scenario: TreeMap narrows on matrix filter (Option A) +- **WHEN** a matrix filter is active (e.g., workcenter=WC-MOLD, package=PKG-A) +- **THEN** the TreeMap SHALL only show data matching the matrix filter +- **THEN** the TreeMap API SHALL be called with workcenter and/or package parameters + +#### Scenario: TreeMap click filters lot table +- **WHEN** user clicks a leaf block in the TreeMap (a specific reason within a workcenter) +- **THEN** `treemapFilter` SHALL be set to `{ workcenter, reason }` +- **THEN** lot table SHALL reload filtered by that workcenter + reason +- **THEN** the clicked block SHALL show a visual highlight (border or opacity change) + +#### Scenario: TreeMap click toggle +- **WHEN** user clicks the same block that is already active +- **THEN** `treemapFilter` SHALL be cleared +- **THEN** lot table SHALL reload without treemap filter + +#### Scenario: Empty TreeMap +- **WHEN** treemap data returns zero items +- **THEN** the TreeMap area SHALL display "目前無 Hold 資料" + +### Requirement: Hold Overview page SHALL display paginated lot details +The page SHALL display a detailed lot table with server-side pagination. + +#### Scenario: Lot table rendering +- **WHEN** lot data is loaded from `GET /api/hold-overview/lots` +- **THEN** a table SHALL display with columns: LOTID, WORKORDER, QTY, Package, Workcenter, Hold Reason, Age, Hold By, Dept, Hold Comment +- **THEN** age values SHALL display with "天" suffix + +#### Scenario: Lot table responds to all cascade filters +- **WHEN** matrixFilter is `{ workcenter: WC-A, package: PKG-1 }` and treemapFilter is `{ reason: 品質確認 }` +- **THEN** lots API SHALL be called with `workcenter=WC-A&package=PKG-1&treemap_reason=品質確認` +- **THEN** only lots matching all active filters SHALL be displayed + +#### Scenario: Pagination +- **WHEN** total lots exceeds per_page (50) +- **THEN** Prev/Next buttons and page info SHALL display +- **THEN** page info SHALL show "顯示 {start} - {end} / {total}" + +#### Scenario: Filter changes reset pagination +- **WHEN** any filter changes (filter bar, matrix click, or treemap click) +- **THEN** pagination SHALL reset to page 1 + +#### Scenario: Empty lot result +- **WHEN** a query returns zero lots +- **THEN** the lot table SHALL display a "No data" placeholder + +### Requirement: Hold Overview page SHALL auto-refresh and handle request cancellation +The page SHALL automatically refresh data and prevent stale request pile-up. + +#### Scenario: Auto-refresh interval +- **WHEN** the page is loaded +- **THEN** data SHALL auto-refresh every 10 minutes using `useAutoRefresh` composable +- **THEN** auto-refresh SHALL be skipped when the tab is hidden + +#### Scenario: Visibility change refresh +- **WHEN** the tab becomes visible after being hidden +- **THEN** data SHALL refresh immediately + +#### Scenario: Request cancellation +- **WHEN** a new data load is triggered while a previous request is in-flight +- **THEN** the previous request SHALL be cancelled via AbortController +- **THEN** the cancelled request SHALL NOT update the UI + +#### Scenario: Manual refresh +- **WHEN** user clicks the "重新整理" button +- **THEN** all data SHALL reload and the auto-refresh timer SHALL reset +- **THEN** all cascade filters (matrixFilter, treemapFilter) SHALL be preserved during refresh + +### Requirement: Hold Overview page SHALL handle loading and error states +The page SHALL display appropriate feedback during API calls and on errors. + +#### Scenario: Initial loading overlay +- **WHEN** the page first loads +- **THEN** a full-page loading overlay SHALL display until all data is loaded + +#### Scenario: API error handling +- **WHEN** an API call fails +- **THEN** an error banner SHALL display with the error message +- **THEN** the page SHALL NOT crash or become unresponsive + +#### Scenario: Refresh indicator +- **WHEN** data is being refreshed (not initial load) +- **THEN** a spinning refresh indicator SHALL display in the header +- **THEN** a success checkmark SHALL flash briefly on completion + +### Requirement: Hold Overview page SHALL have back navigation +The page SHALL provide navigation back to WIP Overview. + +#### Scenario: Back button +- **WHEN** user clicks the "← WIP Overview" button in the header +- **THEN** the page SHALL navigate to `/wip-overview` diff --git a/openspec/changes/archive/2026-02-10-hold-lot-overview/specs/vue-vite-page-architecture/spec.md b/openspec/changes/archive/2026-02-10-hold-lot-overview/specs/vue-vite-page-architecture/spec.md new file mode 100644 index 0000000..b7153b8 --- /dev/null +++ b/openspec/changes/archive/2026-02-10-hold-lot-overview/specs/vue-vite-page-architecture/spec.md @@ -0,0 +1,40 @@ +## MODIFIED Requirements + +### Requirement: Vite config SHALL support Vue SFC and HTML entry points +The Vite build configuration SHALL support Vue Single File Components alongside existing vanilla JS entries. + +#### Scenario: Vue plugin coexistence +- **WHEN** `vite build` is executed +- **THEN** Vue SFC (`.vue` files) SHALL be compiled by `@vitejs/plugin-vue` +- **THEN** existing vanilla JS entry points SHALL continue to build without modification + +#### Scenario: HTML entry point +- **WHEN** a page uses an HTML file as its Vite entry point +- **THEN** Vite SHALL process the HTML and its referenced JS/CSS into `static/dist/` +- **THEN** the output SHALL include `.html`, `.js`, and `.css` + +#### Scenario: Chunk splitting +- **WHEN** Vite builds the project +- **THEN** Vue runtime SHALL be split into a `vendor-vue` chunk +- **THEN** ECharts modules (including TreemapChart) SHALL be split into the existing `vendor-echarts` chunk +- **THEN** chunk splitting SHALL NOT affect existing page bundles + +#### Scenario: Migrated page entry replacement +- **WHEN** a vanilla JS page is migrated to Vue 3 +- **THEN** its Vite entry SHALL change from JS file to HTML file (e.g., `src/wip-overview/main.js` → `src/wip-overview/index.html`) +- **THEN** the original JS entry SHALL be replaced, not kept alongside + +#### Scenario: Hold Overview entry point +- **WHEN** the hold-overview page is added +- **THEN** `vite.config.js` input SHALL include `'hold-overview': resolve(__dirname, 'src/hold-overview/index.html')` +- **THEN** the build SHALL produce `hold-overview.html`, `hold-overview.js`, and `hold-overview.css` in `static/dist/` + +#### Scenario: Shared CSS import across migrated pages +- **WHEN** multiple migrated pages import a shared CSS module (e.g., `wip-shared/styles.css`) +- **THEN** Vite SHALL bundle the shared CSS into each page's output CSS +- **THEN** shared CSS SHALL NOT create a separate shared chunk that requires additional HTTP requests + +#### Scenario: Shared composable import across module boundaries +- **WHEN** a migrated page imports a composable from another shared module (e.g., `hold-overview` imports `useAutoRefresh` from `wip-shared/`) +- **THEN** the composable SHALL be bundled into the importing page's JS output +- **THEN** cross-module imports SHALL NOT create unexpected shared chunks diff --git a/openspec/changes/archive/2026-02-10-hold-lot-overview/tasks.md b/openspec/changes/archive/2026-02-10-hold-lot-overview/tasks.md new file mode 100644 index 0000000..7b07629 --- /dev/null +++ b/openspec/changes/archive/2026-02-10-hold-lot-overview/tasks.md @@ -0,0 +1,72 @@ +## 1. Backend — 擴充現有 Service 函數 + +- [x] 1.1 擴充 `get_hold_detail_summary()` 簽名:`reason` 改為 `Optional[str] = None`,新增 `hold_type: Optional[str] = None` 參數;reason=None 時聚合所有 HOLD lots;hold_type 過濾品質/非品質;cache path 和 Oracle fallback 都需支援;確保現有 Hold Detail 呼叫 `get_hold_detail_summary(reason='xxx')` 行為不變 +- [x] 1.2 擴充 `get_hold_detail_lots()` 簽名:`reason` 改為 `Optional[str] = None`,新增 `hold_type: Optional[str] = None` 和 `treemap_reason: Optional[str] = None` 參數;reason=None 時返回所有 HOLD lots;treemap_reason 作為額外 HOLDREASONNAME 過濾(TreeMap 點擊篩選用);增加 holdReason 欄位到 lot 回傳資料中;確保現有 Hold Detail 呼叫不受影響 +- [x] 1.3 擴充 `get_wip_matrix()` 簽名:新增 `reason: Optional[str] = None` 參數,過濾 HOLDREASONNAME;cache path 用 DataFrame filter,Oracle fallback 用 QueryBuilder;reason=None 時行為不變,確保 WIP Overview 呼叫不受影響 +- [x] 1.4 新增 `get_hold_overview_treemap()` 函數(唯一全新函數):使用 `_select_with_snapshot_indexes(status='HOLD', hold_type=...)` 取得 HOLD DataFrame,按 (WORKCENTER_GROUP, HOLDREASONNAME) groupBy 聚合,回傳 `[{ workcenter, reason, lots, qty, avgAge }]`;接受 `hold_type`, `reason`, `workcenter`, `package` 參數;含 Oracle fallback + +## 2. Backend — 路由 + +- [x] 2.1 建立 `src/mes_dashboard/routes/hold_overview_routes.py`,Flask Blueprint `hold_overview_bp`;頁面路由 `GET /hold-overview` 以 `send_from_directory` 提供 static Vite HTML,含 fallback HTML +- [x] 2.2 實作 `GET /api/hold-overview/summary`:解析 `hold_type`(預設 `quality`)和 `reason` query params,委派給擴充後的 `get_hold_detail_summary(reason=reason, hold_type=hold_type)` +- [x] 2.3 實作 `GET /api/hold-overview/matrix`:委派給現有 `get_wip_matrix(status='HOLD', hold_type=..., reason=...)`;套用 rate limiting (120 req/60s) +- [x] 2.4 實作 `GET /api/hold-overview/treemap`:解析 `hold_type`, `reason`, `workcenter`, `package` params,委派給 `get_hold_overview_treemap()` +- [x] 2.5 實作 `GET /api/hold-overview/lots`:解析所有 filter params + 分頁,委派給擴充後的 `get_hold_detail_lots(reason=reason, hold_type=hold_type, treemap_reason=treemap_reason, ...)`;套用 rate limiting (90 req/60s);per_page 上限 200 +- [x] 2.6 在 Flask app factory(`routes/__init__.py`)中註冊 `hold_overview_bp` + +## 3. Backend — 向後相容驗證 + +- [x] 3.1 驗證 Hold Detail 頁面現有 3 支 API(summary/distribution/lots)在擴充後行為不變:`get_hold_detail_summary(reason='xxx')` 和 `get_hold_detail_lots(reason='xxx', ...)` 結果與擴充前一致 +- [x] 3.2 驗證 WIP Overview 的 `get_wip_matrix()` 呼叫在新增 reason 參數後行為不變(reason=None 預設值) + +## 4. Frontend — 腳手架 + +- [x] 4.1 建立 `frontend/src/hold-overview/` 目錄結構:`index.html`, `main.js`, `App.vue`, `style.css`, `components/` +- [x] 4.2 在 `vite.config.js` 的 input 加入 `'hold-overview': resolve(__dirname, 'src/hold-overview/index.html')` +- [x] 4.3 建立 `index.html`(Vue 3 mount point)、`main.js`(`createApp(App).mount('#app')`),import `style.css` 和 `wip-shared/styles.css` + +## 5. Frontend — FilterBar(全新) + +- [x] 5.1 建立 `components/FilterBar.vue`:Hold Type radio group(品質異常 default, 非品質異常, 全部)+ Reason dropdown(全部 + dynamic reasons);emit `change` 事件帶 `{ holdType, reason }` + +## 6. Frontend — SummaryCards(直接 import) + +- [x] 6.1 在 App.vue 中直接 `import SummaryCards from '../hold-detail/components/SummaryCards.vue'`;props 形狀 `{ totalLots, totalQty, avgAge, maxAge, workcenterCount }` 完全相容,無需新建元件 + +## 7. Frontend — HoldMatrix(基於 MatrixTable 新建) + +- [x] 7.1 建立 `components/HoldMatrix.vue`,以 `wip-overview/MatrixTable.vue` 為基礎:保留 matrix 渲染邏輯(sticky 首欄、Total row/column、"-" 零值、zh-TW 格式化) +- [x] 7.2 擴充互動:cell click → emit `{ workcenter, package }`、workcenter name/row total click → emit `{ workcenter }`、package header/column total click → emit `{ package }`;active cell/row/column highlight;toggle logic(再次點擊同一項 = 清除) + +## 8. Frontend — HoldTreeMap(全新) + +- [x] 8.1 建立 `components/HoldTreeMap.vue`:ECharts TreeMap,`import { TreemapChart } from 'echarts/charts'`;兩層結構(WC parent → Reason child);面積=QTY;`visualMap` 色階 for avgAge(綠<1天, 黃1-3天, 橙3-7天, 紅>7天) +- [x] 8.2 實作 tooltip(workcenter, reason, lots, qty, avgAge)和 click handler → emit `{ workcenter, reason }`;toggle logic;"目前無 Hold 資料" empty state +- [x] 8.3 實作 `autoresize` 和 responsive height + +## 9. Frontend — LotTable(基於 hold-detail/LotTable 新建) + +- [x] 9.1 建立 `components/LotTable.vue`,以 `hold-detail/LotTable.vue` 為基礎:保留分頁邏輯(已 import `wip-shared/Pagination.vue`)、loading/error/empty 狀態、filter indicator;替換欄位:移除 Spec,新增 Hold Reason 欄位(holdReason) + +## 10. Frontend — FilterIndicator(全新) + +- [x] 10.1 建立 `components/FilterIndicator.vue`:顯示 active matrixFilter 和/或 treemapFilter 標籤,含 ✕ 清除按鈕;任一 cascade filter 啟用時顯示「清除所有篩選」按鈕 + +## 11. Frontend — App.vue 整合 + +- [x] 11.1 串接 App.vue:import 所有元件(SummaryCards 從 hold-detail import、其餘從 local components);設定 reactive state for `filterBar`, `matrixFilter`, `treemapFilter`, `page` +- [x] 11.2 實作資料載入:`loadAllData()` 平行呼叫 4 支 API;`loadTreemapAndLots()` for matrix filter 變更;`loadLots()` for treemap filter 變更;使用 `useAutoRefresh` composable(從 `wip-shared/composables/useAutoRefresh.js` import) +- [x] 11.3 實作 filter cascade:filter bar 變更 → 清除 matrixFilter + treemapFilter → `loadAllData()`;matrix click → set matrixFilter, 清除 treemapFilter → `loadTreemapAndLots()`;treemap click → set treemapFilter → `loadLots()` +- [x] 11.4 實作 loading states(initialLoading overlay、refreshing indicator、refresh success/error)、error handling、手動重新整理按鈕、AbortController request cancellation +- [x] 11.5 從 treemap 資料的 distinct reasons 填充 Reason dropdown + +## 12. Frontend — 樣式 + +- [x] 12.1 建立 `style.css`,沿用 `wip-overview/style.css` 和 `hold-detail/style.css` 的 pattern;包含 header、summary cards、matrix table、treemap section、lot table、filter indicator、filter bar、loading overlay、error banner 樣式 + +## 13. Build & 驗證 + +- [x] 13.1 執行 `npm --prefix frontend run build`,確認 `static/dist/` 生成 `hold-overview.html`, `hold-overview.js`, `hold-overview.css` +- [x] 13.2 驗證 Flask serve `/hold-overview` 正常,4 支 API endpoint 回應正確 +- [x] 13.3 端對端測試:filter bar toggle → matrix click → treemap click → lot table cascade;驗證每層正確回應 +- [x] 13.4 回歸測試:確認 Hold Detail 頁面(`/hold-detail?reason=xxx`)功能正常不受影響;確認 WIP Overview Matrix 功能正常不受影響 diff --git a/openspec/specs/hold-overview-api/spec.md b/openspec/specs/hold-overview-api/spec.md new file mode 100644 index 0000000..9436f5b --- /dev/null +++ b/openspec/specs/hold-overview-api/spec.md @@ -0,0 +1,141 @@ +## Purpose +Define stable requirements for hold-overview-api. + +## Requirements + + +### Requirement: Hold Overview API SHALL provide summary statistics +The API SHALL return aggregated summary KPIs for hold lots. + +#### Scenario: Summary endpoint delegates to extended get_hold_detail_summary +- **WHEN** `GET /api/hold-overview/summary` is called with `hold_type=quality` +- **THEN** the route SHALL delegate to the extended `get_hold_detail_summary(reason=None, hold_type='quality')` +- **THEN** the response SHALL return `{ success: true, data: { totalLots, totalQty, workcenterCount, avgAge, maxAge, dataUpdateDate } }` +- **THEN** only lots with EQUIPMENTCOUNT=0 AND CURRENTHOLDCOUNT>0 SHALL be included +- **THEN** hold_type classification SHALL use the same NON_QUALITY_HOLD_REASONS set as existing hold endpoints + +#### Scenario: Summary with reason filter +- **WHEN** `GET /api/hold-overview/summary?hold_type=quality&reason=品質確認` is called +- **THEN** the response SHALL only include lots where HOLDREASONNAME equals the specified reason + +#### Scenario: Summary hold_type=all +- **WHEN** `GET /api/hold-overview/summary?hold_type=all` is called +- **THEN** the response SHALL include both quality and non-quality hold lots + +#### Scenario: Summary error +- **WHEN** the database query fails +- **THEN** the response SHALL return `{ success: false, error: '查詢失敗' }` with HTTP 500 + +### Requirement: Hold Overview API SHALL provide workcenter x package matrix +The API SHALL return a cross-tabulation of workcenters and packages for hold lots. + +#### Scenario: Matrix endpoint +- **WHEN** `GET /api/hold-overview/matrix` is called with `hold_type=quality` +- **THEN** the response SHALL return the same matrix structure as `/api/wip/overview/matrix`: `{ workcenters, packages, matrix, workcenter_totals, package_totals, grand_total }` +- **THEN** workcenters SHALL be sorted by WORKCENTERSEQUENCE_GROUP +- **THEN** packages SHALL be sorted by total QTY descending +- **THEN** only HOLD status lots matching the hold_type SHALL be included + +#### Scenario: Matrix delegates to existing get_wip_matrix +- **WHEN** the matrix endpoint is called +- **THEN** it SHALL delegate to `get_wip_matrix(status='HOLD', hold_type=...)` from wip_service.py + +#### Scenario: Matrix with reason filter +- **WHEN** `GET /api/hold-overview/matrix?hold_type=quality&reason=品質確認` is called +- **THEN** the matrix SHALL only include lots where HOLDREASONNAME equals the specified reason + +### Requirement: Hold Overview API SHALL provide TreeMap aggregation data +The API SHALL return aggregated data suitable for TreeMap visualization. + +#### Scenario: TreeMap endpoint uses new get_hold_overview_treemap function +- **WHEN** `GET /api/hold-overview/treemap` is called with `hold_type=quality` +- **THEN** the route SHALL delegate to `get_hold_overview_treemap()` (the only new service function) +- **THEN** the response SHALL return `{ success: true, data: { items: [...] } }` +- **THEN** each item SHALL contain `{ workcenter, reason, lots, qty, avgAge }` +- **THEN** items SHALL be grouped by (WORKCENTER_GROUP, HOLDREASONNAME) +- **THEN** avgAge SHALL be calculated using the pre-computed AGEBYDAYS column from DW_MES_LOT_V + +#### Scenario: TreeMap with matrix filter +- **WHEN** `GET /api/hold-overview/treemap?hold_type=quality&workcenter=WC-MOLD&package=PKG-A` is called +- **THEN** the response SHALL only include lots matching the workcenter AND package filters + +#### Scenario: TreeMap with reason filter +- **WHEN** `GET /api/hold-overview/treemap?hold_type=quality&reason=品質確認` is called +- **THEN** the response SHALL only include lots where HOLDREASONNAME equals the specified reason + +#### Scenario: TreeMap empty result +- **WHEN** no hold lots match the filters +- **THEN** the response SHALL return `{ success: true, data: { items: [] } }` + +### Requirement: Hold Overview API SHALL provide paginated lot details +The API SHALL return a paginated list of hold lot details. + +#### Scenario: Lots endpoint delegates to extended get_hold_detail_lots +- **WHEN** `GET /api/hold-overview/lots?hold_type=quality&page=1&per_page=50` is called +- **THEN** the route SHALL delegate to the extended `get_hold_detail_lots(reason=None, hold_type='quality', ...)` +- **THEN** the response SHALL return `{ success: true, data: { lots: [...], pagination: { page, perPage, total, totalPages } } }` +- **THEN** each lot SHALL contain: lotId, workorder, qty, package, workcenter, holdReason, age, holdBy, dept, holdComment +- **THEN** lots SHALL be sorted by age descending (longest hold first) + +#### Scenario: Lots with matrix filter +- **WHEN** `GET /api/hold-overview/lots?hold_type=quality&workcenter=WC-MOLD&package=PKG-A` is called +- **THEN** only lots matching the workcenter AND package filters SHALL be returned + +#### Scenario: Lots with treemap filter +- **WHEN** `GET /api/hold-overview/lots?hold_type=quality&workcenter=WC-MOLD&treemap_reason=品質確認` is called +- **THEN** only lots matching the workcenter AND treemap_reason SHALL be returned + +#### Scenario: Lots with all filters combined +- **WHEN** `GET /api/hold-overview/lots?hold_type=quality&reason=品質確認&workcenter=WC-MOLD&package=PKG-A&treemap_reason=品質確認` is called +- **THEN** all filters SHALL be applied as AND conditions + +#### Scenario: Lots pagination bounds +- **WHEN** `page` is less than 1 +- **THEN** page SHALL be treated as 1 +- **WHEN** `per_page` exceeds 200 +- **THEN** per_page SHALL be capped at 200 + +#### Scenario: Lots error +- **WHEN** the database query fails +- **THEN** the response SHALL return `{ success: false, error: '查詢失敗' }` with HTTP 500 + +### Requirement: Hold Overview API SHALL apply rate limiting +The API SHALL apply rate limiting to expensive endpoints. + +#### Scenario: Rate limit on lots endpoint +- **WHEN** the lots endpoint receives excessive requests +- **THEN** rate limiting SHALL be applied using `configured_rate_limit` with a default of 90 requests per 60 seconds + +#### Scenario: Rate limit on matrix endpoint +- **WHEN** the matrix endpoint receives excessive requests +- **THEN** rate limiting SHALL be applied using `configured_rate_limit` with a default of 120 requests per 60 seconds + +### Requirement: Hold Overview page route SHALL serve static Vite HTML +The Flask route SHALL serve the pre-built Vite HTML file. + +#### Scenario: Page route +- **WHEN** user navigates to `/hold-overview` +- **THEN** Flask SHALL serve the pre-built HTML file from `static/dist/hold-overview.html` via `send_from_directory` +- **THEN** the HTML SHALL NOT pass through Jinja2 template rendering + +#### Scenario: Fallback HTML +- **WHEN** the pre-built HTML file does not exist +- **THEN** Flask SHALL return a minimal HTML page with the correct script tag and module import + +### Requirement: Extended service functions SHALL maintain backward compatibility +The extended `get_hold_detail_summary()`, `get_hold_detail_lots()`, and `get_wip_matrix()` SHALL not break existing callers. + +#### Scenario: Hold Detail summary backward compatibility +- **WHEN** existing Hold Detail code calls `get_hold_detail_summary(reason='xxx')` +- **THEN** the result SHALL be identical to the pre-extension behavior +- **THEN** the new `hold_type` parameter SHALL default to None (no additional filtering) + +#### Scenario: Hold Detail lots backward compatibility +- **WHEN** existing Hold Detail code calls `get_hold_detail_lots(reason='xxx', workcenter=..., package=..., age_range=...)` +- **THEN** the result SHALL be identical to the pre-extension behavior +- **THEN** the new `hold_type` and `treemap_reason` parameters SHALL default to None + +#### Scenario: WIP Overview matrix backward compatibility +- **WHEN** existing WIP Overview code calls `get_wip_matrix(status='HOLD', hold_type='quality')` +- **THEN** the result SHALL be identical to the pre-extension behavior +- **THEN** the new `reason` parameter SHALL default to None (no HOLDREASONNAME filtering) diff --git a/openspec/specs/hold-overview-page/spec.md b/openspec/specs/hold-overview-page/spec.md new file mode 100644 index 0000000..10e57bd --- /dev/null +++ b/openspec/specs/hold-overview-page/spec.md @@ -0,0 +1,200 @@ +## Purpose +Define stable requirements for hold-overview-page. + +## Requirements + + +### Requirement: Hold Overview page SHALL display a filter bar with Hold Type and Reason +The page SHALL provide a filter bar for selecting hold type and hold reason. + +#### Scenario: Hold Type radio default +- **WHEN** the page loads +- **THEN** the Hold Type filter SHALL default to "品質異常" +- **THEN** three radio options SHALL display: 品質異常, 非品質異常, 全部 + +#### Scenario: Hold Type change reloads all data +- **WHEN** user changes the Hold Type selection +- **THEN** all four API calls (summary, matrix, treemap, lots) SHALL reload with the new hold_type parameter +- **THEN** any active matrix and treemap filters SHALL be cleared + +#### Scenario: Reason dropdown populated from current data +- **WHEN** summary data is loaded +- **THEN** the Reason dropdown SHALL display "全部" plus all distinct hold reasons from the treemap data +- **THEN** selecting a specific reason SHALL reload all four API calls filtered by that reason +- **THEN** any active matrix and treemap filters SHALL be cleared + +### Requirement: Hold Overview page SHALL display summary KPI cards +The page SHALL show summary statistics for all hold lots matching the current filter. + +#### Scenario: Summary cards rendering +- **WHEN** summary data is loaded from `GET /api/hold-overview/summary` +- **THEN** five cards SHALL display: Hold Lots, Hold QTY, 站別數, 平均滯留天數, 最大滯留天數 +- **THEN** lot and QTY values SHALL use zh-TW number formatting +- **THEN** age values SHALL display with "天" suffix and one decimal place + +#### Scenario: Summary reflects filter bar only +- **WHEN** user clicks a matrix cell or treemap block +- **THEN** summary cards SHALL NOT change (they only respond to filter bar changes) + +### Requirement: Hold Overview page SHALL display a Workcenter x Package matrix +The page SHALL display a cross-tabulation matrix of workcenters vs packages for hold lots. + +#### Scenario: Matrix table rendering +- **WHEN** matrix data is loaded from `GET /api/hold-overview/matrix` +- **THEN** the table SHALL display workcenters as rows and packages as columns +- **THEN** cell values SHALL show QTY with zh-TW number formatting +- **THEN** the first column (Workcenter) SHALL be sticky on horizontal scroll +- **THEN** a Total row and Total column SHALL be displayed +- **THEN** cells with zero value SHALL display "-" + +#### Scenario: Matrix cell click filters TreeMap and lot table +- **WHEN** user clicks a QTY cell in the matrix (intersection of workcenter and package) +- **THEN** `matrixFilter` SHALL be set to `{ workcenter, package }` +- **THEN** TreeMap SHALL reload showing only data for that workcenter + package combination +- **THEN** lot table SHALL reload filtered by that workcenter + package +- **THEN** the clicked cell SHALL show an active highlight + +#### Scenario: Matrix workcenter row click +- **WHEN** user clicks a workcenter name or its Total cell +- **THEN** `matrixFilter` SHALL be set to `{ workcenter }` (all packages) +- **THEN** TreeMap and lot table SHALL reload filtered by that workcenter + +#### Scenario: Matrix package column click +- **WHEN** user clicks a package column header or its Total cell +- **THEN** `matrixFilter` SHALL be set to `{ package }` (all workcenters) +- **THEN** TreeMap and lot table SHALL reload filtered by that package + +#### Scenario: Matrix click toggle +- **WHEN** user clicks the same cell/row/column that is already active +- **THEN** `matrixFilter` SHALL be cleared +- **THEN** TreeMap and lot table SHALL reload without matrix filter + +#### Scenario: Matrix reflects filter bar only +- **WHEN** user clicks a treemap block +- **THEN** matrix SHALL NOT change (it only responds to filter bar changes) + +### Requirement: Hold Overview page SHALL display active filter indicators +The page SHALL show a clear indicator of active cascade filters. + +#### Scenario: Matrix filter indicator +- **WHEN** a matrix filter is active +- **THEN** a filter indicator SHALL display between the matrix and TreeMap sections +- **THEN** the indicator SHALL show the active workcenter and/or package name +- **THEN** a clear button (✕) SHALL remove the matrix filter + +#### Scenario: TreeMap filter indicator +- **WHEN** a treemap filter is active +- **THEN** a filter indicator SHALL display between the TreeMap and lot table sections +- **THEN** the indicator SHALL show the active workcenter and reason name +- **THEN** a clear button (✕) SHALL remove the treemap filter + +#### Scenario: Clear all filters +- **WHEN** user clicks a "清除所有篩選" button +- **THEN** both matrixFilter and treemapFilter SHALL be cleared +- **THEN** TreeMap and lot table SHALL reload without cascade filters + +### Requirement: Hold Overview page SHALL display a TreeMap visualization +The page SHALL display a TreeMap chart showing hold lot distribution by workcenter and reason. + +#### Scenario: TreeMap rendering +- **WHEN** treemap data is loaded from `GET /api/hold-overview/treemap` +- **THEN** the TreeMap SHALL display with two levels: Workcenter (parent) → Hold Reason (child) +- **THEN** block area SHALL represent QTY +- **THEN** block color SHALL represent average age at current station using a 4-level color scale +- **THEN** the color scale legend SHALL display: 綠(<1天), 黃(1-3天), 橙(3-7天), 紅(>7天) + +#### Scenario: TreeMap tooltip +- **WHEN** user hovers over a TreeMap block +- **THEN** a tooltip SHALL display: Workcenter, Reason, Lots count, QTY, and average age + +#### Scenario: TreeMap narrows on matrix filter (Option A) +- **WHEN** a matrix filter is active (e.g., workcenter=WC-MOLD, package=PKG-A) +- **THEN** the TreeMap SHALL only show data matching the matrix filter +- **THEN** the TreeMap API SHALL be called with workcenter and/or package parameters + +#### Scenario: TreeMap click filters lot table +- **WHEN** user clicks a leaf block in the TreeMap (a specific reason within a workcenter) +- **THEN** `treemapFilter` SHALL be set to `{ workcenter, reason }` +- **THEN** lot table SHALL reload filtered by that workcenter + reason +- **THEN** the clicked block SHALL show a visual highlight (border or opacity change) + +#### Scenario: TreeMap click toggle +- **WHEN** user clicks the same block that is already active +- **THEN** `treemapFilter` SHALL be cleared +- **THEN** lot table SHALL reload without treemap filter + +#### Scenario: Empty TreeMap +- **WHEN** treemap data returns zero items +- **THEN** the TreeMap area SHALL display "目前無 Hold 資料" + +### Requirement: Hold Overview page SHALL display paginated lot details +The page SHALL display a detailed lot table with server-side pagination. + +#### Scenario: Lot table rendering +- **WHEN** lot data is loaded from `GET /api/hold-overview/lots` +- **THEN** a table SHALL display with columns: LOTID, WORKORDER, QTY, Package, Workcenter, Hold Reason, Age, Hold By, Dept, Hold Comment +- **THEN** age values SHALL display with "天" suffix + +#### Scenario: Lot table responds to all cascade filters +- **WHEN** matrixFilter is `{ workcenter: WC-A, package: PKG-1 }` and treemapFilter is `{ reason: 品質確認 }` +- **THEN** lots API SHALL be called with `workcenter=WC-A&package=PKG-1&treemap_reason=品質確認` +- **THEN** only lots matching all active filters SHALL be displayed + +#### Scenario: Pagination +- **WHEN** total lots exceeds per_page (50) +- **THEN** Prev/Next buttons and page info SHALL display +- **THEN** page info SHALL show "顯示 {start} - {end} / {total}" + +#### Scenario: Filter changes reset pagination +- **WHEN** any filter changes (filter bar, matrix click, or treemap click) +- **THEN** pagination SHALL reset to page 1 + +#### Scenario: Empty lot result +- **WHEN** a query returns zero lots +- **THEN** the lot table SHALL display a "No data" placeholder + +### Requirement: Hold Overview page SHALL auto-refresh and handle request cancellation +The page SHALL automatically refresh data and prevent stale request pile-up. + +#### Scenario: Auto-refresh interval +- **WHEN** the page is loaded +- **THEN** data SHALL auto-refresh every 10 minutes using `useAutoRefresh` composable +- **THEN** auto-refresh SHALL be skipped when the tab is hidden + +#### Scenario: Visibility change refresh +- **WHEN** the tab becomes visible after being hidden +- **THEN** data SHALL refresh immediately + +#### Scenario: Request cancellation +- **WHEN** a new data load is triggered while a previous request is in-flight +- **THEN** the previous request SHALL be cancelled via AbortController +- **THEN** the cancelled request SHALL NOT update the UI + +#### Scenario: Manual refresh +- **WHEN** user clicks the "重新整理" button +- **THEN** all data SHALL reload and the auto-refresh timer SHALL reset +- **THEN** all cascade filters (matrixFilter, treemapFilter) SHALL be preserved during refresh + +### Requirement: Hold Overview page SHALL handle loading and error states +The page SHALL display appropriate feedback during API calls and on errors. + +#### Scenario: Initial loading overlay +- **WHEN** the page first loads +- **THEN** a full-page loading overlay SHALL display until all data is loaded + +#### Scenario: API error handling +- **WHEN** an API call fails +- **THEN** an error banner SHALL display with the error message +- **THEN** the page SHALL NOT crash or become unresponsive + +#### Scenario: Refresh indicator +- **WHEN** data is being refreshed (not initial load) +- **THEN** a spinning refresh indicator SHALL display in the header +- **THEN** a success checkmark SHALL flash briefly on completion + +### Requirement: Hold Overview page SHALL have back navigation +The page SHALL provide navigation back to WIP Overview. + +#### Scenario: Back button +- **WHEN** user clicks the "← WIP Overview" button in the header +- **THEN** the page SHALL navigate to `/wip-overview` diff --git a/openspec/specs/vue-vite-page-architecture/spec.md b/openspec/specs/vue-vite-page-architecture/spec.md index 1abacbf..3f4594f 100644 --- a/openspec/specs/vue-vite-page-architecture/spec.md +++ b/openspec/specs/vue-vite-page-architecture/spec.md @@ -33,7 +33,7 @@ The Vite build configuration SHALL support Vue Single File Components alongside #### Scenario: Chunk splitting - **WHEN** Vite builds the project - **THEN** Vue runtime SHALL be split into a `vendor-vue` chunk -- **THEN** ECharts modules SHALL be split into the existing `vendor-echarts` chunk +- **THEN** ECharts modules (including TreemapChart) SHALL be split into the existing `vendor-echarts` chunk - **THEN** chunk splitting SHALL NOT affect existing page bundles #### Scenario: Migrated page entry replacement @@ -41,13 +41,18 @@ The Vite build configuration SHALL support Vue Single File Components alongside - **THEN** its Vite entry SHALL change from JS file to HTML file (e.g., `src/wip-overview/main.js` → `src/wip-overview/index.html`) - **THEN** the original JS entry SHALL be replaced, not kept alongside +#### Scenario: Hold Overview entry point +- **WHEN** the hold-overview page is added +- **THEN** `vite.config.js` input SHALL include `'hold-overview': resolve(__dirname, 'src/hold-overview/index.html')` +- **THEN** the build SHALL produce `hold-overview.html`, `hold-overview.js`, and `hold-overview.css` in `static/dist/` + #### Scenario: Shared CSS import across migrated pages - **WHEN** multiple migrated pages import a shared CSS module (e.g., `wip-shared/styles.css`) - **THEN** Vite SHALL bundle the shared CSS into each page's output CSS - **THEN** shared CSS SHALL NOT create a separate shared chunk that requires additional HTTP requests #### Scenario: Shared composable import across module boundaries -- **WHEN** a migrated page imports a composable from another shared module (e.g., `resource-status` imports `useAutoRefresh` from `wip-shared/`) +- **WHEN** a migrated page imports a composable from another shared module (e.g., `hold-overview` imports `useAutoRefresh` from `wip-shared/`) - **THEN** the composable SHALL be bundled into the importing page's JS output - **THEN** cross-module imports SHALL NOT create unexpected shared chunks diff --git a/src/mes_dashboard/routes/__init__.py b/src/mes_dashboard/routes/__init__.py index fad3ec1..77ebf95 100644 --- a/src/mes_dashboard/routes/__init__.py +++ b/src/mes_dashboard/routes/__init__.py @@ -9,6 +9,7 @@ from .resource_routes import resource_bp from .dashboard_routes import dashboard_bp from .excel_query_routes import excel_query_bp from .hold_routes import hold_bp +from .hold_overview_routes import hold_overview_bp from .auth_routes import auth_bp from .admin_routes import admin_bp from .resource_history_routes import resource_history_bp @@ -26,6 +27,7 @@ def register_routes(app) -> None: app.register_blueprint(dashboard_bp) app.register_blueprint(excel_query_bp) app.register_blueprint(hold_bp) + app.register_blueprint(hold_overview_bp) app.register_blueprint(resource_history_bp) app.register_blueprint(job_query_bp) app.register_blueprint(query_tool_bp) @@ -39,6 +41,7 @@ __all__ = [ 'dashboard_bp', 'excel_query_bp', 'hold_bp', + 'hold_overview_bp', 'auth_bp', 'admin_bp', 'resource_history_bp', diff --git a/src/mes_dashboard/routes/hold_overview_routes.py b/src/mes_dashboard/routes/hold_overview_routes.py new file mode 100644 index 0000000..7f6fbaa --- /dev/null +++ b/src/mes_dashboard/routes/hold_overview_routes.py @@ -0,0 +1,181 @@ +# -*- coding: utf-8 -*- +"""Hold Overview page route and API endpoints.""" + +import os +from typing import Optional + +from flask import Blueprint, current_app, jsonify, request, send_from_directory + +from mes_dashboard.core.rate_limit import configured_rate_limit +from mes_dashboard.core.utils import parse_bool_query +from mes_dashboard.services.wip_service import ( + get_hold_detail_lots, + get_hold_detail_summary, + get_hold_overview_treemap, + get_wip_matrix, +) + +hold_overview_bp = Blueprint('hold_overview', __name__) + +_HOLD_OVERVIEW_MATRIX_RATE_LIMIT = configured_rate_limit( + bucket="hold-overview-matrix", + max_attempts_env="HOLD_OVERVIEW_MATRIX_RATE_LIMIT_MAX_REQUESTS", + window_seconds_env="HOLD_OVERVIEW_MATRIX_RATE_LIMIT_WINDOW_SECONDS", + default_max_attempts=120, + default_window_seconds=60, +) + +_HOLD_OVERVIEW_LOTS_RATE_LIMIT = configured_rate_limit( + bucket="hold-overview-lots", + max_attempts_env="HOLD_OVERVIEW_LOTS_RATE_LIMIT_MAX_REQUESTS", + window_seconds_env="HOLD_OVERVIEW_LOTS_RATE_LIMIT_WINDOW_SECONDS", + default_max_attempts=90, + default_window_seconds=60, +) + +_VALID_HOLD_TYPES = {'quality', 'non-quality', 'all'} +_VALID_AGE_RANGES = {'0-1', '1-3', '3-7', '7+'} + + +def _parse_hold_type(default: str = 'quality') -> tuple[Optional[str], Optional[tuple[dict, int]]]: + raw = request.args.get('hold_type', '').strip().lower() + hold_type = raw or default + if hold_type not in _VALID_HOLD_TYPES: + return None, ( + {'success': False, 'error': 'Invalid hold_type. Use quality, non-quality, or all'}, + 400, + ) + if hold_type == 'all': + return None, None + return hold_type, None + + +@hold_overview_bp.route('/hold-overview') +def hold_overview_page(): + """Render hold overview page from static Vite output.""" + dist_dir = os.path.join(current_app.static_folder or "", "dist") + dist_html = os.path.join(dist_dir, "hold-overview.html") + if os.path.exists(dist_html): + return send_from_directory(dist_dir, 'hold-overview.html') + + return ( + "" + "" + "Hold Overview" + "" + "
", + 200, + ) + + +@hold_overview_bp.route('/api/hold-overview/summary') +def api_hold_overview_summary(): + """Return summary KPI data for hold overview page.""" + hold_type, error = _parse_hold_type(default='quality') + if error: + return jsonify(error[0]), error[1] + + reason = request.args.get('reason', '').strip() or None + include_dummy = parse_bool_query(request.args.get('include_dummy')) + + result = get_hold_detail_summary( + reason=reason, + hold_type=hold_type, + include_dummy=include_dummy, + ) + if result is not None: + return jsonify({'success': True, 'data': result}) + return jsonify({'success': False, 'error': '查詢失敗'}), 500 + + +@hold_overview_bp.route('/api/hold-overview/matrix') +@_HOLD_OVERVIEW_MATRIX_RATE_LIMIT +def api_hold_overview_matrix(): + """Return hold-only workcenter x package matrix.""" + hold_type, error = _parse_hold_type(default='quality') + if error: + return jsonify(error[0]), error[1] + + reason = request.args.get('reason', '').strip() or None + include_dummy = parse_bool_query(request.args.get('include_dummy')) + + result = get_wip_matrix( + include_dummy=include_dummy, + status='HOLD', + hold_type=hold_type, + reason=reason, + ) + if result is not None: + return jsonify({'success': True, 'data': result}) + return jsonify({'success': False, 'error': '查詢失敗'}), 500 + + +@hold_overview_bp.route('/api/hold-overview/treemap') +def api_hold_overview_treemap(): + """Return grouped hold overview data for treemap chart.""" + hold_type, error = _parse_hold_type(default='quality') + if error: + return jsonify(error[0]), error[1] + + reason = request.args.get('reason', '').strip() or None + workcenter = request.args.get('workcenter', '').strip() or None + package = request.args.get('package', '').strip() or None + include_dummy = parse_bool_query(request.args.get('include_dummy')) + + result = get_hold_overview_treemap( + hold_type=hold_type, + reason=reason, + workcenter=workcenter, + package=package, + include_dummy=include_dummy, + ) + if result is not None: + return jsonify({'success': True, 'data': result}) + return jsonify({'success': False, 'error': '查詢失敗'}), 500 + + +@hold_overview_bp.route('/api/hold-overview/lots') +@_HOLD_OVERVIEW_LOTS_RATE_LIMIT +def api_hold_overview_lots(): + """Return paginated hold lot details.""" + hold_type, error = _parse_hold_type(default='quality') + if error: + return jsonify(error[0]), error[1] + + reason = request.args.get('reason', '').strip() or None + treemap_reason = request.args.get('treemap_reason', '').strip() or None + workcenter = request.args.get('workcenter', '').strip() or None + package = request.args.get('package', '').strip() or None + age_range = request.args.get('age_range', '').strip() or None + include_dummy = parse_bool_query(request.args.get('include_dummy')) + page = request.args.get('page', 1, type=int) + per_page = request.args.get('per_page', 50, type=int) + + if age_range and age_range not in _VALID_AGE_RANGES: + return jsonify({ + 'success': False, + 'error': 'Invalid age_range. Use 0-1, 1-3, 3-7, or 7+', + }), 400 + + if page is None: + page = 1 + if per_page is None: + per_page = 50 + + page = max(page, 1) + per_page = max(1, min(per_page, 200)) + + result = get_hold_detail_lots( + reason=reason, + hold_type=hold_type, + treemap_reason=treemap_reason, + workcenter=workcenter, + package=package, + age_range=age_range, + include_dummy=include_dummy, + page=page, + page_size=per_page, + ) + if result is not None: + return jsonify({'success': True, 'data': result}) + return jsonify({'success': False, 'error': '查詢失敗'}), 500 diff --git a/src/mes_dashboard/services/wip_service.py b/src/mes_dashboard/services/wip_service.py index 2c0fdc9..09be119 100644 --- a/src/mes_dashboard/services/wip_service.py +++ b/src/mes_dashboard/services/wip_service.py @@ -878,6 +878,7 @@ def get_wip_matrix( lotid: Optional[str] = None, status: Optional[str] = None, hold_type: Optional[str] = None, + reason: Optional[str] = None, package: Optional[str] = None, pj_type: Optional[str] = None ) -> Optional[Dict[str, Any]]: @@ -892,6 +893,8 @@ def get_wip_matrix( status: Optional WIP status filter ('RUN', 'QUEUE', 'HOLD') hold_type: Optional hold type filter ('quality', 'non-quality') Only effective when status='HOLD' + reason: Optional HOLDREASONNAME filter + Only effective when status='HOLD' package: Optional PACKAGE_LEF filter (exact match) pj_type: Optional PJ_TYPE filter (exact match) @@ -910,6 +913,7 @@ def get_wip_matrix( try: status_upper = status.upper() if status else None hold_type_filter = hold_type if status_upper == 'HOLD' else None + reason_filter = reason if status_upper == 'HOLD' else None df = _select_with_snapshot_indexes( include_dummy=include_dummy, workorder=workorder, @@ -926,10 +930,14 @@ def get_wip_matrix( lotid, status, hold_type, + reason, package, pj_type, ) + if reason_filter: + df = df[df['HOLDREASONNAME'] == reason_filter] + # Filter by WORKCENTER_GROUP and PACKAGE_LEF df = df[df['WORKCENTER_GROUP'].notna() & df['PACKAGE_LEF'].notna()] @@ -950,7 +958,16 @@ def get_wip_matrix( logger.warning(f"Cache-based matrix calculation failed, falling back to Oracle: {exc}") # Fallback to Oracle direct query - return _get_wip_matrix_from_oracle(include_dummy, workorder, lotid, status, hold_type, package, pj_type) + return _get_wip_matrix_from_oracle( + include_dummy, + workorder, + lotid, + status, + hold_type, + reason, + package, + pj_type, + ) def _build_matrix_result(df: pd.DataFrame) -> Dict[str, Any]: @@ -1012,6 +1029,7 @@ def _get_wip_matrix_from_oracle( lotid: Optional[str] = None, status: Optional[str] = None, hold_type: Optional[str] = None, + reason: Optional[str] = None, package: Optional[str] = None, pj_type: Optional[str] = None ) -> Optional[Dict[str, Any]]: @@ -1037,6 +1055,8 @@ def _get_wip_matrix_from_oracle( # Hold type sub-filter if hold_type: _add_hold_type_conditions(builder, hold_type) + if reason: + builder.add_param_condition("HOLDREASONNAME", reason) elif status_upper == 'QUEUE': builder.add_condition("COALESCE(EQUIPMENTCOUNT, 0) = 0 AND COALESCE(CURRENTHOLDCOUNT, 0) = 0") @@ -2137,19 +2157,21 @@ def _search_types_from_oracle( # ============================================================ def get_hold_detail_summary( - reason: str, - include_dummy: bool = False + reason: Optional[str] = None, + hold_type: Optional[str] = None, + include_dummy: bool = False, ) -> Optional[Dict[str, Any]]: - """Get summary statistics for a specific hold reason. + """Get summary statistics for hold lots. Uses Redis cache when available, falls back to Oracle direct query. Args: - reason: The HOLDREASONNAME to filter by + reason: Optional HOLDREASONNAME filter + hold_type: Optional hold type filter ('quality', 'non-quality') include_dummy: If True, include DUMMY lots (default: False) Returns: - Dict with totalLots, totalQty, avgAge, maxAge, workcenterCount + Dict with totalLots, totalQty, avgAge, maxAge, workcenterCount, dataUpdateDate """ # Try cache first cached_df = _get_wip_dataframe() @@ -2158,12 +2180,17 @@ def get_hold_detail_summary( df = _select_with_snapshot_indexes( include_dummy=include_dummy, status='HOLD', + hold_type=hold_type, ) if df is None: - return _get_hold_detail_summary_from_oracle(reason, include_dummy) + return _get_hold_detail_summary_from_oracle( + reason=reason, + hold_type=hold_type, + include_dummy=include_dummy, + ) - # Filter for HOLD status with matching reason - df = df[df['HOLDREASONNAME'] == reason] + if reason: + df = df[df['HOLDREASONNAME'] == reason] if df.empty: return { @@ -2171,10 +2198,12 @@ def get_hold_detail_summary( 'totalQty': 0, 'avgAge': 0, 'maxAge': 0, - 'workcenterCount': 0 + 'workcenterCount': 0, + 'dataUpdateDate': get_cached_sys_date(), } # Ensure AGEBYDAYS is numeric + df = df.copy() df['AGEBYDAYS'] = pd.to_numeric(df['AGEBYDAYS'], errors='coerce').fillna(0) return { @@ -2182,7 +2211,8 @@ def get_hold_detail_summary( 'totalQty': int(df['QTY'].sum()), 'avgAge': round(float(df['AGEBYDAYS'].mean()), 1), 'maxAge': float(df['AGEBYDAYS'].max()), - 'workcenterCount': df['WORKCENTER_GROUP'].nunique() + 'workcenterCount': df['WORKCENTER_GROUP'].nunique(), + 'dataUpdateDate': get_cached_sys_date(), } except (DatabasePoolExhaustedError, DatabaseCircuitOpenError): raise @@ -2190,19 +2220,27 @@ def get_hold_detail_summary( logger.warning(f"Cache-based hold detail summary failed, falling back to Oracle: {exc}") # Fallback to Oracle direct query - return _get_hold_detail_summary_from_oracle(reason, include_dummy) + return _get_hold_detail_summary_from_oracle( + reason=reason, + hold_type=hold_type, + include_dummy=include_dummy, + ) def _get_hold_detail_summary_from_oracle( - reason: str, - include_dummy: bool = False + reason: Optional[str] = None, + hold_type: Optional[str] = None, + include_dummy: bool = False, ) -> Optional[Dict[str, Any]]: """Get hold detail summary directly from Oracle (fallback).""" try: builder = _build_base_conditions_builder(include_dummy) builder.add_param_condition("STATUS", "HOLD") builder.add_condition("CURRENTHOLDCOUNT > 0") - builder.add_param_condition("HOLDREASONNAME", reason) + if hold_type: + _add_hold_type_conditions(builder, hold_type) + if reason: + builder.add_param_condition("HOLDREASONNAME", reason) where_clause, params = builder.build_where_only() sql = f""" @@ -2211,7 +2249,8 @@ def _get_hold_detail_summary_from_oracle( SUM(QTY) AS TOTAL_QTY, ROUND(AVG(AGEBYDAYS), 1) AS AVG_AGE, MAX(AGEBYDAYS) AS MAX_AGE, - COUNT(DISTINCT WORKCENTER_GROUP) AS WORKCENTER_COUNT + COUNT(DISTINCT WORKCENTER_GROUP) AS WORKCENTER_COUNT, + MAX(SYS_DATE) AS DATA_UPDATE_DATE FROM {WIP_VIEW} {where_clause} """ @@ -2226,7 +2265,8 @@ def _get_hold_detail_summary_from_oracle( 'totalQty': int(row['TOTAL_QTY'] or 0), 'avgAge': float(row['AVG_AGE']) if row['AVG_AGE'] else 0, 'maxAge': float(row['MAX_AGE']) if row['MAX_AGE'] else 0, - 'workcenterCount': int(row['WORKCENTER_COUNT'] or 0) + 'workcenterCount': int(row['WORKCENTER_COUNT'] or 0), + 'dataUpdateDate': str(row['DATA_UPDATE_DATE']) if row['DATA_UPDATE_DATE'] else None, } except (DatabasePoolExhaustedError, DatabaseCircuitOpenError): raise @@ -2521,7 +2561,9 @@ def _get_hold_detail_distribution_from_oracle( def get_hold_detail_lots( - reason: str, + reason: Optional[str] = None, + hold_type: Optional[str] = None, + treemap_reason: Optional[str] = None, workcenter: Optional[str] = None, package: Optional[str] = None, age_range: Optional[str] = None, @@ -2529,12 +2571,14 @@ def get_hold_detail_lots( page: int = 1, page_size: int = 50 ) -> Optional[Dict[str, Any]]: - """Get paginated lot details for a specific hold reason. + """Get paginated lot details for hold lots. Uses Redis cache when available, falls back to Oracle direct query. Args: - reason: The HOLDREASONNAME to filter by + reason: Optional HOLDREASONNAME filter (from filter bar) + hold_type: Optional hold type filter ('quality', 'non-quality') + treemap_reason: Optional HOLDREASONNAME filter from treemap selection workcenter: Optional WORKCENTER_GROUP filter package: Optional PACKAGE_LEF filter age_range: Optional age range filter ('0-1', '1-3', '3-7', '7+') @@ -2545,6 +2589,9 @@ def get_hold_detail_lots( Returns: Dict with lots list, pagination info, and active filters """ + page = max(int(page or 1), 1) + page_size = max(int(page_size or 50), 1) + # Try cache first cached_df = _get_wip_dataframe() if cached_df is not None: @@ -2554,10 +2601,13 @@ def get_hold_detail_lots( workcenter=workcenter, package=package, status='HOLD', + hold_type=hold_type, ) if df is None: return _get_hold_detail_lots_from_oracle( reason=reason, + hold_type=hold_type, + treemap_reason=treemap_reason, workcenter=workcenter, package=package, age_range=age_range, @@ -2566,10 +2616,13 @@ def get_hold_detail_lots( page_size=page_size, ) - # Filter for HOLD status with matching reason - df = df[df['HOLDREASONNAME'] == reason] + if reason: + df = df[df['HOLDREASONNAME'] == reason] + if treemap_reason: + df = df[df['HOLDREASONNAME'] == treemap_reason] # Ensure numeric columns + df = df.copy() df['AGEBYDAYS'] = pd.to_numeric(df['AGEBYDAYS'], errors='coerce').fillna(0) # Optional age filter @@ -2600,6 +2653,7 @@ def get_hold_detail_lots( 'qty': int(row.get('QTY', 0) or 0), 'package': _safe_value(row.get('PACKAGE_LEF')), 'workcenter': _safe_value(row.get('WORKCENTER_GROUP')), + 'holdReason': _safe_value(row.get('HOLDREASONNAME')), 'spec': _safe_value(row.get('SPECNAME')), 'age': round(float(row.get('AGEBYDAYS', 0) or 0), 1), 'holdBy': _safe_value(row.get('HOLDEMP')), @@ -2618,6 +2672,9 @@ def get_hold_detail_lots( 'totalPages': total_pages }, 'filters': { + 'holdType': hold_type, + 'reason': reason, + 'treemapReason': treemap_reason, 'workcenter': workcenter, 'package': package, 'ageRange': age_range @@ -2630,12 +2687,22 @@ def get_hold_detail_lots( # Fallback to Oracle direct query return _get_hold_detail_lots_from_oracle( - reason, workcenter, package, age_range, include_dummy, page, page_size + reason=reason, + hold_type=hold_type, + treemap_reason=treemap_reason, + workcenter=workcenter, + package=package, + age_range=age_range, + include_dummy=include_dummy, + page=page, + page_size=page_size, ) def _get_hold_detail_lots_from_oracle( - reason: str, + reason: Optional[str] = None, + hold_type: Optional[str] = None, + treemap_reason: Optional[str] = None, workcenter: Optional[str] = None, package: Optional[str] = None, age_range: Optional[str] = None, @@ -2648,7 +2715,12 @@ def _get_hold_detail_lots_from_oracle( builder = _build_base_conditions_builder(include_dummy) builder.add_param_condition("STATUS", "HOLD") builder.add_condition("CURRENTHOLDCOUNT > 0") - builder.add_param_condition("HOLDREASONNAME", reason) + if hold_type: + _add_hold_type_conditions(builder, hold_type) + if reason: + builder.add_param_condition("HOLDREASONNAME", reason) + if treemap_reason: + builder.add_param_condition("HOLDREASONNAME", treemap_reason) # Optional filters if workcenter: @@ -2690,6 +2762,7 @@ def _get_hold_detail_lots_from_oracle( QTY, PACKAGE_LEF AS PACKAGE, WORKCENTER_GROUP AS WORKCENTER, + HOLDREASONNAME AS HOLD_REASON, SPECNAME AS SPEC, ROUND(AGEBYDAYS, 1) AS AGE, HOLDEMP AS HOLD_BY, @@ -2713,6 +2786,7 @@ def _get_hold_detail_lots_from_oracle( 'qty': int(row['QTY'] or 0), 'package': _safe_value(row['PACKAGE']), 'workcenter': _safe_value(row['WORKCENTER']), + 'holdReason': _safe_value(row['HOLD_REASON']), 'spec': _safe_value(row['SPEC']), 'age': float(row['AGE']) if row['AGE'] else 0, 'holdBy': _safe_value(row['HOLD_BY']), @@ -2731,6 +2805,9 @@ def _get_hold_detail_lots_from_oracle( 'totalPages': total_pages }, 'filters': { + 'holdType': hold_type, + 'reason': reason, + 'treemapReason': treemap_reason, 'workcenter': workcenter, 'package': package, 'ageRange': age_range @@ -2745,6 +2822,146 @@ def _get_hold_detail_lots_from_oracle( return None +# ============================================================ +# Hold Overview API Functions +# ============================================================ + +def get_hold_overview_treemap( + hold_type: Optional[str] = None, + reason: Optional[str] = None, + workcenter: Optional[str] = None, + package: Optional[str] = None, + include_dummy: bool = False, +) -> Optional[Dict[str, Any]]: + """Get hold overview treemap aggregation grouped by workcenter and reason.""" + cached_df = _get_wip_dataframe() + if cached_df is not None: + try: + df = _select_with_snapshot_indexes( + include_dummy=include_dummy, + workcenter=workcenter, + package=package, + status='HOLD', + hold_type=hold_type, + ) + if df is None: + return _get_hold_overview_treemap_from_oracle( + hold_type=hold_type, + reason=reason, + workcenter=workcenter, + package=package, + include_dummy=include_dummy, + ) + + if reason: + df = df[df['HOLDREASONNAME'] == reason] + + df = df[df['WORKCENTER_GROUP'].notna() & df['HOLDREASONNAME'].notna()] + if df.empty: + return {'items': []} + + df = df.copy() + df['AGEBYDAYS'] = pd.to_numeric(df['AGEBYDAYS'], errors='coerce').fillna(0) + df['QTY'] = pd.to_numeric(df['QTY'], errors='coerce').fillna(0) + + grouped = df.groupby( + ['WORKCENTER_GROUP', 'WORKCENTERSEQUENCE_GROUP', 'HOLDREASONNAME'], + dropna=False, + ).agg( + LOTS=('LOTID', 'count'), + QTY=('QTY', 'sum'), + AVG_AGE=('AGEBYDAYS', 'mean'), + ).reset_index() + grouped = grouped.sort_values( + ['WORKCENTERSEQUENCE_GROUP', 'QTY'], + ascending=[True, False], + ) + + items = [] + for _, row in grouped.iterrows(): + items.append({ + 'workcenter': _safe_value(row.get('WORKCENTER_GROUP')), + 'reason': _safe_value(row.get('HOLDREASONNAME')), + 'lots': int(row.get('LOTS', 0) or 0), + 'qty': int(row.get('QTY', 0) or 0), + 'avgAge': round(float(row.get('AVG_AGE', 0) or 0), 1), + }) + return {'items': items} + except (DatabasePoolExhaustedError, DatabaseCircuitOpenError): + raise + except Exception as exc: + logger.warning(f"Cache-based hold overview treemap failed, falling back to Oracle: {exc}") + + return _get_hold_overview_treemap_from_oracle( + hold_type=hold_type, + reason=reason, + workcenter=workcenter, + package=package, + include_dummy=include_dummy, + ) + + +def _get_hold_overview_treemap_from_oracle( + hold_type: Optional[str] = None, + reason: Optional[str] = None, + workcenter: Optional[str] = None, + package: Optional[str] = None, + include_dummy: bool = False, +) -> Optional[Dict[str, Any]]: + """Get hold overview treemap aggregation directly from Oracle (fallback).""" + try: + builder = _build_base_conditions_builder(include_dummy) + builder.add_param_condition("STATUS", "HOLD") + builder.add_condition("CURRENTHOLDCOUNT > 0") + if hold_type: + _add_hold_type_conditions(builder, hold_type) + if reason: + builder.add_param_condition("HOLDREASONNAME", reason) + if workcenter: + builder.add_param_condition("WORKCENTER_GROUP", workcenter) + if package: + builder.add_param_condition("PACKAGE_LEF", package) + + where_clause, params = builder.build_where_only() + sql = f""" + SELECT + WORKCENTER_GROUP, + WORKCENTERSEQUENCE_GROUP, + HOLDREASONNAME, + COUNT(*) AS LOTS, + SUM(QTY) AS QTY, + ROUND(AVG(AGEBYDAYS), 1) AS AVG_AGE + FROM {WIP_VIEW} + {where_clause} + AND WORKCENTER_GROUP IS NOT NULL + AND HOLDREASONNAME IS NOT NULL + GROUP BY WORKCENTER_GROUP, WORKCENTERSEQUENCE_GROUP, HOLDREASONNAME + ORDER BY WORKCENTERSEQUENCE_GROUP, SUM(QTY) DESC + """ + df = read_sql_df(sql, params) + + if df is None or df.empty: + return {'items': []} + + items = [] + for _, row in df.iterrows(): + items.append({ + 'workcenter': _safe_value(row.get('WORKCENTER_GROUP')), + 'reason': _safe_value(row.get('HOLDREASONNAME')), + 'lots': int(row.get('LOTS', 0) or 0), + 'qty': int(row.get('QTY', 0) or 0), + 'avgAge': round(float(row.get('AVG_AGE', 0) or 0), 1), + }) + return {'items': items} + except (DatabasePoolExhaustedError, DatabaseCircuitOpenError): + raise + except Exception as exc: + logger.error(f"Hold overview treemap query failed: {exc}") + import traceback + traceback.print_exc() + return None + + # ============================================================ # Lot Detail API Functions # ============================================================ diff --git a/tests/test_app_factory.py b/tests/test_app_factory.py index 25b0932..13b57b8 100644 --- a/tests/test_app_factory.py +++ b/tests/test_app_factory.py @@ -53,12 +53,17 @@ class AppFactoryTests(unittest.TestCase): "/resource", "/wip-overview", "/wip-detail", + "/hold-overview", "/excel-query", "/query-tool", "/tmtt-defect", "/api/wip/overview/summary", "/api/wip/overview/matrix", "/api/wip/overview/hold", + "/api/hold-overview/summary", + "/api/hold-overview/matrix", + "/api/hold-overview/treemap", + "/api/hold-overview/lots", "/api/wip/detail/", "/api/wip/meta/workcenters", "/api/wip/meta/packages", diff --git a/tests/test_hold_overview_routes.py b/tests/test_hold_overview_routes.py new file mode 100644 index 0000000..b9f0486 --- /dev/null +++ b/tests/test_hold_overview_routes.py @@ -0,0 +1,228 @@ +# -*- coding: utf-8 -*- +"""Unit tests for Hold Overview API routes.""" + +import json +import unittest +from unittest.mock import patch + +from mes_dashboard.app import create_app +import mes_dashboard.core.database as db + + +class TestHoldOverviewRoutesBase(unittest.TestCase): + """Base class for Hold Overview route tests.""" + + def setUp(self): + db._ENGINE = None + self.app = create_app('testing') + self.app.config['TESTING'] = True + self.client = self.app.test_client() + + +class TestHoldOverviewPageRoute(TestHoldOverviewRoutesBase): + """Test GET /hold-overview page route.""" + + @patch('mes_dashboard.routes.hold_overview_routes.os.path.exists', return_value=False) + def test_hold_overview_page_includes_vite_entry(self, _mock_exists): + # Page is registered as 'dev' status, requires admin session + with self.client.session_transaction() as sess: + sess['admin'] = {'displayName': 'Test Admin', 'employeeNo': 'A001'} + response = self.client.get('/hold-overview') + self.assertEqual(response.status_code, 200) + self.assertIn(b'/static/dist/hold-overview.js', response.data) + + @patch('mes_dashboard.routes.hold_overview_routes.os.path.exists', return_value=False) + def test_hold_overview_page_returns_403_without_admin(self, _mock_exists): + response = self.client.get('/hold-overview') + self.assertEqual(response.status_code, 403) + + +class TestHoldOverviewSummaryRoute(TestHoldOverviewRoutesBase): + """Test GET /api/hold-overview/summary endpoint.""" + + @patch('mes_dashboard.routes.hold_overview_routes.get_hold_detail_summary') + def test_summary_defaults_to_quality(self, mock_service): + mock_service.return_value = { + 'totalLots': 12, + 'totalQty': 3400, + 'avgAge': 2.5, + 'maxAge': 9.0, + 'workcenterCount': 3, + 'dataUpdateDate': '2026-01-01 08:00:00', + } + + response = self.client.get('/api/hold-overview/summary') + payload = json.loads(response.data) + + self.assertEqual(response.status_code, 200) + self.assertTrue(payload['success']) + mock_service.assert_called_once_with( + reason=None, + hold_type='quality', + include_dummy=False, + ) + + @patch('mes_dashboard.routes.hold_overview_routes.get_hold_detail_summary') + def test_summary_hold_type_all_maps_to_none(self, mock_service): + mock_service.return_value = { + 'totalLots': 0, + 'totalQty': 0, + 'avgAge': 0, + 'maxAge': 0, + 'workcenterCount': 0, + 'dataUpdateDate': None, + } + + response = self.client.get('/api/hold-overview/summary?hold_type=all&reason=品質確認') + self.assertEqual(response.status_code, 200) + mock_service.assert_called_once_with( + reason='品質確認', + hold_type=None, + include_dummy=False, + ) + + def test_summary_invalid_hold_type(self): + response = self.client.get('/api/hold-overview/summary?hold_type=invalid') + payload = json.loads(response.data) + self.assertEqual(response.status_code, 400) + self.assertFalse(payload['success']) + + @patch('mes_dashboard.routes.hold_overview_routes.get_hold_detail_summary') + def test_summary_failure_returns_500(self, mock_service): + mock_service.return_value = None + response = self.client.get('/api/hold-overview/summary') + payload = json.loads(response.data) + self.assertEqual(response.status_code, 500) + self.assertFalse(payload['success']) + + +class TestHoldOverviewMatrixRoute(TestHoldOverviewRoutesBase): + """Test GET /api/hold-overview/matrix endpoint.""" + + @patch('mes_dashboard.routes.hold_overview_routes.get_wip_matrix') + def test_matrix_passes_hold_filters(self, mock_service): + mock_service.return_value = { + 'workcenters': [], + 'packages': [], + 'matrix': {}, + 'workcenter_totals': {}, + 'package_totals': {}, + 'grand_total': 0, + } + + response = self.client.get('/api/hold-overview/matrix?hold_type=non-quality&reason=特殊需求管控') + self.assertEqual(response.status_code, 200) + mock_service.assert_called_once_with( + include_dummy=False, + status='HOLD', + hold_type='non-quality', + reason='特殊需求管控', + ) + + def test_matrix_invalid_hold_type(self): + response = self.client.get('/api/hold-overview/matrix?hold_type=invalid') + self.assertEqual(response.status_code, 400) + + @patch('mes_dashboard.routes.hold_overview_routes.get_wip_matrix') + @patch('mes_dashboard.core.rate_limit.check_and_record', return_value=(True, 7)) + def test_matrix_rate_limited_returns_429(self, _mock_limit, mock_service): + response = self.client.get('/api/hold-overview/matrix') + payload = json.loads(response.data) + self.assertEqual(response.status_code, 429) + self.assertEqual(payload['error']['code'], 'TOO_MANY_REQUESTS') + self.assertEqual(response.headers.get('Retry-After'), '7') + mock_service.assert_not_called() + + +class TestHoldOverviewTreemapRoute(TestHoldOverviewRoutesBase): + """Test GET /api/hold-overview/treemap endpoint.""" + + @patch('mes_dashboard.routes.hold_overview_routes.get_hold_overview_treemap') + def test_treemap_passes_filters(self, mock_service): + mock_service.return_value = {'items': []} + + response = self.client.get( + '/api/hold-overview/treemap?hold_type=quality&reason=品質確認&workcenter=WB&package=QFN' + ) + self.assertEqual(response.status_code, 200) + mock_service.assert_called_once_with( + hold_type='quality', + reason='品質確認', + workcenter='WB', + package='QFN', + include_dummy=False, + ) + + @patch('mes_dashboard.routes.hold_overview_routes.get_hold_overview_treemap') + def test_treemap_failure_returns_500(self, mock_service): + mock_service.return_value = None + response = self.client.get('/api/hold-overview/treemap') + payload = json.loads(response.data) + self.assertEqual(response.status_code, 500) + self.assertFalse(payload['success']) + + +class TestHoldOverviewLotsRoute(TestHoldOverviewRoutesBase): + """Test GET /api/hold-overview/lots endpoint.""" + + @patch('mes_dashboard.routes.hold_overview_routes.get_hold_detail_lots') + def test_lots_passes_all_filters_and_caps_per_page(self, mock_service): + mock_service.return_value = { + 'lots': [], + 'pagination': {'page': 2, 'perPage': 200, 'total': 0, 'totalPages': 1}, + 'filters': {}, + } + + response = self.client.get( + '/api/hold-overview/lots?hold_type=all&reason=品質確認' + '&workcenter=WB&package=QFN&treemap_reason=品質確認' + '&age_range=1-3&page=2&per_page=500' + ) + self.assertEqual(response.status_code, 200) + mock_service.assert_called_once_with( + reason='品質確認', + hold_type=None, + treemap_reason='品質確認', + workcenter='WB', + package='QFN', + age_range='1-3', + include_dummy=False, + page=2, + page_size=200, + ) + + @patch('mes_dashboard.routes.hold_overview_routes.get_hold_detail_lots') + def test_lots_handles_page_less_than_one(self, mock_service): + mock_service.return_value = { + 'lots': [], + 'pagination': {'page': 1, 'perPage': 50, 'total': 0, 'totalPages': 1}, + 'filters': {}, + } + + response = self.client.get('/api/hold-overview/lots?page=0') + self.assertEqual(response.status_code, 200) + call_args = mock_service.call_args + self.assertEqual(call_args.kwargs['page'], 1) + + def test_lots_invalid_age_range(self): + response = self.client.get('/api/hold-overview/lots?age_range=invalid') + payload = json.loads(response.data) + self.assertEqual(response.status_code, 400) + self.assertFalse(payload['success']) + + def test_lots_invalid_hold_type(self): + response = self.client.get('/api/hold-overview/lots?hold_type=invalid') + payload = json.loads(response.data) + self.assertEqual(response.status_code, 400) + self.assertFalse(payload['success']) + + @patch('mes_dashboard.routes.hold_overview_routes.get_hold_detail_lots') + @patch('mes_dashboard.core.rate_limit.check_and_record', return_value=(True, 4)) + def test_lots_rate_limited_returns_429(self, _mock_limit, mock_service): + response = self.client.get('/api/hold-overview/lots') + payload = json.loads(response.data) + self.assertEqual(response.status_code, 429) + self.assertEqual(payload['error']['code'], 'TOO_MANY_REQUESTS') + self.assertEqual(response.headers.get('Retry-After'), '4') + mock_service.assert_not_called() + diff --git a/tests/test_page_registry.py b/tests/test_page_registry.py index 78f099b..c9d39b3 100644 --- a/tests/test_page_registry.py +++ b/tests/test_page_registry.py @@ -35,14 +35,17 @@ def mock_registry(temp_data_file): """Mock page_registry to use temp file.""" original_data_file = page_registry.DATA_FILE original_cache = page_registry._cache + original_cache_mtime = page_registry._cache_mtime page_registry.DATA_FILE = temp_data_file page_registry._cache = None + page_registry._cache_mtime = 0.0 yield temp_data_file page_registry.DATA_FILE = original_data_file page_registry._cache = original_cache + page_registry._cache_mtime = original_cache_mtime class TestSchemaMigration: @@ -205,7 +208,8 @@ class TestReloadCache: home["status"] = "dev" temp_data_file.write_text(json.dumps(data)) - assert page_registry.get_page_status("/") == "released" + # Note: _load() has mtime-based invalidation that may auto-detect + # the file change, so we only assert post-reload behavior. page_registry.reload_cache() assert page_registry.get_page_status("/") == "dev" diff --git a/tests/test_rate_limit_routes.py b/tests/test_rate_limit_routes.py index b90b83c..866172a 100644 --- a/tests/test_rate_limit_routes.py +++ b/tests/test_rate_limit_routes.py @@ -40,3 +40,29 @@ def test_hold_detail_lots_rate_limit_returns_429(_mock_limit, mock_service): assert payload['error']['code'] == 'TOO_MANY_REQUESTS' assert response.headers.get('Retry-After') == '4' mock_service.assert_not_called() + + +@patch('mes_dashboard.routes.hold_overview_routes.get_wip_matrix') +@patch('mes_dashboard.core.rate_limit.check_and_record', return_value=(True, 6)) +def test_hold_overview_matrix_rate_limit_returns_429(_mock_limit, mock_service): + client = _client() + response = client.get('/api/hold-overview/matrix') + + assert response.status_code == 429 + payload = response.get_json() + assert payload['error']['code'] == 'TOO_MANY_REQUESTS' + assert response.headers.get('Retry-After') == '6' + mock_service.assert_not_called() + + +@patch('mes_dashboard.routes.hold_overview_routes.get_hold_detail_lots') +@patch('mes_dashboard.core.rate_limit.check_and_record', return_value=(True, 3)) +def test_hold_overview_lots_rate_limit_returns_429(_mock_limit, mock_service): + client = _client() + response = client.get('/api/hold-overview/lots') + + assert response.status_code == 429 + payload = response.get_json() + assert payload['error']['code'] == 'TOO_MANY_REQUESTS' + assert response.headers.get('Retry-After') == '3' + mock_service.assert_not_called() diff --git a/tests/test_template_integration.py b/tests/test_template_integration.py index 8c5c621..388c36b 100644 --- a/tests/test_template_integration.py +++ b/tests/test_template_integration.py @@ -54,6 +54,15 @@ class TestTemplateIntegration(unittest.TestCase): self.assertIn('type="module"', html) self.assertNotIn('mes-toast-container', html) + def test_hold_overview_serves_pure_vite_module(self): + response = self.client.get('/hold-overview') + self.assertEqual(response.status_code, 200) + html = response.data.decode('utf-8') + + self.assertIn('/static/dist/hold-overview.js', html) + self.assertIn('type="module"', html) + self.assertNotIn('mes-toast-container', html) + def test_tables_page_serves_pure_vite_module(self): response = self.client.get('/tables') self.assertEqual(response.status_code, 200) @@ -303,6 +312,7 @@ class TestViteModuleIntegration(unittest.TestCase): endpoints_and_assets = [ ('/wip-overview', 'wip-overview.js'), ('/wip-detail', 'wip-detail.js'), + ('/hold-overview', 'hold-overview.js'), ('/hold-detail?reason=test-reason', 'hold-detail.js'), ('/tables', 'tables.js'), ('/resource', 'resource-status.js'), diff --git a/tests/test_wip_service.py b/tests/test_wip_service.py index a4818a9..59fea8a 100644 --- a/tests/test_wip_service.py +++ b/tests/test_wip_service.py @@ -9,17 +9,20 @@ from unittest.mock import patch, MagicMock from functools import wraps import pandas as pd -from mes_dashboard.services.wip_service import ( - WIP_VIEW, - get_wip_summary, - get_wip_matrix, - get_wip_hold_summary, - get_wip_detail, - get_workcenters, - get_packages, - search_workorders, - search_lot_ids, -) +from mes_dashboard.services.wip_service import ( + WIP_VIEW, + get_wip_summary, + get_wip_matrix, + get_wip_hold_summary, + get_wip_detail, + get_hold_detail_summary, + get_hold_detail_lots, + get_hold_overview_treemap, + get_workcenters, + get_packages, + search_workorders, + search_lot_ids, +) def disable_cache(func): @@ -654,7 +657,140 @@ class TestMultipleFilterConditions(unittest.TestCase): -import pytest +class TestHoldOverviewServiceCachePath(unittest.TestCase): + """Test hold overview related behavior on cache path.""" + + def setUp(self): + import mes_dashboard.services.wip_service as wip_service + with wip_service._wip_search_index_lock: + wip_service._wip_search_index_cache.clear() + with wip_service._wip_snapshot_lock: + wip_service._wip_snapshot_cache.clear() + + @staticmethod + def _sample_hold_df() -> pd.DataFrame: + return pd.DataFrame({ + 'LOTID': ['L1', 'L2', 'L3', 'L4', 'L5'], + 'WORKORDER': ['WO1', 'WO2', 'WO3', 'WO4', 'WO5'], + 'QTY': [100, 50, 80, 60, 20], + 'PACKAGE_LEF': ['PKG-A', 'PKG-B', 'PKG-A', 'PKG-Z', 'PKG-C'], + 'WORKCENTER_GROUP': ['WC-A', 'WC-B', 'WC-A', 'WC-Z', 'WC-C'], + 'WORKCENTERSEQUENCE_GROUP': [1, 2, 1, 9, 3], + 'HOLDREASONNAME': ['品質確認', '特殊需求管控', '品質確認', None, '設備異常'], + 'AGEBYDAYS': [2.0, 3.0, 5.0, 0.3, 1.2], + 'EQUIPMENTCOUNT': [0, 0, 0, 1, 0], + 'CURRENTHOLDCOUNT': [1, 1, 1, 0, 1], + 'SPECNAME': ['S1', 'S2', 'S1', 'S9', 'S3'], + 'HOLDEMP': ['EMP1', 'EMP2', 'EMP3', 'EMP4', 'EMP5'], + 'DEPTNAME': ['QC', 'PD', 'QC', 'RUN', 'QC'], + 'COMMENT_HOLD': ['C1', 'C2', 'C3', 'C4', 'C5'], + 'PJ_TYPE': ['T1', 'T2', 'T1', 'T9', 'T3'], + }) + + @patch('mes_dashboard.services.wip_service.get_cached_sys_date', return_value='2026-02-10 10:00:00') + @patch('mes_dashboard.services.wip_service.get_cached_wip_data') + def test_get_hold_detail_summary_supports_optional_reason_and_hold_type( + self, + mock_cached_wip, + _mock_sys_date, + ): + mock_cached_wip.return_value = self._sample_hold_df() + + reason_summary = get_hold_detail_summary(reason='品質確認') + self.assertEqual(reason_summary['totalLots'], 2) + self.assertEqual(reason_summary['totalQty'], 180) + self.assertEqual(reason_summary['workcenterCount'], 1) + self.assertEqual(reason_summary['dataUpdateDate'], '2026-02-10 10:00:00') + + quality_summary = get_hold_detail_summary(hold_type='quality') + self.assertEqual(quality_summary['totalLots'], 3) + self.assertEqual(quality_summary['totalQty'], 200) + self.assertEqual(quality_summary['workcenterCount'], 2) + + all_hold_summary = get_hold_detail_summary() + self.assertEqual(all_hold_summary['totalLots'], 4) + self.assertEqual(all_hold_summary['totalQty'], 250) + self.assertEqual(all_hold_summary['workcenterCount'], 3) + + @patch('mes_dashboard.services.wip_service.get_cached_wip_data') + def test_get_hold_detail_lots_returns_hold_reason_and_treemap_filter(self, mock_cached_wip): + mock_cached_wip.return_value = self._sample_hold_df() + + reason_result = get_hold_detail_lots(reason='品質確認', page=1, page_size=10) + self.assertEqual(len(reason_result['lots']), 2) + self.assertEqual(reason_result['lots'][0]['lotId'], 'L3') + self.assertEqual(reason_result['lots'][0]['holdReason'], '品質確認') + + treemap_result = get_hold_detail_lots( + reason=None, + hold_type=None, + treemap_reason='特殊需求管控', + page=1, + page_size=10, + ) + self.assertEqual(len(treemap_result['lots']), 1) + self.assertEqual(treemap_result['lots'][0]['lotId'], 'L2') + self.assertEqual(treemap_result['lots'][0]['holdReason'], '特殊需求管控') + + @patch('mes_dashboard.services.wip_service.get_cached_wip_data') + def test_get_wip_matrix_reason_filter_keeps_backward_compatibility(self, mock_cached_wip): + mock_cached_wip.return_value = self._sample_hold_df() + + hold_quality_all = get_wip_matrix(status='HOLD', hold_type='quality') + self.assertEqual(hold_quality_all['grand_total'], 200) + + hold_quality_reason = get_wip_matrix( + status='HOLD', + hold_type='quality', + reason='品質確認', + ) + self.assertEqual(hold_quality_reason['grand_total'], 180) + self.assertEqual(hold_quality_reason['workcenters'], ['WC-A']) + + @patch('mes_dashboard.services.wip_service.get_cached_wip_data') + def test_get_hold_overview_treemap_groups_by_workcenter_and_reason(self, mock_cached_wip): + mock_cached_wip.return_value = self._sample_hold_df() + + result = get_hold_overview_treemap(hold_type='quality') + self.assertIsNotNone(result) + items = result['items'] + self.assertEqual(len(items), 2) + expected = {(item['workcenter'], item['reason']): item for item in items} + self.assertEqual(expected[('WC-A', '品質確認')]['lots'], 2) + self.assertEqual(expected[('WC-A', '品質確認')]['qty'], 180) + self.assertAlmostEqual(expected[('WC-A', '品質確認')]['avgAge'], 3.5) + self.assertEqual(expected[('WC-C', '設備異常')]['lots'], 1) + + +class TestHoldOverviewServiceOracleFallback(unittest.TestCase): + """Test reason filtering behavior on Oracle fallback path.""" + + @disable_cache + @patch('mes_dashboard.services.wip_service.read_sql_df') + def test_get_wip_matrix_oracle_applies_reason_for_hold_status(self, mock_read_sql): + mock_read_sql.return_value = pd.DataFrame() + + get_wip_matrix(status='HOLD', reason='品質確認') + + call_args = mock_read_sql.call_args + sql = call_args[0][0] + params = call_args[0][1] if len(call_args[0]) > 1 else {} + self.assertIn('HOLDREASONNAME', sql) + self.assertTrue(any(v == '品質確認' for v in params.values())) + + @disable_cache + @patch('mes_dashboard.services.wip_service.read_sql_df') + def test_get_wip_matrix_oracle_ignores_reason_for_non_hold_status(self, mock_read_sql): + mock_read_sql.return_value = pd.DataFrame() + + get_wip_matrix(status='RUN', reason='品質確認') + + call_args = mock_read_sql.call_args + sql = call_args[0][0] + self.assertNotIn('HOLDREASONNAME', sql) + + +import pytest class TestWipServiceIntegration: