diff --git a/data/page_status.json b/data/page_status.json index 79cc814..4d367c4 100644 --- a/data/page_status.json +++ b/data/page_status.json @@ -19,6 +19,13 @@ "drawer_id": "reports", "order": 2 }, + { + "route": "/hold-history", + "name": "Hold 歷史績效", + "status": "dev", + "drawer_id": "reports", + "order": 3 + }, { "route": "/wip-detail", "name": "WIP 明細", @@ -34,14 +41,14 @@ "name": "設備歷史績效", "status": "released", "drawer_id": "reports", - "order": 4 + "order": 5 }, { "route": "/qc-gate", "name": "QC-GATE 狀態", "status": "released", "drawer_id": "reports", - "order": 5 + "order": 6 }, { "route": "/tables", @@ -55,7 +62,7 @@ "name": "設備即時概況", "status": "released", "drawer_id": "reports", - "order": 3 + "order": 4 }, { "route": "/excel-query", diff --git a/docs/hold_history.md b/docs/hold_history.md new file mode 100644 index 0000000..7701eca --- /dev/null +++ b/docs/hold_history.md @@ -0,0 +1,67 @@ +/*PJMES043-Hold歷史紀錄 + 20240716 Peeler 新增匯總紀錄_PJM022024000878 + 20250520 Peeler 加總判斷Future Hold不同站別相同原因只計算第一次Hold_PJM022025000733 +*/ +SELECT TO_NUMBER(BS.TXNDAY) AS TXNDAY1,BS.TXNDAY + ,CASE WHEN :P_QCHOLDPARA = 2 + THEN SUM(CASE WHEN HD.HOLDTXNDAY <= BS.TRANSACTION_DAYS AND (HD.RELEASETXNDAY IS NULL OR BS.TRANSACTION_DAYS < HD.RELEASETXNDAY) AND BS.TRANSACTION_DAYS <= TO_CHAR(SYSDATE , 'YYYY/MM/DD') AND HD.RN_HOLD = 1 THEN QTY + ELSE 0 END) + ELSE SUM(CASE WHEN HD.HOLDTXNDAY <= BS.TRANSACTION_DAYS AND (HD.RELEASETXNDAY IS NULL OR BS.TRANSACTION_DAYS < HD.RELEASETXNDAY) AND BS.TRANSACTION_DAYS <= TO_CHAR(SYSDATE , 'YYYY/MM/DD') AND QCHOLDFLAG = :P_QCHOLDPARA AND HD.RN_HOLD = 1 THEN QTY + ELSE 0 END ) + END AS HOLDQTY + + ,CASE WHEN :P_QCHOLDPARA = 2 + THEN SUM(CASE WHEN HD.HOLDTXNDAY = BS.TRANSACTION_DAYS AND (HD.RELEASETXNDAY IS NULL OR BS.TRANSACTION_DAYS <= HD.RELEASETXNDAY) AND HD.FUTUREHOLD_FLAG = 1 THEN QTY + ELSE 0 END ) + ELSE SUM(CASE WHEN HD.HOLDTXNDAY = BS.TRANSACTION_DAYS AND (HD.RELEASETXNDAY IS NULL OR BS.TRANSACTION_DAYS <= HD.RELEASETXNDAY) AND QCHOLDFLAG = :P_QCHOLDPARA AND HD.FUTUREHOLD_FLAG = 1 THEN QTY + ELSE 0 END ) + END AS NEW_HOLDQTY + + ,CASE WHEN :P_QCHOLDPARA = 2 + THEN SUM(CASE WHEN HD.RELEASETXNDAY = BS.TRANSACTION_DAYS AND HD.RELEASETXNDAY >= HD.HOLDTXNDAY THEN QTY + ELSE 0 END ) + ELSE SUM(CASE WHEN HD.RELEASETXNDAY = BS.TRANSACTION_DAYS AND HD.RELEASETXNDAY >= HD.HOLDTXNDAY AND QCHOLDFLAG = :P_QCHOLDPARA THEN QTY + ELSE 0 END ) + END AS RELEASEQTY + + ,CASE WHEN :P_QCHOLDPARA = 2 + THEN SUM(CASE WHEN HD.HOLDTXNDAY = BS.TRANSACTION_DAYS AND (HD.RELEASETXNDAY IS NULL OR BS.TRANSACTION_DAYS <= HD.RELEASETXNDAY) AND HD.RN_HOLD = 1 AND HD.FUTUREHOLD_FLAG = 0 THEN QTY + ELSE 0 END ) + ELSE SUM(CASE WHEN HD.HOLDTXNDAY = BS.TRANSACTION_DAYS AND (HD.RELEASETXNDAY IS NULL OR BS.TRANSACTION_DAYS <= HD.RELEASETXNDAY) AND QCHOLDFLAG = :P_QCHOLDPARA AND HD.RN_HOLD = 1 AND HD.FUTUREHOLD_FLAG = 0 THEN QTY + ELSE 0 END ) + END AS FUTURE_HOLDQTY + FROM(select TO_CHAR(to_date(:P_TxnDate_S , 'YYYY/MM/DD') + rownum -1,'YYYY') AS TXNYEAR + , TO_CHAR(to_date(:P_TxnDate_S , 'YYYY/MM/DD') + rownum -1,'MM') AS TXNMONTH + , TO_CHAR(to_date(:P_TxnDate_S , 'YYYY/MM/DD') + rownum -1,'DD') AS TXNDAY + , TO_CHAR(to_date(:P_TxnDate_S , 'YYYY/MM/DD') + rownum -1,'YYYY/MM/DD') AS TRANSACTION_DAYS + From dual + CONNECT BY LEVEL <= TO_CHAR(LAST_DAY(to_date(:P_TxnDate_S,'YYYY/MM/DD')),'DD') + )BS, + (SELECT HOLDTXNDAY,RELEASETXNDAY,HOLDTXNDATE,RELEASETXNDATE,CONTAINERID,QTY, QCHOLDFLAG + ,ROW_NUMBER() OVER (PARTITION BY CONTAINERID,HOLDTXNDAY ORDER BY HOLDTXNDATE DESC) AS RN_HOLD--同一張工單當天重複Hold + ,ROW_NUMBER() OVER (PARTITION BY CONTAINERID,RELEASETXNDAY ORDER BY RELEASETXNDATE DESC) AS RN_RELEASE--同一張工單當天重複Release + ,CASE WHEN FUTUREHOLD = 1 AND RN_CONHOLD <> 1 THEN 0 + ELSE 1 END AS FUTUREHOLD_FLAG --FutureHold相同原因第一筆計算1其餘給0 + FROM(SELECT CASE WHEN TO_CHAR(HD.HOLDTXNDATE,'HH24MI')>=0730 + THEN TO_CHAR(HD.HOLDTXNDATE +1 ,'YYYY/MM/DD') + ELSE TO_CHAR(HD.HOLDTXNDATE ,'YYYY/MM/DD') END AS HOLDTXNDAY + ,CASE WHEN TO_CHAR(HD.RELEASETXNDATE,'HH24MI')>=0730 + THEN TO_CHAR(HD.RELEASETXNDATE +1 ,'YYYY/MM/DD') + ELSE TO_CHAR(HD.RELEASETXNDATE ,'YYYY/MM/DD') END AS RELEASETXNDAY + ,HD.HOLDTXNDATE + ,HD.RELEASETXNDATE + ,HD.CONTAINERID + ,HD.QTY + ,CASE WHEN HD.HOLDREASONNAME IN(:P_QCHOLDREASON) THEN 1 + ELSE 0 END AS QCHOLDFLAG + ,CASE WHEN HD.FUTUREHOLDCOMMENTS IS NOT NULL THEN 1 + ELSE 0 END AS FUTUREHOLD + ,ROW_NUMBER() OVER (PARTITION BY HD.CONTAINERID,HD.HOLDREASONID ORDER BY HD.HOLDTXNDATE) AS RN_CONHOLD--同一張工單重複Hold + FROM DW_MES_HOLDRELEASEHISTORY HD + WHERE 1=1 + AND ((HD.HOLDTXNDATE >= TO_DATE(:P_TxnDate_S||' 073000', 'YYYYMMDD HH24MISS')-1) Or :P_TxnDate_S is null OR (HD.RELEASETXNDATE >= TO_DATE(:P_TxnDate_S||' 073000', 'YYYYMMDD HH24MISS')-1) OR (HD.RELEASETXNDATE IS NULL)) + AND ((HD.HOLDTXNDATE <= TO_DATE(:P_TxnDate_E||' 073000', 'YYYYMMDD HH24MISS')) Or :P_TxnDate_E is null OR (HD.RELEASETXNDATE <= TO_DATE(:P_TxnDate_E||' 073000', 'YYYYMMDD HH24MISS')) OR (HD.RELEASETXNDATE IS NULL)) + ) + )HD + WHERE 1=1 +GROUP BY BS.TXNDAY \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json index a7ce740..e7c4cd9 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/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", + "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/hold-history/index.html ../src/mes_dashboard/static/dist/hold-history.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-detail/components/LotTable.vue b/frontend/src/hold-detail/components/LotTable.vue index d9661f2..1fa0c08 100644 --- a/frontend/src/hold-detail/components/LotTable.vue +++ b/frontend/src/hold-detail/components/LotTable.vue @@ -85,6 +85,7 @@ const pageInfo = computed(() => { LOTID WORKORDER QTY + Product Package Workcenter Spec @@ -92,22 +93,24 @@ const pageInfo = computed(() => { Hold By Dept Hold Comment + Future Hold Comment - Loading... + Loading... - {{ errorMessage }} + {{ errorMessage }} - No data + No data {{ lot.lotId || '-' }} {{ lot.workorder || '-' }} {{ formatNumber(lot.qty) }} + {{ lot.product || '-' }} {{ lot.package || '-' }} {{ lot.workcenter || '-' }} {{ lot.spec || '-' }} @@ -115,6 +118,7 @@ const pageInfo = computed(() => { {{ lot.holdBy || '-' }} {{ lot.dept || '-' }} {{ lot.holdComment || '-' }} + {{ lot.futureHoldComment || '-' }} diff --git a/frontend/src/hold-history/App.vue b/frontend/src/hold-history/App.vue new file mode 100644 index 0000000..3b4b927 --- /dev/null +++ b/frontend/src/hold-history/App.vue @@ -0,0 +1,500 @@ + + + diff --git a/frontend/src/hold-history/components/DailyTrend.vue b/frontend/src/hold-history/components/DailyTrend.vue new file mode 100644 index 0000000..266d457 --- /dev/null +++ b/frontend/src/hold-history/components/DailyTrend.vue @@ -0,0 +1,131 @@ + + + diff --git a/frontend/src/hold-history/components/DetailTable.vue b/frontend/src/hold-history/components/DetailTable.vue new file mode 100644 index 0000000..ca4706b --- /dev/null +++ b/frontend/src/hold-history/components/DetailTable.vue @@ -0,0 +1,118 @@ + + + diff --git a/frontend/src/hold-history/components/DurationChart.vue b/frontend/src/hold-history/components/DurationChart.vue new file mode 100644 index 0000000..1daf81d --- /dev/null +++ b/frontend/src/hold-history/components/DurationChart.vue @@ -0,0 +1,115 @@ + + + diff --git a/frontend/src/hold-history/components/FilterBar.vue b/frontend/src/hold-history/components/FilterBar.vue new file mode 100644 index 0000000..08b5f01 --- /dev/null +++ b/frontend/src/hold-history/components/FilterBar.vue @@ -0,0 +1,103 @@ + + + diff --git a/frontend/src/hold-history/components/FilterIndicator.vue b/frontend/src/hold-history/components/FilterIndicator.vue new file mode 100644 index 0000000..158a24e --- /dev/null +++ b/frontend/src/hold-history/components/FilterIndicator.vue @@ -0,0 +1,32 @@ + + + diff --git a/frontend/src/hold-history/components/ReasonPareto.vue b/frontend/src/hold-history/components/ReasonPareto.vue new file mode 100644 index 0000000..71d03ee --- /dev/null +++ b/frontend/src/hold-history/components/ReasonPareto.vue @@ -0,0 +1,140 @@ + + + diff --git a/frontend/src/hold-history/components/RecordTypeFilter.vue b/frontend/src/hold-history/components/RecordTypeFilter.vue new file mode 100644 index 0000000..13943f4 --- /dev/null +++ b/frontend/src/hold-history/components/RecordTypeFilter.vue @@ -0,0 +1,53 @@ + + + diff --git a/frontend/src/hold-history/components/SummaryCards.vue b/frontend/src/hold-history/components/SummaryCards.vue new file mode 100644 index 0000000..c76d77c --- /dev/null +++ b/frontend/src/hold-history/components/SummaryCards.vue @@ -0,0 +1,59 @@ + + + diff --git a/frontend/src/hold-history/index.html b/frontend/src/hold-history/index.html new file mode 100644 index 0000000..fb2f80d --- /dev/null +++ b/frontend/src/hold-history/index.html @@ -0,0 +1,12 @@ + + + + + + Hold History + + +
+ + + diff --git a/frontend/src/hold-history/main.js b/frontend/src/hold-history/main.js new file mode 100644 index 0000000..badc9a1 --- /dev/null +++ b/frontend/src/hold-history/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-history/style.css b/frontend/src/hold-history/style.css new file mode 100644 index 0000000..1caf02b --- /dev/null +++ b/frontend/src/hold-history/style.css @@ -0,0 +1,305 @@ +.hold-history-header { + background: linear-gradient(135deg, #0f766e 0%, #0ea5e9 100%); +} + +.hold-history-page .header h1 { + font-size: 22px; +} + +.hold-type-badge { + display: inline-flex; + align-items: center; + padding: 4px 10px; + border-radius: 999px; + font-size: 12px; + font-weight: 700; + background: rgba(255, 255, 255, 0.18); + color: #ffffff; +} + +.card { + background: var(--card-bg); + border-radius: 10px; + box-shadow: var(--shadow); + overflow: hidden; + margin-bottom: 14px; +} + +.card-header { + padding: 14px 18px; + border-bottom: 1px solid var(--border); + background: #f8fafc; +} + +.card-title { + font-size: 15px; + font-weight: 700; + color: #0f172a; +} + +.card-body { + padding: 14px 16px; +} + +.error-banner { + margin-bottom: 14px; + padding: 10px 12px; + border-radius: 6px; + background: #fef2f2; + color: #991b1b; + font-size: 13px; +} + +.filter-bar { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 16px; + padding: 16px 20px; +} + +.filter-group { + display: flex; + flex-direction: column; + gap: 8px; +} + +.filter-label { + font-size: 12px; + font-weight: 700; + color: #475569; +} + +.date-input { + min-width: 170px; + padding: 8px 10px; + border: 1px solid var(--border); + border-radius: 8px; + font-size: 13px; + background: #ffffff; +} + +.date-input:focus { + outline: none; + border-color: #0ea5e9; + box-shadow: 0 0 0 2px rgba(14, 165, 233, 0.18); +} + +.radio-group, +.checkbox-group { + display: inline-flex; + flex-wrap: wrap; + gap: 10px; +} + +.radio-option, +.checkbox-option { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + border-radius: 8px; + border: 1px solid var(--border); + background: #ffffff; + cursor: pointer; + font-size: 13px; +} + +.radio-option.active, +.checkbox-option.active { + border-color: #0284c7; + background: #e0f2fe; + color: #075985; + font-weight: 700; +} + +.record-type-filter { + display: flex; + align-items: center; + gap: 12px; +} + +.hold-history-summary-row { + grid-template-columns: repeat(6, minmax(0, 1fr)); +} + +.summary-card { + border: 1px solid var(--border); +} + +.summary-value.positive { + color: #15803d; +} + +.summary-value.negative { + color: #b91c1c; +} + +.stat-positive .summary-value { + color: #15803d; +} + +.stat-negative-red .summary-value { + color: #b91c1c; +} + +.stat-negative-orange .summary-value { + color: #c2410c; +} + +.hold-history-chart-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; + margin-bottom: 14px; +} + +.trend-chart-wrap, +.pareto-chart-wrap, +.duration-chart-wrap { + height: 360px; +} + +.reason-filter-indicator { + display: flex; + align-items: center; + gap: 10px; + margin: 2px 0 14px; +} + +.filter-chip.reason, +.filter-chip.duration { + display: inline-flex; + align-items: center; + padding: 6px 12px; + border-radius: 999px; + font-size: 12px; + font-weight: 700; + background: #dbeafe; + color: #1e3a8a; +} + +.filter-chip.duration { + background: #f3e8ff; + color: #581c87; +} + +.clear-reason-btn { + padding: 6px 10px; + font-size: 12px; +} + +.department-table-wrap, +.detail-table-wrap { + padding: 0; + overflow: auto; +} + +.dept-table, +.detail-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; +} + +.dept-table th, +.dept-table td, +.detail-table th, +.detail-table td { + padding: 9px 10px; + border-bottom: 1px solid #e5e7eb; + white-space: nowrap; + text-align: left; +} + +.dept-table th, +.detail-table th { + position: sticky; + top: 0; + z-index: 1; + background: #f8fafc; + font-weight: 700; +} + +.dept-table td:nth-child(3), +.dept-table td:nth-child(4), +.dept-table td:nth-child(5), +.detail-table td:nth-child(11), +.detail-table td:nth-child(12) { + text-align: right; +} + +.dept-row { + background: #ffffff; +} + +.person-row { + background: #f8fafc; +} + +.person-name { + color: #334155; +} + +.expand-btn { + width: 24px; + height: 24px; + border: 1px solid #cbd5e1; + border-radius: 4px; + background: #ffffff; + cursor: pointer; + font-size: 14px; + line-height: 1; +} + +.detail-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.table-info { + font-size: 12px; + color: #64748b; +} + +.cell-comment { + max-width: 220px; + overflow: hidden; + text-overflow: ellipsis; +} + +@media (max-width: 1440px) { + .hold-history-summary-row { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +@media (max-width: 1080px) { + .hold-history-chart-grid { + grid-template-columns: 1fr; + } + + .hold-history-summary-row { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 760px) { + .hold-history-summary-row { + grid-template-columns: 1fr; + } + + .filter-bar { + padding: 14px 16px; + } + + .date-input { + min-width: 140px; + } + + .hold-history-page .header h1 { + font-size: 20px; + } +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js index d2bb97b..d48cd0f 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -17,6 +17,7 @@ export default defineConfig(({ mode }) => ({ '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'), + 'hold-history': resolve(__dirname, 'src/hold-history/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-history-dashboard/.openspec.yaml b/openspec/changes/archive/2026-02-10-hold-history-dashboard/.openspec.yaml new file mode 100644 index 0000000..70eb9e0 --- /dev/null +++ b/openspec/changes/archive/2026-02-10-hold-history-dashboard/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-02-10 diff --git a/openspec/changes/archive/2026-02-10-hold-history-dashboard/design.md b/openspec/changes/archive/2026-02-10-hold-history-dashboard/design.md new file mode 100644 index 0000000..5fc8d52 --- /dev/null +++ b/openspec/changes/archive/2026-02-10-hold-history-dashboard/design.md @@ -0,0 +1,94 @@ +## Context + +Hold Overview (DW_MES_LOT_V, Redis cache) 提供即時快照;Hold Detail 深入單一 Reason。但主管缺乏歷史視角——趨勢、時長分析、部門績效都只能透過 BI 工具 (PJMES043) 手動操作。 + +本設計在既有 Dashboard 架構上新增一個歷史績效頁面,直接查詢 `DWH.DW_MES_HOLDRELEASEHISTORY` 表 (~310K rows),搭配 Redis 快取加速近期資料。 + +## Goals / Non-Goals + +**Goals:** +- 提供自由日期區間的 Hold 歷史每日趨勢圖(On Hold/新增/解除/Future Hold) +- 提供 Reason Pareto、Duration 分布、負責人統計(部門+個人)分析 +- 提供 paginated Hold/Release 明細表 +- Reason Pareto 點擊可 cascade filter 負責人統計與明細表 +- 近二月資料使用 Redis 快取(12hr TTL),前端切換 Hold Type 免 re-call + +**Non-Goals:** +- 不替代既有 Hold Overview / Hold Detail 的即時功能 +- 不引入即時 WebSocket 推送 +- 不做跨頁面的 drill-through(本頁面自成體系) +- 不修改 HOLDRELEASEHISTORY 表結構或新增 index + +## Decisions + +### 1. 資料來源:直接查詢 HOLDRELEASEHISTORY vs. 預建聚合表 + +**選擇**: 直接查詢 + Redis 快取聚合結果 + +**理由**: 310K 行規模適中,calendar-spine cross-join 月級查詢在秒級內完成。預建聚合表增加 ETL 複雜度,且歷史數據變動低頻,12hr 快取已足夠。 + +**替代方案**: 在 DWH 建立物化視圖 → 拒絕,因需 DBA 協調且 Dashboard 應盡量自包含。 + +### 2. 快取策略:近二月 Redis vs. 全量快取 vs. 無快取 + +**選擇**: 近二月(當月 + 前一月)Redis 快取,12hr TTL + +**理由**: 多數使用者查看近期資料。近二月快取命中率高,超過二月的查詢較少且可接受直接 Oracle 查詢的延遲。全量快取浪費記憶體且過期管理複雜。 + +**Redis key**: `hold_history:daily:{YYYY-MM}` +**結構**: 一份快取包含 quality / non_quality / all 三種 hold_type 的每日聚合,前端切換免 API re-call。 +**跨月查詢**: 後端從多個月快取中切出需要的日期範圍後合併回傳。 + +### 3. trend API 回傳三種 hold_type vs. 按需查詢 + +**選擇**: trend API 一次回傳三種 hold_type 的每日資料 + +**理由**: 趨勢是最常操作的圖表,切換 hold_type 應即時響應。三種 hold_type 資料已在同一份 Redis 快取中,回傳全部不增加 I/O,但大幅改善 UX。其餘 4 支 API (pareto/duration/department/list) 按 hold_type 過濾,因為它們的 payload 可能很大。 + +### 4. SQL 集中管理:sql/hold_history/ 目錄 + +**選擇**: SQL 檔案放在 `src/mes_dashboard/sql/hold_history/` 目錄 + +**理由**: 遵循既有 `sql/query_tool/`、`sql/dashboard/`、`sql/resource/`、`sql/wip/` 的集中管理模式。SQL 與 Python 分離便於 review 和維護。 + +**檔案規劃**: +- `trend.sql` — calendar-spine cross-join 每日聚合(翻譯自 hold_history.md) +- `reason_pareto.sql` — GROUP BY HOLDREASONNAME +- `duration.sql` — 已 release 的 hold 時長分布 +- `department.sql` — GROUP BY HOLDEMPDEPTNAME / HOLDEMP +- `list.sql` — paginated 明細查詢 + +### 5. 商業邏輯:07:30 班別邊界 + +**選擇**: 忠實保留 hold_history.md 的班別邊界邏輯 + +**理由**: 這是工廠既有的日報定義,07:30 後的交易歸入隔天。偏離此定義會導致 Dashboard 數字與既有 BI 報表不一致。 + +**實作**: 在 SQL 層處理 (`CASE WHEN TO_CHAR(HOLDTXNDATE,'HH24MI') >= '0730' THEN TRUNC(HOLDTXNDATE) + 1 ELSE TRUNC(HOLDTXNDATE) END`)。 + +### 6. 前端架構 + +**選擇**: 獨立 Vue 3 SFC 頁面,複用 wip-shared composables + +**元件規劃**: +- `App.vue` — 頁面主容器、狀態管理、API 呼叫 +- `FilterBar.vue` — DatePicker + Hold Type radio +- `SummaryCards.vue` — 6 張 KPI 卡片 +- `DailyTrend.vue` — ECharts 折線+柱狀混合圖 +- `ReasonPareto.vue` — ECharts Pareto 圖(可點擊) +- `DurationChart.vue` — ECharts 橫向柱狀圖 +- `DepartmentTable.vue` — 可展開的部門/個人統計表 +- `DetailTable.vue` — paginated 明細表 + +### 7. Duration 分布的計算範圍 + +**選擇**: 僅計算已 Release 的 hold(RELEASETXNDATE IS NOT NULL) + +**理由**: 仍在 hold 中的無法確定最終時長,納入會扭曲分布。明細表中仍顯示未 release 的 hold(以 SYSDATE 計算到目前時長)。 + +## Risks / Trade-offs + +- **[HOLDTXNDATE 無 index]** → HOLDRELEASEHISTORY 僅有 HISTORYMAINLINEID 和 CONTAINERID 的 index。日期範圍查詢走 full table scan (~310K rows)。緩解:12hr Redis 快取 + 月級查詢粒度限制。若未來資料量成長,可考慮請 DBA 加 HOLDTXNDATE index。 +- **[Calendar-spine cross-join 效能]** → 月曆骨幹 × 全表 cross join 是最重的查詢。緩解:Redis 快取近二月,超過二月直接查詢但接受較長 loading。 +- **[Redis 快取一致性]** → 12hr TTL 意味資料最多延遲 12 小時。緩解:歷史資料本身就是 T-1 更新,12hr 延遲對管理決策無影響。 +- **[明細表回傳 HOLDCOMMENTS/RELEASECOMMENTS]** → 文字欄位可能很長。緩解:前端 truncate 顯示,hover 看全文。 diff --git a/openspec/changes/archive/2026-02-10-hold-history-dashboard/proposal.md b/openspec/changes/archive/2026-02-10-hold-history-dashboard/proposal.md new file mode 100644 index 0000000..ca56a6a --- /dev/null +++ b/openspec/changes/archive/2026-02-10-hold-history-dashboard/proposal.md @@ -0,0 +1,32 @@ +## Why + +Hold Overview 和 Hold Detail 都是基於 DW_MES_LOT_V 的即時快照,只能回答「現在線上有什麼 Hold」。主管需要追蹤歷史趨勢來回答「Hold 狀況是在改善還是惡化?哪些原因最耗時?哪個部門處理最慢?」。目前這些分析只能透過 BI 工具 (PJMES043) 手動查詢,無法即時在 Dashboard 上呈現。 + +## What Changes + +- 新增 `/hold-history` 頁面,提供 Hold/Release 歷史績效 Dashboard +- 新增 5 支 API endpoints (`/api/hold-history/trend`, `reason-pareto`, `duration`, `department`, `list`) +- 新增 `hold_history_service.py` 服務層,查詢 `DWH.DW_MES_HOLDRELEASEHISTORY` 表 +- 新增 SQL 檔案集中管理在 `src/mes_dashboard/sql/hold_history/` 目錄 +- trend API 採用 Redis 快取策略(近二月聚合資料,12hr TTL) +- 翻譯 `docs/hold_history.md` 中的 calendar-spine cross-join 商業邏輯為參數化 SQL +- 新增 Vite entry point `src/hold-history/index.html` +- 新增頁面註冊至 `data/page_status.json` + +## Capabilities + +### New Capabilities +- `hold-history-page`: Hold 歷史績效 Dashboard 前端頁面,包含篩選器、Summary KPIs、Daily Trend 圖、Reason Pareto、Duration 分布、負責人統計、明細表,及 Reason Pareto 的 cascade filter 機制 +- `hold-history-api`: Hold 歷史績效 API 後端,包含 5 支 endpoints、Oracle 查詢(含 calendar-spine 商業邏輯)、Redis 快取策略、SQL 集中管理 + +### Modified Capabilities +- `vue-vite-page-architecture`: 新增 Hold History entry point 至 Vite 配置 + +## Impact + +- **後端**: 新增 Flask Blueprint `hold_history_routes.py`、服務層 `hold_history_service.py`、SQL 檔案 `sql/hold_history/` +- **前端**: 新增 `frontend/src/hold-history/` 頁面目錄,使用 ECharts (BarChart, LineChart) 及 wip-shared composables +- **資料庫**: 直接查詢 `DWH.DW_MES_HOLDRELEASEHISTORY`(~310K rows),無 schema 變更 +- **Redis**: 新增 `hold_history:daily:{YYYY-MM}` 快取 key +- **配置**: `vite.config.js` 新增 entry、`page_status.json` 新增頁面註冊 +- **既有功能**: 無影響,完全獨立的新頁面和新 API diff --git a/openspec/changes/archive/2026-02-10-hold-history-dashboard/specs/hold-history-api/spec.md b/openspec/changes/archive/2026-02-10-hold-history-dashboard/specs/hold-history-api/spec.md new file mode 100644 index 0000000..f723cae --- /dev/null +++ b/openspec/changes/archive/2026-02-10-hold-history-dashboard/specs/hold-history-api/spec.md @@ -0,0 +1,172 @@ +## ADDED Requirements + +### Requirement: Hold History API SHALL provide daily trend data with Redis caching +The API SHALL return daily aggregated hold/release metrics for the selected date range. + +#### Scenario: Trend endpoint returns all three hold types +- **WHEN** `GET /api/hold-history/trend?start_date=2025-01-01&end_date=2025-01-31` is called +- **THEN** the response SHALL return `{ success: true, data: { days: [...] } }` +- **THEN** each day item SHALL contain `{ date, quality: { holdQty, newHoldQty, releaseQty, futureHoldQty }, non_quality: { ... }, all: { ... } }` +- **THEN** all three hold_type variants SHALL be included in a single response + +#### Scenario: Trend uses shift boundary at 07:30 +- **WHEN** daily aggregation is calculated +- **THEN** transactions with time >= 07:30 SHALL be attributed to the next calendar day +- **THEN** transactions with time < 07:30 SHALL be attributed to the current calendar day + +#### Scenario: Trend deduplicates same-day multiple holds +- **WHEN** a lot is held multiple times on the same day +- **THEN** only one hold event SHALL be counted for that day (using ROW_NUMBER per CONTAINERID per day) + +#### Scenario: Trend deduplicates future holds +- **WHEN** the same lot has multiple future holds for the same reason +- **THEN** only the first occurrence SHALL be counted (using ROW_NUMBER per CONTAINERID per HOLDREASONID) + +#### Scenario: Trend hold type classification +- **WHEN** trend data is aggregated by hold type +- **THEN** quality classification SHALL use the same NON_QUALITY_HOLD_REASONS set as existing hold endpoints +- **THEN** holds with HOLDREASONNAME NOT in NON_QUALITY_HOLD_REASONS SHALL be classified as quality +- **THEN** the "all" variant SHALL include both quality and non-quality holds + +#### Scenario: Trend Redis cache for recent two months +- **WHEN** the requested date range falls within the current month or previous month +- **THEN** the service SHALL check Redis for cached data at key `hold_history:daily:{YYYY-MM}` +- **THEN** if cache exists, data SHALL be returned from Redis +- **THEN** if cache is missing, data SHALL be queried from Oracle and stored in Redis with 12-hour TTL + +#### Scenario: Trend direct Oracle query for older data +- **WHEN** the requested date range includes months older than the previous month +- **THEN** the service SHALL query Oracle directly without caching + +#### Scenario: Trend cross-month query assembly +- **WHEN** the requested date range spans multiple months (e.g., 2025-01-15 to 2025-02-15) +- **THEN** the service SHALL fetch each month's data independently (from cache or Oracle) +- **THEN** the service SHALL trim the combined result to the exact requested date range +- **THEN** the response SHALL contain only days within start_date and end_date inclusive + +#### Scenario: Trend error +- **WHEN** the database query fails +- **THEN** the response SHALL return `{ success: false, error: '查詢失敗' }` with HTTP 500 + +### Requirement: Hold History API SHALL provide reason Pareto data +The API SHALL return hold reason distribution for Pareto analysis. + +#### Scenario: Reason Pareto endpoint +- **WHEN** `GET /api/hold-history/reason-pareto?start_date=2025-01-01&end_date=2025-01-31&hold_type=quality` is called +- **THEN** the response SHALL return `{ success: true, data: { items: [...] } }` +- **THEN** each item SHALL contain `{ reason, count, qty, pct, cumPct }` +- **THEN** items SHALL be sorted by count descending +- **THEN** pct SHALL be percentage of total hold events +- **THEN** cumPct SHALL be running cumulative percentage + +#### Scenario: Reason Pareto uses shift boundary +- **WHEN** hold events are counted for Pareto +- **THEN** the 07:30 shift boundary rule SHALL be applied to HOLDTXNDATE + +#### Scenario: Reason Pareto hold type filter +- **WHEN** hold_type is "quality" +- **THEN** only quality hold reasons SHALL be included +- **WHEN** hold_type is "non-quality" +- **THEN** only non-quality hold reasons SHALL be included +- **WHEN** hold_type is "all" +- **THEN** all hold reasons SHALL be included + +### Requirement: Hold History API SHALL provide hold duration distribution +The API SHALL return hold duration distribution buckets. + +#### Scenario: Duration endpoint +- **WHEN** `GET /api/hold-history/duration?start_date=2025-01-01&end_date=2025-01-31&hold_type=quality` is called +- **THEN** the response SHALL return `{ success: true, data: { items: [...] } }` +- **THEN** items SHALL contain 4 buckets: `{ range: "<4h", count, pct }`, `{ range: "4-24h", count, pct }`, `{ range: "1-3d", count, pct }`, `{ range: ">3d", count, pct }` + +#### Scenario: Duration only includes released holds +- **WHEN** duration is calculated +- **THEN** only hold records with RELEASETXNDATE IS NOT NULL SHALL be included +- **THEN** duration SHALL be calculated as RELEASETXNDATE - HOLDTXNDATE + +#### Scenario: Duration date range filter +- **WHEN** start_date and end_date are provided +- **THEN** only holds with HOLDTXNDATE within the date range (applying 07:30 shift boundary) SHALL be included + +### Requirement: Hold History API SHALL provide department statistics +The API SHALL return hold/release statistics aggregated by department with optional person detail. + +#### Scenario: Department endpoint +- **WHEN** `GET /api/hold-history/department?start_date=2025-01-01&end_date=2025-01-31&hold_type=quality` is called +- **THEN** the response SHALL return `{ success: true, data: { items: [...] } }` +- **THEN** each item SHALL contain `{ dept, holdCount, releaseCount, avgHoldHours, persons: [{ name, holdCount, releaseCount, avgHoldHours }] }` +- **THEN** items SHALL be sorted by holdCount descending + +#### Scenario: Department with reason filter +- **WHEN** `GET /api/hold-history/department?start_date=2025-01-01&end_date=2025-01-31&hold_type=quality&reason=品質確認` is called +- **THEN** only hold records matching the specified reason SHALL be included in department and person statistics + +#### Scenario: Department hold count vs release count +- **WHEN** department statistics are calculated +- **THEN** holdCount SHALL count records where HOLDEMPDEPTNAME equals the department AND HOLDTXNDATE is within the date range +- **THEN** releaseCount SHALL count records where RELEASEEMPDEPTNAME equals the department AND RELEASETXNDATE is within the date range +- **THEN** avgHoldHours SHALL be the average of (RELEASETXNDATE - HOLDTXNDATE) in hours for released holds initiated by that department + +### Requirement: Hold History API SHALL provide paginated detail list +The API SHALL return a paginated list of individual hold/release records. + +#### Scenario: List endpoint +- **WHEN** `GET /api/hold-history/list?start_date=2025-01-01&end_date=2025-01-31&hold_type=quality&page=1&per_page=50` is called +- **THEN** the response SHALL return `{ success: true, data: { items: [...], pagination: { page, perPage, total, totalPages } } }` +- **THEN** each item SHALL contain: lotId, workorder, workcenter, holdReason, holdDate, holdEmp, holdComment, releaseDate, releaseEmp, releaseComment, holdHours, ncr +- **THEN** items SHALL be sorted by HOLDTXNDATE descending + +#### Scenario: List with reason filter +- **WHEN** `GET /api/hold-history/list?start_date=2025-01-01&end_date=2025-01-31&hold_type=quality&reason=品質確認` is called +- **THEN** only records matching the specified HOLDREASONNAME SHALL be returned + +#### Scenario: List unreleased hold records +- **WHEN** a hold record has RELEASETXNDATE IS NULL +- **THEN** releaseDate SHALL be null +- **THEN** holdHours SHALL be calculated as (SYSDATE - HOLDTXNDATE) * 24 + +#### Scenario: List 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: List date range uses shift boundary +- **WHEN** records are filtered by date range +- **THEN** the 07:30 shift boundary rule SHALL be applied to HOLDTXNDATE + +### Requirement: Hold History API SHALL use centralized SQL files +The API SHALL load SQL queries from files in the `src/mes_dashboard/sql/hold_history/` directory. + +#### Scenario: SQL file organization +- **WHEN** the hold history service executes a query +- **THEN** the SQL SHALL be loaded from `sql/hold_history/.sql` +- **THEN** the following SQL files SHALL exist: `trend.sql`, `reason_pareto.sql`, `duration.sql`, `department.sql`, `list.sql` + +#### Scenario: SQL parameterization +- **WHEN** SQL queries are executed +- **THEN** all user-provided parameters (dates, hold_type, reason) SHALL be passed as bind parameters +- **THEN** no string interpolation SHALL be used for user input + +### Requirement: Hold History API SHALL apply rate limiting +The API SHALL apply rate limiting to expensive endpoints. + +#### Scenario: Rate limit on list endpoint +- **WHEN** the list 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 trend endpoint +- **WHEN** the trend endpoint receives excessive requests +- **THEN** rate limiting SHALL be applied using `configured_rate_limit` with a default of 60 requests per 60 seconds + +### Requirement: Hold History 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-history` +- **THEN** Flask SHALL serve the pre-built HTML file from `static/dist/hold-history.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 diff --git a/openspec/changes/archive/2026-02-10-hold-history-dashboard/specs/hold-history-page/spec.md b/openspec/changes/archive/2026-02-10-hold-history-dashboard/specs/hold-history-page/spec.md new file mode 100644 index 0000000..78db380 --- /dev/null +++ b/openspec/changes/archive/2026-02-10-hold-history-dashboard/specs/hold-history-page/spec.md @@ -0,0 +1,172 @@ +## ADDED Requirements + +### Requirement: Hold History page SHALL display a filter bar with date range and hold type +The page SHALL provide a filter bar for selecting date range and hold type classification. + +#### Scenario: Default date range +- **WHEN** the page loads +- **THEN** the date range SHALL default to the first and last day of the current month + +#### Scenario: Hold Type radio default +- **WHEN** the page loads +- **THEN** the Hold Type filter SHALL default to "品質異常" +- **THEN** three radio options SHALL display: 品質異常, 非品質異常, 全部 + +#### Scenario: Filter bar change reloads all data +- **WHEN** user changes the date range or Hold Type selection +- **THEN** all API calls (trend, reason-pareto, duration, department, list) SHALL reload with the new parameters +- **THEN** any active Reason Pareto filter SHALL be cleared +- **THEN** pagination SHALL reset to page 1 + +### Requirement: Hold History page SHALL display summary KPI cards +The page SHALL show 6 summary KPI cards derived from the trend data for the selected period. + +#### Scenario: Summary cards rendering +- **WHEN** trend data is loaded +- **THEN** six cards SHALL display: Release 數量, New Hold 數量, Future Hold 數量, 淨變動, 期末 On Hold, 平均 Hold 時長 +- **THEN** Release SHALL be displayed as a positive indicator (green) +- **THEN** New Hold and Future Hold SHALL be displayed as negative indicators (red/orange) +- **THEN** 淨變動 SHALL equal Release - New Hold - Future Hold +- **THEN** 期末 On Hold SHALL be the HOLDQTY of the last day in the selected range +- **THEN** number values SHALL use zh-TW number formatting + +#### Scenario: Summary reflects filter bar only +- **WHEN** user clicks a Reason Pareto block +- **THEN** summary cards SHALL NOT change (they only respond to filter bar changes) + +### Requirement: Hold History page SHALL display a Daily Trend chart +The page SHALL display a mixed line+bar chart showing daily hold stock and flow. + +#### Scenario: Daily Trend chart rendering +- **WHEN** trend data is loaded +- **THEN** an ECharts mixed chart SHALL display with dual Y-axes +- **THEN** the left Y-axis SHALL show flow quantities (Release, New Hold, Future Hold) +- **THEN** the right Y-axis SHALL show HOLDQTY stock level +- **THEN** the X-axis SHALL show dates within the selected range + +#### Scenario: Bar direction encoding +- **WHEN** daily trend bars are rendered +- **THEN** Release bars SHALL extend upward (positive direction, green color) +- **THEN** New Hold bars SHALL extend downward (negative direction, red color) +- **THEN** Future Hold bars SHALL extend downward (negative direction, orange color, stacked with New Hold) +- **THEN** HOLDQTY SHALL display as a line on the right Y-axis + +#### Scenario: Hold Type switching without re-call +- **WHEN** user changes the Hold Type radio on the filter bar +- **THEN** if the date range has not changed, the trend chart SHALL update from locally cached data +- **THEN** no additional API call SHALL be made for the trend endpoint + +#### Scenario: Daily Trend reflects filter bar only +- **WHEN** user clicks a Reason Pareto block +- **THEN** the Daily Trend chart SHALL NOT change (it only responds to filter bar changes) + +### Requirement: Hold History page SHALL display a Reason Pareto chart +The page SHALL display a Pareto chart showing hold reason distribution. + +#### Scenario: Reason Pareto rendering +- **WHEN** reason-pareto data is loaded +- **THEN** a Pareto chart SHALL display with bars (count per reason) and a cumulative percentage line +- **THEN** reasons SHALL be sorted by count descending +- **THEN** the cumulative line SHALL reach 100% at the rightmost bar + +#### Scenario: Reason Pareto click filters downstream +- **WHEN** user clicks a reason bar in the Pareto chart +- **THEN** `reasonFilter` SHALL be set to the clicked reason name +- **THEN** Department table SHALL reload filtered by that reason +- **THEN** Detail table SHALL reload filtered by that reason +- **THEN** the clicked bar SHALL show a visual highlight + +#### Scenario: Reason Pareto click toggle +- **WHEN** user clicks the same reason bar that is already active +- **THEN** `reasonFilter` SHALL be cleared +- **THEN** Department table and Detail table SHALL reload without reason filter + +#### Scenario: Reason Pareto reflects filter bar only +- **WHEN** user clicks a reason bar +- **THEN** Summary KPIs, Daily Trend, and Duration chart SHALL NOT change + +### Requirement: Hold History page SHALL display Hold Duration distribution +The page SHALL display a horizontal bar chart showing hold duration distribution. + +#### Scenario: Duration chart rendering +- **WHEN** duration data is loaded +- **THEN** a horizontal bar chart SHALL display with 4 buckets: <4h, 4-24h, 1-3天, >3天 +- **THEN** each bar SHALL show count and percentage +- **THEN** only released holds (RELEASETXNDATE IS NOT NULL) SHALL be included + +#### Scenario: Duration reflects filter bar only +- **WHEN** user clicks a Reason Pareto block +- **THEN** the Duration chart SHALL NOT change (it only responds to filter bar changes) + +### Requirement: Hold History page SHALL display Department statistics with expandable rows +The page SHALL display a table showing hold/release statistics per department, expandable to show individual persons. + +#### Scenario: Department table rendering +- **WHEN** department data is loaded +- **THEN** a table SHALL display with columns: 部門, Hold 次數, Release 次數, 平均 Hold 時長(hr) +- **THEN** departments SHALL be sorted by Hold 次數 descending +- **THEN** each department row SHALL have an expand toggle + +#### Scenario: Department row expansion +- **WHEN** user clicks the expand toggle on a department row +- **THEN** individual person rows SHALL display below the department row +- **THEN** person rows SHALL show: 人員名稱, Hold 次數, Release 次數, 平均 Hold 時長(hr) + +#### Scenario: Department table responds to reason filter +- **WHEN** a Reason Pareto filter is active +- **THEN** department data SHALL reload filtered by the selected reason +- **THEN** only holds matching the reason SHALL be included in statistics + +### Requirement: Hold History page SHALL display paginated Hold/Release detail list +The page SHALL display a detailed list of individual hold/release records with server-side pagination. + +#### Scenario: Detail table columns +- **WHEN** detail data is loaded +- **THEN** a table SHALL display with columns: Lot ID, WorkOrder, 站別, Hold Reason, Hold 時間, Hold 人員, Hold Comment, Release 時間, Release 人員, Release Comment, 時長(hr), NCR + +#### Scenario: Unreleased hold display +- **WHEN** a hold record has RELEASETXNDATE IS NULL +- **THEN** the Release 時間 column SHALL display "仍在 Hold" +- **THEN** the 時長 column SHALL display the duration from HOLDTXNDATE to current time + +#### Scenario: Detail table pagination +- **WHEN** total records exceed per_page (50) +- **THEN** Prev/Next buttons and page info SHALL display +- **THEN** page info SHALL show "顯示 {start} - {end} / {total}" + +#### Scenario: Detail table responds to reason filter +- **WHEN** a Reason Pareto filter is active +- **THEN** detail data SHALL reload filtered by the selected reason +- **THEN** pagination SHALL reset to page 1 + +#### Scenario: Filter changes reset pagination +- **WHEN** any filter changes (filter bar or Reason Pareto click) +- **THEN** pagination SHALL reset to page 1 + +### Requirement: Hold History page SHALL display active filter indicator +The page SHALL show a clear indicator when a Reason Pareto filter is active. + +#### Scenario: Reason filter indicator +- **WHEN** a reason filter is active +- **THEN** a filter indicator SHALL display above the Department table section +- **THEN** the indicator SHALL show the active reason name +- **THEN** a clear button (✕) SHALL remove the reason filter + +### Requirement: Hold History 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 + +### Requirement: Hold History page SHALL have navigation links +The page SHALL provide navigation to related pages. + +#### Scenario: Back to Hold Overview +- **WHEN** user clicks the "← Hold Overview" button in the header +- **THEN** the page SHALL navigate to `/hold-overview` diff --git a/openspec/changes/archive/2026-02-10-hold-history-dashboard/specs/vue-vite-page-architecture/spec.md b/openspec/changes/archive/2026-02-10-hold-history-dashboard/specs/vue-vite-page-architecture/spec.md new file mode 100644 index 0000000..a902eaa --- /dev/null +++ b/openspec/changes/archive/2026-02-10-hold-history-dashboard/specs/vue-vite-page-architecture/spec.md @@ -0,0 +1,45 @@ +## 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, BarChart, LineChart) 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: Hold History entry point +- **WHEN** the hold-history page is added +- **THEN** `vite.config.js` input SHALL include `'hold-history': resolve(__dirname, 'src/hold-history/index.html')` +- **THEN** the build SHALL produce `hold-history.html`, `hold-history.js`, and `hold-history.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-history` 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-history-dashboard/tasks.md b/openspec/changes/archive/2026-02-10-hold-history-dashboard/tasks.md new file mode 100644 index 0000000..0bb2c4d --- /dev/null +++ b/openspec/changes/archive/2026-02-10-hold-history-dashboard/tasks.md @@ -0,0 +1,66 @@ +## 1. SQL 檔案建立 + +- [x] 1.1 建立 `src/mes_dashboard/sql/hold_history/` 目錄 +- [x] 1.2 建立 `trend.sql` — calendar-spine cross-join 每日聚合查詢(翻譯 hold_history.md 邏輯,含 07:30 班別邊界、同日去重、Future Hold 去重、品質分類) +- [x] 1.3 建立 `reason_pareto.sql` — GROUP BY HOLDREASONNAME,含 count/qty/pct/cumPct 計算 +- [x] 1.4 建立 `duration.sql` — 已 release hold 的時長分布(4 bucket: <4h, 4-24h, 1-3d, >3d) +- [x] 1.5 建立 `department.sql` — GROUP BY HOLDEMPDEPTNAME / HOLDEMP,含 hold/release 計數及平均時長 +- [x] 1.6 建立 `list.sql` — paginated 明細查詢(含 HOLDCOMMENTS/RELEASECOMMENTS,未 release 用 SYSDATE 計算時長) + +## 2. 後端服務層 + +- [x] 2.1 建立 `src/mes_dashboard/services/hold_history_service.py`,實作 SQL 載入輔助函式(從 sql/hold_history/ 讀取 .sql 檔案) +- [x] 2.2 實作 `get_hold_history_trend(start_date, end_date)` — 執行 trend.sql,回傳三種 hold_type 的每日聚合資料 +- [x] 2.3 實作 trend Redis 快取邏輯 — 近二月快取(key: `hold_history:daily:{YYYY-MM}`,TTL 12hr),跨月查詢拼接,超過二月直接 Oracle +- [x] 2.4 實作 `get_hold_history_reason_pareto(start_date, end_date, hold_type)` — 執行 reason_pareto.sql +- [x] 2.5 實作 `get_hold_history_duration(start_date, end_date, hold_type)` — 執行 duration.sql +- [x] 2.6 實作 `get_hold_history_department(start_date, end_date, hold_type, reason=None)` — 執行 department.sql,回傳部門層級含 persons 陣列 +- [x] 2.7 實作 `get_hold_history_list(start_date, end_date, hold_type, reason=None, page=1, per_page=50)` — 執行 list.sql,回傳 paginated 結果 + +## 3. 後端路由層 + +- [x] 3.1 建立 `src/mes_dashboard/routes/hold_history_routes.py` Flask Blueprint +- [x] 3.2 實作 `GET /hold-history` 頁面路由 — send_from_directory / fallback HTML +- [x] 3.3 實作 `GET /api/hold-history/trend` — 呼叫 service,rate limit 60/60s +- [x] 3.4 實作 `GET /api/hold-history/reason-pareto` — 呼叫 service +- [x] 3.5 實作 `GET /api/hold-history/duration` — 呼叫 service +- [x] 3.6 實作 `GET /api/hold-history/department` — 呼叫 service,含 optional reason 參數 +- [x] 3.7 實作 `GET /api/hold-history/list` — 呼叫 service,rate limit 90/60s,含 optional reason 參數 +- [x] 3.8 在 `routes/__init__.py` 註冊 hold_history_bp Blueprint + +## 4. 頁面註冊與 Vite 配置 + +- [x] 4.1 在 `data/page_status.json` 新增 `/hold-history` 頁面(status: dev, drawer: reports) +- [x] 4.2 在 `frontend/vite.config.js` 新增 `'hold-history': resolve(__dirname, 'src/hold-history/index.html')` entry point + +## 5. 前端頁面骨架 + +- [x] 5.1 建立 `frontend/src/hold-history/` 目錄結構(index.html, main.js, App.vue, style.css) +- [x] 5.2 實作 `App.vue` — 頁面主容器、狀態管理(filterBar, reasonFilter, pagination)、API 呼叫流程、cascade filter 邏輯 +- [x] 5.3 實作 `FilterBar.vue` — DatePicker(預設當月)+ Hold Type radio(品質異常/非品質異常/全部) + +## 6. 前端元件 — KPI 與趨勢圖 + +- [x] 6.1 實作 `SummaryCards.vue` — 6 張 KPI 卡片(Release, New Hold, Future Hold, 淨變動, 期末 On Hold, 平均時長),Release 綠色正向、New/Future 紅/橙負向 +- [x] 6.2 實作 `DailyTrend.vue` — ECharts 折線+柱狀混合圖,左 Y 軸增減量(Release↑綠, New↓紅, Future↓橙 stacked),右 Y 軸 On Hold 折線 + +## 7. 前端元件 — 分析圖表 + +- [x] 7.1 實作 `ReasonPareto.vue` — ECharts Pareto 圖(柱狀 count + 累積%折線),可點擊觸發 reasonFilter toggle +- [x] 7.2 實作 `DurationChart.vue` — ECharts 橫向柱狀圖(<4h, 4-24h, 1-3天, >3天),顯示 count 和百分比 + +## 8. 前端元件 — 表格 + +- [x] 8.1 實作 `FilterIndicator.vue` — 顯示 active reason filter 及清除按鈕 +- [x] 8.2 實作 `DepartmentTable.vue` — 部門統計表,可展開看個人層級,受 reasonFilter 篩選 +- [x] 8.3 實作 `DetailTable.vue` — paginated 明細表(12 欄位),未 release 顯示 "仍在 Hold",受 reasonFilter 篩選 + +## 9. 後端測試 + +- [x] 9.1 建立 `tests/test_hold_history_routes.py` — 測試頁面路由(含 admin session)、5 支 API endpoint 參數傳遞、rate limiting、error handling +- [x] 9.2 建立 `tests/test_hold_history_service.py` — 測試 trend 快取邏輯(cache hit/miss/cross-month)、各 service function 的 Oracle 查詢與回傳格式、hold_type 分類、shift boundary 邏輯 + +## 10. 整合驗證 + +- [x] 10.1 執行既有測試確認無回歸(test_hold_overview_routes, test_wip_service, test_page_registry) +- [x] 10.2 驗證 vite build 成功產出 hold-history.html/js/css 且不影響既有 entry points diff --git a/openspec/specs/hold-history-api/spec.md b/openspec/specs/hold-history-api/spec.md new file mode 100644 index 0000000..f723cae --- /dev/null +++ b/openspec/specs/hold-history-api/spec.md @@ -0,0 +1,172 @@ +## ADDED Requirements + +### Requirement: Hold History API SHALL provide daily trend data with Redis caching +The API SHALL return daily aggregated hold/release metrics for the selected date range. + +#### Scenario: Trend endpoint returns all three hold types +- **WHEN** `GET /api/hold-history/trend?start_date=2025-01-01&end_date=2025-01-31` is called +- **THEN** the response SHALL return `{ success: true, data: { days: [...] } }` +- **THEN** each day item SHALL contain `{ date, quality: { holdQty, newHoldQty, releaseQty, futureHoldQty }, non_quality: { ... }, all: { ... } }` +- **THEN** all three hold_type variants SHALL be included in a single response + +#### Scenario: Trend uses shift boundary at 07:30 +- **WHEN** daily aggregation is calculated +- **THEN** transactions with time >= 07:30 SHALL be attributed to the next calendar day +- **THEN** transactions with time < 07:30 SHALL be attributed to the current calendar day + +#### Scenario: Trend deduplicates same-day multiple holds +- **WHEN** a lot is held multiple times on the same day +- **THEN** only one hold event SHALL be counted for that day (using ROW_NUMBER per CONTAINERID per day) + +#### Scenario: Trend deduplicates future holds +- **WHEN** the same lot has multiple future holds for the same reason +- **THEN** only the first occurrence SHALL be counted (using ROW_NUMBER per CONTAINERID per HOLDREASONID) + +#### Scenario: Trend hold type classification +- **WHEN** trend data is aggregated by hold type +- **THEN** quality classification SHALL use the same NON_QUALITY_HOLD_REASONS set as existing hold endpoints +- **THEN** holds with HOLDREASONNAME NOT in NON_QUALITY_HOLD_REASONS SHALL be classified as quality +- **THEN** the "all" variant SHALL include both quality and non-quality holds + +#### Scenario: Trend Redis cache for recent two months +- **WHEN** the requested date range falls within the current month or previous month +- **THEN** the service SHALL check Redis for cached data at key `hold_history:daily:{YYYY-MM}` +- **THEN** if cache exists, data SHALL be returned from Redis +- **THEN** if cache is missing, data SHALL be queried from Oracle and stored in Redis with 12-hour TTL + +#### Scenario: Trend direct Oracle query for older data +- **WHEN** the requested date range includes months older than the previous month +- **THEN** the service SHALL query Oracle directly without caching + +#### Scenario: Trend cross-month query assembly +- **WHEN** the requested date range spans multiple months (e.g., 2025-01-15 to 2025-02-15) +- **THEN** the service SHALL fetch each month's data independently (from cache or Oracle) +- **THEN** the service SHALL trim the combined result to the exact requested date range +- **THEN** the response SHALL contain only days within start_date and end_date inclusive + +#### Scenario: Trend error +- **WHEN** the database query fails +- **THEN** the response SHALL return `{ success: false, error: '查詢失敗' }` with HTTP 500 + +### Requirement: Hold History API SHALL provide reason Pareto data +The API SHALL return hold reason distribution for Pareto analysis. + +#### Scenario: Reason Pareto endpoint +- **WHEN** `GET /api/hold-history/reason-pareto?start_date=2025-01-01&end_date=2025-01-31&hold_type=quality` is called +- **THEN** the response SHALL return `{ success: true, data: { items: [...] } }` +- **THEN** each item SHALL contain `{ reason, count, qty, pct, cumPct }` +- **THEN** items SHALL be sorted by count descending +- **THEN** pct SHALL be percentage of total hold events +- **THEN** cumPct SHALL be running cumulative percentage + +#### Scenario: Reason Pareto uses shift boundary +- **WHEN** hold events are counted for Pareto +- **THEN** the 07:30 shift boundary rule SHALL be applied to HOLDTXNDATE + +#### Scenario: Reason Pareto hold type filter +- **WHEN** hold_type is "quality" +- **THEN** only quality hold reasons SHALL be included +- **WHEN** hold_type is "non-quality" +- **THEN** only non-quality hold reasons SHALL be included +- **WHEN** hold_type is "all" +- **THEN** all hold reasons SHALL be included + +### Requirement: Hold History API SHALL provide hold duration distribution +The API SHALL return hold duration distribution buckets. + +#### Scenario: Duration endpoint +- **WHEN** `GET /api/hold-history/duration?start_date=2025-01-01&end_date=2025-01-31&hold_type=quality` is called +- **THEN** the response SHALL return `{ success: true, data: { items: [...] } }` +- **THEN** items SHALL contain 4 buckets: `{ range: "<4h", count, pct }`, `{ range: "4-24h", count, pct }`, `{ range: "1-3d", count, pct }`, `{ range: ">3d", count, pct }` + +#### Scenario: Duration only includes released holds +- **WHEN** duration is calculated +- **THEN** only hold records with RELEASETXNDATE IS NOT NULL SHALL be included +- **THEN** duration SHALL be calculated as RELEASETXNDATE - HOLDTXNDATE + +#### Scenario: Duration date range filter +- **WHEN** start_date and end_date are provided +- **THEN** only holds with HOLDTXNDATE within the date range (applying 07:30 shift boundary) SHALL be included + +### Requirement: Hold History API SHALL provide department statistics +The API SHALL return hold/release statistics aggregated by department with optional person detail. + +#### Scenario: Department endpoint +- **WHEN** `GET /api/hold-history/department?start_date=2025-01-01&end_date=2025-01-31&hold_type=quality` is called +- **THEN** the response SHALL return `{ success: true, data: { items: [...] } }` +- **THEN** each item SHALL contain `{ dept, holdCount, releaseCount, avgHoldHours, persons: [{ name, holdCount, releaseCount, avgHoldHours }] }` +- **THEN** items SHALL be sorted by holdCount descending + +#### Scenario: Department with reason filter +- **WHEN** `GET /api/hold-history/department?start_date=2025-01-01&end_date=2025-01-31&hold_type=quality&reason=品質確認` is called +- **THEN** only hold records matching the specified reason SHALL be included in department and person statistics + +#### Scenario: Department hold count vs release count +- **WHEN** department statistics are calculated +- **THEN** holdCount SHALL count records where HOLDEMPDEPTNAME equals the department AND HOLDTXNDATE is within the date range +- **THEN** releaseCount SHALL count records where RELEASEEMPDEPTNAME equals the department AND RELEASETXNDATE is within the date range +- **THEN** avgHoldHours SHALL be the average of (RELEASETXNDATE - HOLDTXNDATE) in hours for released holds initiated by that department + +### Requirement: Hold History API SHALL provide paginated detail list +The API SHALL return a paginated list of individual hold/release records. + +#### Scenario: List endpoint +- **WHEN** `GET /api/hold-history/list?start_date=2025-01-01&end_date=2025-01-31&hold_type=quality&page=1&per_page=50` is called +- **THEN** the response SHALL return `{ success: true, data: { items: [...], pagination: { page, perPage, total, totalPages } } }` +- **THEN** each item SHALL contain: lotId, workorder, workcenter, holdReason, holdDate, holdEmp, holdComment, releaseDate, releaseEmp, releaseComment, holdHours, ncr +- **THEN** items SHALL be sorted by HOLDTXNDATE descending + +#### Scenario: List with reason filter +- **WHEN** `GET /api/hold-history/list?start_date=2025-01-01&end_date=2025-01-31&hold_type=quality&reason=品質確認` is called +- **THEN** only records matching the specified HOLDREASONNAME SHALL be returned + +#### Scenario: List unreleased hold records +- **WHEN** a hold record has RELEASETXNDATE IS NULL +- **THEN** releaseDate SHALL be null +- **THEN** holdHours SHALL be calculated as (SYSDATE - HOLDTXNDATE) * 24 + +#### Scenario: List 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: List date range uses shift boundary +- **WHEN** records are filtered by date range +- **THEN** the 07:30 shift boundary rule SHALL be applied to HOLDTXNDATE + +### Requirement: Hold History API SHALL use centralized SQL files +The API SHALL load SQL queries from files in the `src/mes_dashboard/sql/hold_history/` directory. + +#### Scenario: SQL file organization +- **WHEN** the hold history service executes a query +- **THEN** the SQL SHALL be loaded from `sql/hold_history/.sql` +- **THEN** the following SQL files SHALL exist: `trend.sql`, `reason_pareto.sql`, `duration.sql`, `department.sql`, `list.sql` + +#### Scenario: SQL parameterization +- **WHEN** SQL queries are executed +- **THEN** all user-provided parameters (dates, hold_type, reason) SHALL be passed as bind parameters +- **THEN** no string interpolation SHALL be used for user input + +### Requirement: Hold History API SHALL apply rate limiting +The API SHALL apply rate limiting to expensive endpoints. + +#### Scenario: Rate limit on list endpoint +- **WHEN** the list 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 trend endpoint +- **WHEN** the trend endpoint receives excessive requests +- **THEN** rate limiting SHALL be applied using `configured_rate_limit` with a default of 60 requests per 60 seconds + +### Requirement: Hold History 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-history` +- **THEN** Flask SHALL serve the pre-built HTML file from `static/dist/hold-history.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 diff --git a/openspec/specs/hold-history-page/spec.md b/openspec/specs/hold-history-page/spec.md new file mode 100644 index 0000000..78db380 --- /dev/null +++ b/openspec/specs/hold-history-page/spec.md @@ -0,0 +1,172 @@ +## ADDED Requirements + +### Requirement: Hold History page SHALL display a filter bar with date range and hold type +The page SHALL provide a filter bar for selecting date range and hold type classification. + +#### Scenario: Default date range +- **WHEN** the page loads +- **THEN** the date range SHALL default to the first and last day of the current month + +#### Scenario: Hold Type radio default +- **WHEN** the page loads +- **THEN** the Hold Type filter SHALL default to "品質異常" +- **THEN** three radio options SHALL display: 品質異常, 非品質異常, 全部 + +#### Scenario: Filter bar change reloads all data +- **WHEN** user changes the date range or Hold Type selection +- **THEN** all API calls (trend, reason-pareto, duration, department, list) SHALL reload with the new parameters +- **THEN** any active Reason Pareto filter SHALL be cleared +- **THEN** pagination SHALL reset to page 1 + +### Requirement: Hold History page SHALL display summary KPI cards +The page SHALL show 6 summary KPI cards derived from the trend data for the selected period. + +#### Scenario: Summary cards rendering +- **WHEN** trend data is loaded +- **THEN** six cards SHALL display: Release 數量, New Hold 數量, Future Hold 數量, 淨變動, 期末 On Hold, 平均 Hold 時長 +- **THEN** Release SHALL be displayed as a positive indicator (green) +- **THEN** New Hold and Future Hold SHALL be displayed as negative indicators (red/orange) +- **THEN** 淨變動 SHALL equal Release - New Hold - Future Hold +- **THEN** 期末 On Hold SHALL be the HOLDQTY of the last day in the selected range +- **THEN** number values SHALL use zh-TW number formatting + +#### Scenario: Summary reflects filter bar only +- **WHEN** user clicks a Reason Pareto block +- **THEN** summary cards SHALL NOT change (they only respond to filter bar changes) + +### Requirement: Hold History page SHALL display a Daily Trend chart +The page SHALL display a mixed line+bar chart showing daily hold stock and flow. + +#### Scenario: Daily Trend chart rendering +- **WHEN** trend data is loaded +- **THEN** an ECharts mixed chart SHALL display with dual Y-axes +- **THEN** the left Y-axis SHALL show flow quantities (Release, New Hold, Future Hold) +- **THEN** the right Y-axis SHALL show HOLDQTY stock level +- **THEN** the X-axis SHALL show dates within the selected range + +#### Scenario: Bar direction encoding +- **WHEN** daily trend bars are rendered +- **THEN** Release bars SHALL extend upward (positive direction, green color) +- **THEN** New Hold bars SHALL extend downward (negative direction, red color) +- **THEN** Future Hold bars SHALL extend downward (negative direction, orange color, stacked with New Hold) +- **THEN** HOLDQTY SHALL display as a line on the right Y-axis + +#### Scenario: Hold Type switching without re-call +- **WHEN** user changes the Hold Type radio on the filter bar +- **THEN** if the date range has not changed, the trend chart SHALL update from locally cached data +- **THEN** no additional API call SHALL be made for the trend endpoint + +#### Scenario: Daily Trend reflects filter bar only +- **WHEN** user clicks a Reason Pareto block +- **THEN** the Daily Trend chart SHALL NOT change (it only responds to filter bar changes) + +### Requirement: Hold History page SHALL display a Reason Pareto chart +The page SHALL display a Pareto chart showing hold reason distribution. + +#### Scenario: Reason Pareto rendering +- **WHEN** reason-pareto data is loaded +- **THEN** a Pareto chart SHALL display with bars (count per reason) and a cumulative percentage line +- **THEN** reasons SHALL be sorted by count descending +- **THEN** the cumulative line SHALL reach 100% at the rightmost bar + +#### Scenario: Reason Pareto click filters downstream +- **WHEN** user clicks a reason bar in the Pareto chart +- **THEN** `reasonFilter` SHALL be set to the clicked reason name +- **THEN** Department table SHALL reload filtered by that reason +- **THEN** Detail table SHALL reload filtered by that reason +- **THEN** the clicked bar SHALL show a visual highlight + +#### Scenario: Reason Pareto click toggle +- **WHEN** user clicks the same reason bar that is already active +- **THEN** `reasonFilter` SHALL be cleared +- **THEN** Department table and Detail table SHALL reload without reason filter + +#### Scenario: Reason Pareto reflects filter bar only +- **WHEN** user clicks a reason bar +- **THEN** Summary KPIs, Daily Trend, and Duration chart SHALL NOT change + +### Requirement: Hold History page SHALL display Hold Duration distribution +The page SHALL display a horizontal bar chart showing hold duration distribution. + +#### Scenario: Duration chart rendering +- **WHEN** duration data is loaded +- **THEN** a horizontal bar chart SHALL display with 4 buckets: <4h, 4-24h, 1-3天, >3天 +- **THEN** each bar SHALL show count and percentage +- **THEN** only released holds (RELEASETXNDATE IS NOT NULL) SHALL be included + +#### Scenario: Duration reflects filter bar only +- **WHEN** user clicks a Reason Pareto block +- **THEN** the Duration chart SHALL NOT change (it only responds to filter bar changes) + +### Requirement: Hold History page SHALL display Department statistics with expandable rows +The page SHALL display a table showing hold/release statistics per department, expandable to show individual persons. + +#### Scenario: Department table rendering +- **WHEN** department data is loaded +- **THEN** a table SHALL display with columns: 部門, Hold 次數, Release 次數, 平均 Hold 時長(hr) +- **THEN** departments SHALL be sorted by Hold 次數 descending +- **THEN** each department row SHALL have an expand toggle + +#### Scenario: Department row expansion +- **WHEN** user clicks the expand toggle on a department row +- **THEN** individual person rows SHALL display below the department row +- **THEN** person rows SHALL show: 人員名稱, Hold 次數, Release 次數, 平均 Hold 時長(hr) + +#### Scenario: Department table responds to reason filter +- **WHEN** a Reason Pareto filter is active +- **THEN** department data SHALL reload filtered by the selected reason +- **THEN** only holds matching the reason SHALL be included in statistics + +### Requirement: Hold History page SHALL display paginated Hold/Release detail list +The page SHALL display a detailed list of individual hold/release records with server-side pagination. + +#### Scenario: Detail table columns +- **WHEN** detail data is loaded +- **THEN** a table SHALL display with columns: Lot ID, WorkOrder, 站別, Hold Reason, Hold 時間, Hold 人員, Hold Comment, Release 時間, Release 人員, Release Comment, 時長(hr), NCR + +#### Scenario: Unreleased hold display +- **WHEN** a hold record has RELEASETXNDATE IS NULL +- **THEN** the Release 時間 column SHALL display "仍在 Hold" +- **THEN** the 時長 column SHALL display the duration from HOLDTXNDATE to current time + +#### Scenario: Detail table pagination +- **WHEN** total records exceed per_page (50) +- **THEN** Prev/Next buttons and page info SHALL display +- **THEN** page info SHALL show "顯示 {start} - {end} / {total}" + +#### Scenario: Detail table responds to reason filter +- **WHEN** a Reason Pareto filter is active +- **THEN** detail data SHALL reload filtered by the selected reason +- **THEN** pagination SHALL reset to page 1 + +#### Scenario: Filter changes reset pagination +- **WHEN** any filter changes (filter bar or Reason Pareto click) +- **THEN** pagination SHALL reset to page 1 + +### Requirement: Hold History page SHALL display active filter indicator +The page SHALL show a clear indicator when a Reason Pareto filter is active. + +#### Scenario: Reason filter indicator +- **WHEN** a reason filter is active +- **THEN** a filter indicator SHALL display above the Department table section +- **THEN** the indicator SHALL show the active reason name +- **THEN** a clear button (✕) SHALL remove the reason filter + +### Requirement: Hold History 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 + +### Requirement: Hold History page SHALL have navigation links +The page SHALL provide navigation to related pages. + +#### Scenario: Back to Hold Overview +- **WHEN** user clicks the "← Hold Overview" button in the header +- **THEN** the page SHALL navigate to `/hold-overview` diff --git a/openspec/specs/vue-vite-page-architecture/spec.md b/openspec/specs/vue-vite-page-architecture/spec.md index 3f4594f..e910490 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 (including TreemapChart) SHALL be split into the existing `vendor-echarts` chunk +- **THEN** ECharts modules (including TreemapChart, BarChart, LineChart) SHALL be split into the existing `vendor-echarts` chunk - **THEN** chunk splitting SHALL NOT affect existing page bundles #### Scenario: Migrated page entry replacement @@ -46,13 +46,18 @@ The Vite build configuration SHALL support Vue Single File Components alongside - **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: Hold History entry point +- **WHEN** the hold-history page is added +- **THEN** `vite.config.js` input SHALL include `'hold-history': resolve(__dirname, 'src/hold-history/index.html')` +- **THEN** the build SHALL produce `hold-history.html`, `hold-history.js`, and `hold-history.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/`) +- **WHEN** a migrated page imports a composable from another shared module (e.g., `hold-history` 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 77ebf95..faf336c 100644 --- a/src/mes_dashboard/routes/__init__.py +++ b/src/mes_dashboard/routes/__init__.py @@ -10,6 +10,7 @@ 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 .hold_history_routes import hold_history_bp from .auth_routes import auth_bp from .admin_routes import admin_bp from .resource_history_routes import resource_history_bp @@ -28,6 +29,7 @@ def register_routes(app) -> None: app.register_blueprint(excel_query_bp) app.register_blueprint(hold_bp) app.register_blueprint(hold_overview_bp) + app.register_blueprint(hold_history_bp) app.register_blueprint(resource_history_bp) app.register_blueprint(job_query_bp) app.register_blueprint(query_tool_bp) @@ -42,6 +44,7 @@ __all__ = [ 'excel_query_bp', 'hold_bp', 'hold_overview_bp', + 'hold_history_bp', 'auth_bp', 'admin_bp', 'resource_history_bp', diff --git a/src/mes_dashboard/routes/hold_history_routes.py b/src/mes_dashboard/routes/hold_history_routes.py new file mode 100644 index 0000000..9876602 --- /dev/null +++ b/src/mes_dashboard/routes/hold_history_routes.py @@ -0,0 +1,212 @@ +# -*- coding: utf-8 -*- +"""Hold History page route and API endpoints.""" + +from __future__ import annotations + +import os +from datetime import datetime +from typing import Optional, Tuple + +from flask import Blueprint, current_app, jsonify, request, send_from_directory + +from mes_dashboard.core.rate_limit import configured_rate_limit +from mes_dashboard.services.hold_history_service import ( + get_hold_history_duration, + get_hold_history_list, + get_hold_history_reason_pareto, + get_hold_history_trend, + get_still_on_hold_count, +) + +hold_history_bp = Blueprint('hold_history', __name__) + +_HOLD_HISTORY_TREND_RATE_LIMIT = configured_rate_limit( + bucket='hold-history-trend', + max_attempts_env='HOLD_HISTORY_TREND_RATE_LIMIT_MAX_REQUESTS', + window_seconds_env='HOLD_HISTORY_TREND_RATE_LIMIT_WINDOW_SECONDS', + default_max_attempts=60, + default_window_seconds=60, +) + +_HOLD_HISTORY_LIST_RATE_LIMIT = configured_rate_limit( + bucket='hold-history-list', + max_attempts_env='HOLD_HISTORY_LIST_RATE_LIMIT_MAX_REQUESTS', + window_seconds_env='HOLD_HISTORY_LIST_RATE_LIMIT_WINDOW_SECONDS', + default_max_attempts=90, + default_window_seconds=60, +) + +_VALID_HOLD_TYPES = {'quality', 'non-quality', 'all'} +_VALID_RECORD_TYPES = {'new', 'on_hold', 'released'} +_VALID_DURATION_RANGES = {'<4h', '4-24h', '1-3d', '>3d'} + + +def _parse_date_range() -> tuple[Optional[str], Optional[str], Optional[tuple[dict, int]]]: + start_date = request.args.get('start_date', '').strip() + end_date = request.args.get('end_date', '').strip() + + if not start_date or not end_date: + return None, None, ({'success': False, 'error': '缺少必要參數: start_date, end_date'}, 400) + + try: + start = datetime.strptime(start_date, '%Y-%m-%d').date() + end = datetime.strptime(end_date, '%Y-%m-%d').date() + except ValueError: + return None, None, ({'success': False, 'error': '日期格式錯誤,請使用 YYYY-MM-DD'}, 400) + + if end < start: + return None, None, ({'success': False, 'error': 'end_date 不可早於 start_date'}, 400) + + return start_date, end_date, None + + +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, + ) + return hold_type, None + + +def _parse_record_type(default: str = 'new') -> tuple[Optional[str], Optional[tuple[dict, int]]]: + raw = request.args.get('record_type', '').strip().lower() + record_type = raw or default + parts = [p.strip() for p in record_type.split(',') if p.strip()] + if not parts: + parts = [default] + for part in parts: + if part not in _VALID_RECORD_TYPES: + return None, ( + {'success': False, 'error': 'Invalid record_type. Use new, on_hold, or released'}, + 400, + ) + return ','.join(parts), None + + +@hold_history_bp.route('/hold-history') +def hold_history_page(): + """Render Hold History page from static Vite output.""" + dist_dir = os.path.join(current_app.static_folder or '', 'dist') + dist_html = os.path.join(dist_dir, 'hold-history.html') + if os.path.exists(dist_html): + return send_from_directory(dist_dir, 'hold-history.html') + + return ( + '' + '' + 'Hold History' + '' + '
', + 200, + ) + + +@hold_history_bp.route('/api/hold-history/trend') +@_HOLD_HISTORY_TREND_RATE_LIMIT +def api_hold_history_trend(): + """Return daily hold history trend data.""" + start_date, end_date, date_error = _parse_date_range() + if date_error: + return jsonify(date_error[0]), date_error[1] + + result = get_hold_history_trend(start_date, end_date) + if result is not None: + count = get_still_on_hold_count() + if count is not None: + result['stillOnHoldCount'] = count + return jsonify({'success': True, 'data': result}) + return jsonify({'success': False, 'error': '查詢失敗'}), 500 + + +@hold_history_bp.route('/api/hold-history/reason-pareto') +def api_hold_history_reason_pareto(): + """Return hold reason Pareto data.""" + start_date, end_date, date_error = _parse_date_range() + if date_error: + return jsonify(date_error[0]), date_error[1] + + hold_type, hold_type_error = _parse_hold_type(default='quality') + if hold_type_error: + return jsonify(hold_type_error[0]), hold_type_error[1] + + record_type, record_type_error = _parse_record_type() + if record_type_error: + return jsonify(record_type_error[0]), record_type_error[1] + + result = get_hold_history_reason_pareto(start_date, end_date, hold_type, record_type) + if result is not None: + return jsonify({'success': True, 'data': result}) + return jsonify({'success': False, 'error': '查詢失敗'}), 500 + + +@hold_history_bp.route('/api/hold-history/duration') +def api_hold_history_duration(): + """Return hold duration distribution data.""" + start_date, end_date, date_error = _parse_date_range() + if date_error: + return jsonify(date_error[0]), date_error[1] + + hold_type, hold_type_error = _parse_hold_type(default='quality') + if hold_type_error: + return jsonify(hold_type_error[0]), hold_type_error[1] + + record_type, record_type_error = _parse_record_type() + if record_type_error: + return jsonify(record_type_error[0]), record_type_error[1] + + result = get_hold_history_duration(start_date, end_date, hold_type, record_type) + if result is not None: + return jsonify({'success': True, 'data': result}) + return jsonify({'success': False, 'error': '查詢失敗'}), 500 + + +@hold_history_bp.route('/api/hold-history/list') +@_HOLD_HISTORY_LIST_RATE_LIMIT +def api_hold_history_list(): + """Return paginated hold detail list.""" + start_date, end_date, date_error = _parse_date_range() + if date_error: + return jsonify(date_error[0]), date_error[1] + + hold_type, hold_type_error = _parse_hold_type(default='quality') + if hold_type_error: + return jsonify(hold_type_error[0]), hold_type_error[1] + + record_type, record_type_error = _parse_record_type() + if record_type_error: + return jsonify(record_type_error[0]), record_type_error[1] + + reason = request.args.get('reason', '').strip() or None + + raw_duration = request.args.get('duration_range', '').strip() or None + if raw_duration and raw_duration not in _VALID_DURATION_RANGES: + return jsonify({'success': False, 'error': 'Invalid duration_range'}), 400 + duration_range = raw_duration + + page = request.args.get('page', 1, type=int) + per_page = request.args.get('per_page', 50, type=int) + + 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_history_list( + start_date=start_date, + end_date=end_date, + hold_type=hold_type, + reason=reason, + record_type=record_type, + duration_range=duration_range, + page=page, + per_page=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/hold_history_service.py b/src/mes_dashboard/services/hold_history_service.py new file mode 100644 index 0000000..311e0f2 --- /dev/null +++ b/src/mes_dashboard/services/hold_history_service.py @@ -0,0 +1,507 @@ +# -*- coding: utf-8 -*- +"""Hold History dashboard service layer.""" + +from __future__ import annotations + +import json +import logging +from datetime import date, datetime, timedelta +from functools import lru_cache +from pathlib import Path +from typing import Any, Dict, Iterator, Optional + +import pandas as pd + +from mes_dashboard.core.database import ( + DatabaseCircuitOpenError, + DatabasePoolExhaustedError, + read_sql_df, +) +from mes_dashboard.core.redis_client import get_key, get_redis_client +from mes_dashboard.services.filter_cache import get_workcenter_group as _get_wc_group +from mes_dashboard.sql.filters import CommonFilters + +logger = logging.getLogger('mes_dashboard.hold_history_service') + +_SQL_DIR = Path(__file__).resolve().parent.parent / 'sql' / 'hold_history' +_VALID_HOLD_TYPES = {'quality', 'non-quality', 'all'} +_TREND_CACHE_TTL_SECONDS = 12 * 60 * 60 +_TREND_CACHE_KEY_PREFIX = 'hold_history:daily' + + +@lru_cache(maxsize=16) +def _load_hold_history_sql(name: str) -> str: + """Load hold history SQL by file name without extension.""" + path = _SQL_DIR / f'{name}.sql' + if not path.exists(): + raise FileNotFoundError(f'SQL file not found: {path}') + + sql = path.read_text(encoding='utf-8') + if '{{ NON_QUALITY_REASONS }}' in sql: + sql = sql.replace('{{ NON_QUALITY_REASONS }}', CommonFilters.get_non_quality_reasons_sql()) + return sql + + +def _parse_iso_date(value: str) -> date: + return datetime.strptime(str(value), '%Y-%m-%d').date() + + +def _format_iso_date(value: date) -> str: + return value.strftime('%Y-%m-%d') + + +def _iter_days(start: date, end: date) -> Iterator[date]: + current = start + while current <= end: + yield current + current += timedelta(days=1) + + +def _iter_month_starts(start: date, end: date) -> Iterator[date]: + current = start.replace(day=1) + while current <= end: + yield current + current = (current.replace(day=28) + timedelta(days=4)).replace(day=1) + + +def _month_end(month_start: date) -> date: + next_month_start = (month_start.replace(day=28) + timedelta(days=4)).replace(day=1) + return next_month_start - timedelta(days=1) + + +def _is_cacheable_month(month_start: date, today: Optional[date] = None) -> bool: + current = (today or date.today()).replace(day=1) + previous = (current - timedelta(days=1)).replace(day=1) + return month_start in {current, previous} + + +def _trend_cache_key(month_start: date) -> str: + return get_key(f'{_TREND_CACHE_KEY_PREFIX}:{month_start.strftime("%Y-%m")}') + + +def _normalize_hold_type(hold_type: Optional[str], default: str = 'quality') -> str: + normalized = str(hold_type or default).strip().lower() + if normalized not in _VALID_HOLD_TYPES: + return default + return normalized + + +def _record_type_flags(record_type: Any) -> Dict[str, int]: + """Convert record_type value(s) to SQL boolean flags.""" + if isinstance(record_type, (list, tuple, set)): + types = {str(t).strip().lower() for t in record_type} + else: + types = {t.strip().lower() for t in str(record_type or 'new').split(',')} + return { + 'include_new': 1 if 'new' in types else 0, + 'include_on_hold': 1 if 'on_hold' in types else 0, + 'include_released': 1 if 'released' in types else 0, + } + + +def _safe_int(value: Any) -> int: + if value is None: + return 0 + try: + if pd.isna(value): + return 0 + except Exception: + pass + try: + return int(float(value)) + except (TypeError, ValueError): + return 0 + + +def _safe_float(value: Any) -> float: + if value is None: + return 0.0 + try: + if pd.isna(value): + return 0.0 + except Exception: + pass + try: + return float(value) + except (TypeError, ValueError): + return 0.0 + + +def _clean_text(value: Any) -> Optional[str]: + if value is None: + return None + try: + if pd.isna(value): + return None + except Exception: + pass + text = str(value).strip() + return text or None + + +def _format_datetime(value: Any) -> Optional[str]: + if value is None: + return None + try: + if pd.isna(value): + return None + except Exception: + pass + + if isinstance(value, datetime): + dt = value + elif isinstance(value, pd.Timestamp): + dt = value.to_pydatetime() + elif isinstance(value, str): + text = value.strip() + if not text: + return None + try: + dt = pd.to_datetime(text).to_pydatetime() + except Exception: + return text + else: + try: + dt = pd.to_datetime(value).to_pydatetime() + except Exception: + return str(value) + + return dt.strftime('%Y-%m-%d %H:%M:%S') + + +def _empty_trend_metrics() -> Dict[str, int]: + return { + 'holdQty': 0, + 'newHoldQty': 0, + 'releaseQty': 0, + 'futureHoldQty': 0, + } + + +def _empty_trend_day(day: str) -> Dict[str, Any]: + return { + 'date': day, + 'quality': _empty_trend_metrics(), + 'non_quality': _empty_trend_metrics(), + 'all': _empty_trend_metrics(), + } + + +def _normalize_trend_day(payload: Dict[str, Any], fallback_day: Optional[str] = None) -> Dict[str, Any]: + day = str(payload.get('date') or fallback_day or '').strip() + normalized = _empty_trend_day(day) + + for source_key, target_key in ( + ('quality', 'quality'), + ('non_quality', 'non_quality'), + ('non-quality', 'non_quality'), + ('all', 'all'), + ): + section = payload.get(source_key) + if not isinstance(section, dict): + continue + normalized[target_key] = { + 'holdQty': _safe_int(section.get('holdQty')), + 'newHoldQty': _safe_int(section.get('newHoldQty')), + 'releaseQty': _safe_int(section.get('releaseQty')), + 'futureHoldQty': _safe_int(section.get('futureHoldQty')), + } + + return normalized + + +def _build_month_trend_from_df(df: pd.DataFrame) -> list[Dict[str, Any]]: + if df is None or df.empty: + return [] + + day_map: Dict[str, Dict[str, Any]] = {} + + for _, row in df.iterrows(): + day = str(row.get('TXN_DATE') or '').strip() + if not day: + continue + + if day not in day_map: + day_map[day] = _empty_trend_day(day) + + hold_type = str(row.get('HOLD_TYPE') or '').strip().lower() + if hold_type == 'non-quality': + target_key = 'non_quality' + elif hold_type in {'quality', 'all'}: + target_key = hold_type + else: + continue + + day_map[day][target_key] = { + 'holdQty': _safe_int(row.get('HOLD_QTY')), + 'newHoldQty': _safe_int(row.get('NEW_HOLD_QTY')), + 'releaseQty': _safe_int(row.get('RELEASE_QTY')), + 'futureHoldQty': _safe_int(row.get('FUTURE_HOLD_QTY')), + } + + return [day_map[key] for key in sorted(day_map)] + + +def _query_month_trend(month_start: date) -> list[Dict[str, Any]]: + month_end = _month_end(month_start) + sql = _load_hold_history_sql('trend') + params = { + 'start_date': _format_iso_date(month_start), + 'end_date': _format_iso_date(month_end), + } + df = read_sql_df(sql, params) + return _build_month_trend_from_df(df) + + +def _get_month_trend_cache(month_start: date) -> Optional[list[Dict[str, Any]]]: + client = get_redis_client() + if client is None: + return None + + key = _trend_cache_key(month_start) + try: + payload = client.get(key) + if not payload: + return None + decoded = json.loads(payload) + if not isinstance(decoded, list): + return None + + items: list[Dict[str, Any]] = [] + for item in decoded: + if not isinstance(item, dict): + continue + normalized = _normalize_trend_day(item) + if normalized.get('date'): + items.append(normalized) + if not items: + return None + return items + except Exception as exc: + logger.warning('Failed reading hold-history trend cache key %s: %s', key, exc) + return None + + +def _set_month_trend_cache(month_start: date, items: list[Dict[str, Any]]) -> None: + client = get_redis_client() + if client is None: + return + + key = _trend_cache_key(month_start) + try: + client.setex( + key, + _TREND_CACHE_TTL_SECONDS, + json.dumps(items, ensure_ascii=False), + ) + except Exception as exc: + logger.warning('Failed writing hold-history trend cache key %s: %s', key, exc) + + +def _get_month_trend_data(month_start: date) -> list[Dict[str, Any]]: + if _is_cacheable_month(month_start): + cached = _get_month_trend_cache(month_start) + if cached is not None: + return cached + + queried = _query_month_trend(month_start) + _set_month_trend_cache(month_start, queried) + return queried + + return _query_month_trend(month_start) + + +def get_hold_history_trend(start_date: str, end_date: str) -> Optional[Dict[str, Any]]: + """Get daily trend data for all hold-type variants.""" + try: + start = _parse_iso_date(start_date) + end = _parse_iso_date(end_date) + if end < start: + return {'days': []} + + day_map: Dict[str, Dict[str, Any]] = {} + for month_start in _iter_month_starts(start, end): + month_days = _get_month_trend_data(month_start) + for item in month_days: + normalized = _normalize_trend_day(item) + day = normalized.get('date') + if day: + day_map[day] = normalized + + days: list[Dict[str, Any]] = [] + for current in _iter_days(start, end): + current_key = _format_iso_date(current) + days.append(day_map.get(current_key, _empty_trend_day(current_key))) + + return {'days': days} + except (DatabasePoolExhaustedError, DatabaseCircuitOpenError): + raise + except Exception as exc: + logger.error('Hold history trend query failed: %s', exc) + return None + + +def get_still_on_hold_count() -> Optional[Dict[str, int]]: + """Count all holds that are still unreleased (factory-wide), by hold type.""" + try: + sql = _load_hold_history_sql('still_on_hold_count') + df = read_sql_df(sql, {}) + + if df is None or df.empty: + return {'quality': 0, 'non_quality': 0, 'all': 0} + + row = df.iloc[0] + return { + 'quality': _safe_int(row.get('QUALITY_COUNT')), + 'non_quality': _safe_int(row.get('NON_QUALITY_COUNT')), + 'all': _safe_int(row.get('ALL_COUNT')), + } + except (DatabasePoolExhaustedError, DatabaseCircuitOpenError): + raise + except Exception as exc: + logger.error('Hold history still-on-hold count query failed: %s', exc) + return None + + +def get_hold_history_reason_pareto( + start_date: str, + end_date: str, + hold_type: str, + record_type: str = 'new', +) -> Optional[Dict[str, Any]]: + """Get reason Pareto items.""" + try: + sql = _load_hold_history_sql('reason_pareto') + params = { + 'start_date': start_date, + 'end_date': end_date, + 'hold_type': _normalize_hold_type(hold_type), + **_record_type_flags(record_type), + } + df = read_sql_df(sql, params) + + items: list[Dict[str, Any]] = [] + if df is not None and not df.empty: + for _, row in df.iterrows(): + items.append({ + 'reason': _clean_text(row.get('REASON')) or '(未填寫)', + 'count': _safe_int(row.get('ITEM_COUNT')), + 'qty': _safe_int(row.get('QTY')), + 'pct': round(_safe_float(row.get('PCT')), 2), + 'cumPct': round(_safe_float(row.get('CUM_PCT')), 2), + }) + + return {'items': items} + except (DatabasePoolExhaustedError, DatabaseCircuitOpenError): + raise + except Exception as exc: + logger.error('Hold history reason pareto query failed: %s', exc) + return None + + +def get_hold_history_duration( + start_date: str, + end_date: str, + hold_type: str, + record_type: str = 'new', +) -> Optional[Dict[str, Any]]: + """Get hold duration distribution buckets.""" + try: + sql = _load_hold_history_sql('duration') + params = { + 'start_date': start_date, + 'end_date': end_date, + 'hold_type': _normalize_hold_type(hold_type), + **_record_type_flags(record_type), + } + df = read_sql_df(sql, params) + + items: list[Dict[str, Any]] = [] + if df is not None and not df.empty: + for _, row in df.iterrows(): + items.append({ + 'range': _clean_text(row.get('RANGE_LABEL')) or '-', + 'count': _safe_int(row.get('ITEM_COUNT')), + 'qty': _safe_int(row.get('QTY')), + 'pct': round(_safe_float(row.get('PCT')), 2), + }) + + return {'items': items} + except (DatabasePoolExhaustedError, DatabaseCircuitOpenError): + raise + except Exception as exc: + logger.error('Hold history duration query failed: %s', exc) + return None + + +def get_hold_history_list( + start_date: str, + end_date: str, + hold_type: str, + reason: Optional[str] = None, + record_type: str = 'new', + duration_range: Optional[str] = None, + page: int = 1, + per_page: int = 50, +) -> Optional[Dict[str, Any]]: + """Get paginated hold history detail list.""" + try: + page = max(int(page or 1), 1) + per_page = max(1, min(int(per_page or 50), 200)) + offset = (page - 1) * per_page + + sql = _load_hold_history_sql('list') + params = { + 'start_date': start_date, + 'end_date': end_date, + 'hold_type': _normalize_hold_type(hold_type), + 'reason': reason, + **_record_type_flags(record_type), + 'duration_range': duration_range, + 'offset': offset, + 'limit': per_page, + } + df = read_sql_df(sql, params) + + items: list[Dict[str, Any]] = [] + total = 0 + + if df is not None and not df.empty: + for _, row in df.iterrows(): + if total == 0: + total = _safe_int(row.get('TOTAL_COUNT')) + + wc_name = _clean_text(row.get('WORKCENTER')) + wc_group = _get_wc_group(wc_name) if wc_name else None + items.append({ + 'lotId': _clean_text(row.get('LOT_ID')), + 'workorder': _clean_text(row.get('WORKORDER')), + 'workcenter': wc_group or wc_name, + 'holdReason': _clean_text(row.get('HOLD_REASON')), + 'qty': _safe_int(row.get('QTY')), + 'holdDate': _format_datetime(row.get('HOLD_DATE')), + 'holdEmp': _clean_text(row.get('HOLD_EMP')), + 'holdComment': _clean_text(row.get('HOLD_COMMENT')), + 'releaseDate': _format_datetime(row.get('RELEASE_DATE')), + 'releaseEmp': _clean_text(row.get('RELEASE_EMP')), + 'releaseComment': _clean_text(row.get('RELEASE_COMMENT')), + 'holdHours': round(_safe_float(row.get('HOLD_HOURS')), 2), + 'ncr': _clean_text(row.get('NCR_ID')), + }) + + total_pages = (total + per_page - 1) // per_page if total > 0 else 1 + + return { + 'items': items, + 'pagination': { + 'page': page, + 'perPage': per_page, + 'total': total, + 'totalPages': total_pages, + }, + } + except (DatabasePoolExhaustedError, DatabaseCircuitOpenError): + raise + except Exception as exc: + logger.error('Hold history list query failed: %s', exc) + return None diff --git a/src/mes_dashboard/services/wip_service.py b/src/mes_dashboard/services/wip_service.py index 09be119..7b5ed04 100644 --- a/src/mes_dashboard/services/wip_service.py +++ b/src/mes_dashboard/services/wip_service.py @@ -2651,6 +2651,7 @@ def get_hold_detail_lots( 'lotId': _safe_value(row.get('LOTID')), 'workorder': _safe_value(row.get('WORKORDER')), 'qty': int(row.get('QTY', 0) or 0), + 'product': _safe_value(row.get('PRODUCT')), 'package': _safe_value(row.get('PACKAGE_LEF')), 'workcenter': _safe_value(row.get('WORKCENTER_GROUP')), 'holdReason': _safe_value(row.get('HOLDREASONNAME')), @@ -2658,7 +2659,8 @@ def get_hold_detail_lots( 'age': round(float(row.get('AGEBYDAYS', 0) or 0), 1), 'holdBy': _safe_value(row.get('HOLDEMP')), 'dept': _safe_value(row.get('DEPTNAME')), - 'holdComment': _safe_value(row.get('COMMENT_HOLD')) + 'holdComment': _safe_value(row.get('COMMENT_HOLD')), + 'futureHoldComment': _safe_value(row.get('COMMENT_FUTURE')), }) total_pages = (total + page_size - 1) // page_size if total > 0 else 1 @@ -2760,6 +2762,7 @@ def _get_hold_detail_lots_from_oracle( LOTID, WORKORDER, QTY, + PRODUCT, PACKAGE_LEF AS PACKAGE, WORKCENTER_GROUP AS WORKCENTER, HOLDREASONNAME AS HOLD_REASON, @@ -2768,6 +2771,7 @@ def _get_hold_detail_lots_from_oracle( HOLDEMP AS HOLD_BY, DEPTNAME AS DEPT, COMMENT_HOLD AS HOLD_COMMENT, + COMMENT_FUTURE AS FUTURE_HOLD_COMMENT, ROW_NUMBER() OVER (ORDER BY AGEBYDAYS DESC, LOTID) AS RN FROM {WIP_VIEW} {where_clause} @@ -2784,6 +2788,7 @@ def _get_hold_detail_lots_from_oracle( 'lotId': _safe_value(row['LOTID']), 'workorder': _safe_value(row['WORKORDER']), 'qty': int(row['QTY'] or 0), + 'product': _safe_value(row['PRODUCT']), 'package': _safe_value(row['PACKAGE']), 'workcenter': _safe_value(row['WORKCENTER']), 'holdReason': _safe_value(row['HOLD_REASON']), @@ -2791,7 +2796,8 @@ def _get_hold_detail_lots_from_oracle( 'age': float(row['AGE']) if row['AGE'] else 0, 'holdBy': _safe_value(row['HOLD_BY']), 'dept': _safe_value(row['DEPT']), - 'holdComment': _safe_value(row['HOLD_COMMENT']) + 'holdComment': _safe_value(row['HOLD_COMMENT']), + 'futureHoldComment': _safe_value(row['FUTURE_HOLD_COMMENT']), }) total_pages = (total + page_size - 1) // page_size if total > 0 else 1 diff --git a/src/mes_dashboard/sql/hold_history/duration.sql b/src/mes_dashboard/sql/hold_history/duration.sql new file mode 100644 index 0000000..dce2b5e --- /dev/null +++ b/src/mes_dashboard/sql/hold_history/duration.sql @@ -0,0 +1,73 @@ +WITH history_base AS ( + SELECT + CASE + WHEN TO_CHAR(h.HOLDTXNDATE, 'HH24MI') >= '0730' THEN TRUNC(h.HOLDTXNDATE) + 1 + ELSE TRUNC(h.HOLDTXNDATE) + END AS hold_day, + h.HOLDTXNDATE, + h.RELEASETXNDATE, + NVL(h.QTY, 0) AS qty, + CASE + WHEN h.HOLDREASONNAME IN ({{ NON_QUALITY_REASONS }}) THEN 'non-quality' + ELSE 'quality' + END AS hold_type + FROM DWH.DW_MES_HOLDRELEASEHISTORY h +), +filtered AS ( + SELECT + CASE + WHEN RELEASETXNDATE IS NULL THEN (SYSDATE - HOLDTXNDATE) * 24 + ELSE (RELEASETXNDATE - HOLDTXNDATE) * 24 + END AS hold_hours, + qty + FROM history_base + WHERE hold_day BETWEEN TO_DATE(:start_date, 'YYYY-MM-DD') AND TO_DATE(:end_date, 'YYYY-MM-DD') + AND (:hold_type = 'all' OR hold_type = :hold_type) + AND (:include_new = 1 + OR (:include_on_hold = 1 AND RELEASETXNDATE IS NULL) + OR (:include_released = 1 AND RELEASETXNDATE IS NOT NULL)) +), +bucketed AS ( + SELECT + CASE + WHEN hold_hours < 4 THEN '<4h' + WHEN hold_hours < 24 THEN '4-24h' + WHEN hold_hours < 72 THEN '1-3d' + ELSE '>3d' + END AS range_label, + qty + FROM filtered +), +bucket_counts AS ( + SELECT + range_label, + COUNT(*) AS item_count, + SUM(qty) AS qty + FROM bucketed + GROUP BY range_label +), +totals AS ( + SELECT SUM(qty) AS total_qty FROM bucket_counts +), +buckets AS ( + SELECT '<4h' AS range_label, 1 AS order_key FROM dual + UNION ALL + SELECT '4-24h' AS range_label, 2 AS order_key FROM dual + UNION ALL + SELECT '1-3d' AS range_label, 3 AS order_key FROM dual + UNION ALL + SELECT '>3d' AS range_label, 4 AS order_key FROM dual +) +SELECT + b.range_label, + NVL(c.item_count, 0) AS item_count, + NVL(c.qty, 0) AS qty, + CASE + WHEN t.total_qty = 0 THEN 0 + ELSE ROUND(NVL(c.qty, 0) * 100 / t.total_qty, 2) + END AS pct, + b.order_key +FROM buckets b +LEFT JOIN bucket_counts c ON c.range_label = b.range_label +CROSS JOIN totals t +ORDER BY b.order_key diff --git a/src/mes_dashboard/sql/hold_history/list.sql b/src/mes_dashboard/sql/hold_history/list.sql new file mode 100644 index 0000000..cd6a93e --- /dev/null +++ b/src/mes_dashboard/sql/hold_history/list.sql @@ -0,0 +1,95 @@ +WITH history_base AS ( + SELECT + CASE + WHEN TO_CHAR(h.HOLDTXNDATE, 'HH24MI') >= '0730' THEN TRUNC(h.HOLDTXNDATE) + 1 + ELSE TRUNC(h.HOLDTXNDATE) + END AS hold_day, + h.CONTAINERID, + h.PJ_WORKORDER, + h.WORKCENTERNAME, + h.HOLDREASONNAME, + h.HOLDTXNDATE, + NVL(h.QTY, 0) AS QTY, + h.HOLDEMP, + h.HOLDCOMMENTS, + h.RELEASETXNDATE, + h.RELEASEEMP, + h.RELEASECOMMENTS, + h.NCRID, + CASE + WHEN h.HOLDREASONNAME IN ({{ NON_QUALITY_REASONS }}) THEN 'non-quality' + ELSE 'quality' + END AS hold_type + FROM DWH.DW_MES_HOLDRELEASEHISTORY h +), +filtered AS ( + SELECT + b.*, + CASE + WHEN b.RELEASETXNDATE IS NULL THEN (SYSDATE - b.HOLDTXNDATE) * 24 + ELSE (b.RELEASETXNDATE - b.HOLDTXNDATE) * 24 + END AS hold_hours + FROM history_base b + WHERE b.hold_day BETWEEN TO_DATE(:start_date, 'YYYY-MM-DD') AND TO_DATE(:end_date, 'YYYY-MM-DD') + AND (:hold_type = 'all' OR b.hold_type = :hold_type) + AND (:reason IS NULL OR b.HOLDREASONNAME = :reason) + AND (:include_new = 1 + OR (:include_on_hold = 1 AND b.RELEASETXNDATE IS NULL) + OR (:include_released = 1 AND b.RELEASETXNDATE IS NOT NULL)) + AND (:duration_range IS NULL + OR (:duration_range = '<4h' AND + CASE WHEN b.RELEASETXNDATE IS NULL THEN (SYSDATE - b.HOLDTXNDATE) * 24 + ELSE (b.RELEASETXNDATE - b.HOLDTXNDATE) * 24 END < 4) + OR (:duration_range = '4-24h' AND + CASE WHEN b.RELEASETXNDATE IS NULL THEN (SYSDATE - b.HOLDTXNDATE) * 24 + ELSE (b.RELEASETXNDATE - b.HOLDTXNDATE) * 24 END >= 4 AND + CASE WHEN b.RELEASETXNDATE IS NULL THEN (SYSDATE - b.HOLDTXNDATE) * 24 + ELSE (b.RELEASETXNDATE - b.HOLDTXNDATE) * 24 END < 24) + OR (:duration_range = '1-3d' AND + CASE WHEN b.RELEASETXNDATE IS NULL THEN (SYSDATE - b.HOLDTXNDATE) * 24 + ELSE (b.RELEASETXNDATE - b.HOLDTXNDATE) * 24 END >= 24 AND + CASE WHEN b.RELEASETXNDATE IS NULL THEN (SYSDATE - b.HOLDTXNDATE) * 24 + ELSE (b.RELEASETXNDATE - b.HOLDTXNDATE) * 24 END < 72) + OR (:duration_range = '>3d' AND + CASE WHEN b.RELEASETXNDATE IS NULL THEN (SYSDATE - b.HOLDTXNDATE) * 24 + ELSE (b.RELEASETXNDATE - b.HOLDTXNDATE) * 24 END >= 72)) +), +ranked AS ( + SELECT + NVL(l.LOTID, TRIM(f.CONTAINERID)) AS lot_id, + f.PJ_WORKORDER AS workorder, + f.WORKCENTERNAME AS workcenter, + f.HOLDREASONNAME AS hold_reason, + f.QTY AS qty, + f.HOLDTXNDATE AS hold_date, + f.HOLDEMP AS hold_emp, + f.HOLDCOMMENTS AS hold_comment, + f.RELEASETXNDATE AS release_date, + f.RELEASEEMP AS release_emp, + f.RELEASECOMMENTS AS release_comment, + f.hold_hours, + f.NCRID AS ncr_id, + ROW_NUMBER() OVER (ORDER BY f.HOLDTXNDATE DESC, f.CONTAINERID) AS rn, + COUNT(*) OVER () AS total_count + FROM filtered f + LEFT JOIN DWH.DW_MES_LOT_V l ON l.CONTAINERID = f.CONTAINERID +) +SELECT + lot_id, + workorder, + workcenter, + hold_reason, + qty, + hold_date, + hold_emp, + hold_comment, + release_date, + release_emp, + release_comment, + hold_hours, + ncr_id, + total_count +FROM ranked +WHERE rn > :offset + AND rn <= :offset + :limit +ORDER BY rn diff --git a/src/mes_dashboard/sql/hold_history/reason_pareto.sql b/src/mes_dashboard/sql/hold_history/reason_pareto.sql new file mode 100644 index 0000000..10e3bfa --- /dev/null +++ b/src/mes_dashboard/sql/hold_history/reason_pareto.sql @@ -0,0 +1,86 @@ +WITH history_base AS ( + SELECT + CASE + WHEN TO_CHAR(h.HOLDTXNDATE, 'HH24MI') >= '0730' THEN TRUNC(h.HOLDTXNDATE) + 1 + ELSE TRUNC(h.HOLDTXNDATE) + END AS hold_day, + h.CONTAINERID, + h.HOLDREASONID, + h.HOLDREASONNAME, + h.RELEASETXNDATE, + NVL(h.QTY, 0) AS qty, + CASE + WHEN h.HOLDREASONNAME IN ({{ NON_QUALITY_REASONS }}) THEN 'non-quality' + ELSE 'quality' + END AS hold_type, + CASE + WHEN h.FUTUREHOLDCOMMENTS IS NOT NULL THEN 1 + ELSE 0 + END AS is_future_hold, + ROW_NUMBER() OVER ( + PARTITION BY + h.CONTAINERID, + CASE + WHEN TO_CHAR(h.HOLDTXNDATE, 'HH24MI') >= '0730' THEN TRUNC(h.HOLDTXNDATE) + 1 + ELSE TRUNC(h.HOLDTXNDATE) + END + ORDER BY h.HOLDTXNDATE DESC + ) AS rn_hold_day, + ROW_NUMBER() OVER ( + PARTITION BY h.CONTAINERID, h.HOLDREASONID + ORDER BY h.HOLDTXNDATE + ) AS rn_future_reason + FROM DWH.DW_MES_HOLDRELEASEHISTORY h +), +filtered AS ( + SELECT + NVL(TRIM(HOLDREASONNAME), '(未填寫)') AS reason, + qty + FROM history_base + WHERE hold_day BETWEEN TO_DATE(:start_date, 'YYYY-MM-DD') AND TO_DATE(:end_date, 'YYYY-MM-DD') + AND (:hold_type = 'all' OR hold_type = :hold_type) + AND (:include_new = 1 + OR (:include_on_hold = 1 AND RELEASETXNDATE IS NULL) + OR (:include_released = 1 AND RELEASETXNDATE IS NOT NULL)) + AND rn_hold_day = 1 + AND ( + CASE + WHEN is_future_hold = 1 AND rn_future_reason <> 1 THEN 0 + ELSE 1 + END + ) = 1 +), +grouped AS ( + SELECT + reason, + COUNT(*) AS item_count, + SUM(qty) AS qty + FROM filtered + GROUP BY reason +), +ordered AS ( + SELECT + reason, + item_count, + qty, + SUM(qty) OVER () AS total_qty, + SUM(qty) OVER ( + ORDER BY qty DESC, reason + ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW + ) AS running_qty + FROM grouped +) +SELECT + reason, + item_count, + qty, + CASE + WHEN total_qty = 0 THEN 0 + ELSE ROUND(qty * 100 / total_qty, 2) + END AS pct, + CASE + WHEN total_qty = 0 THEN 0 + ELSE ROUND(running_qty * 100 / total_qty, 2) + END AS cum_pct +FROM ordered +ORDER BY qty DESC, reason diff --git a/src/mes_dashboard/sql/hold_history/still_on_hold_count.sql b/src/mes_dashboard/sql/hold_history/still_on_hold_count.sql new file mode 100644 index 0000000..a9a5e6e --- /dev/null +++ b/src/mes_dashboard/sql/hold_history/still_on_hold_count.sql @@ -0,0 +1,16 @@ +SELECT + SUM( + CASE + WHEN h.HOLDREASONNAME NOT IN ({{ NON_QUALITY_REASONS }}) THEN NVL(h.QTY, 0) + ELSE 0 + END + ) AS quality_count, + SUM( + CASE + WHEN h.HOLDREASONNAME IN ({{ NON_QUALITY_REASONS }}) THEN NVL(h.QTY, 0) + ELSE 0 + END + ) AS non_quality_count, + SUM(NVL(h.QTY, 0)) AS all_count +FROM DWH.DW_MES_HOLDRELEASEHISTORY h +WHERE h.RELEASETXNDATE IS NULL diff --git a/src/mes_dashboard/sql/hold_history/trend.sql b/src/mes_dashboard/sql/hold_history/trend.sql new file mode 100644 index 0000000..40d678e --- /dev/null +++ b/src/mes_dashboard/sql/hold_history/trend.sql @@ -0,0 +1,130 @@ +WITH calendar AS ( + SELECT TRUNC(TO_DATE(:start_date, 'YYYY-MM-DD')) + LEVEL - 1 AS day_date + FROM dual + CONNECT BY LEVEL <= ( + TRUNC(TO_DATE(:end_date, 'YYYY-MM-DD')) - TRUNC(TO_DATE(:start_date, 'YYYY-MM-DD')) + 1 + ) +), +hold_types AS ( + SELECT 'quality' AS hold_type FROM dual + UNION ALL + SELECT 'non-quality' AS hold_type FROM dual + UNION ALL + SELECT 'all' AS hold_type FROM dual +), +history_base AS ( + SELECT + CASE + WHEN TO_CHAR(h.HOLDTXNDATE, 'HH24MI') >= '0730' THEN TRUNC(h.HOLDTXNDATE) + 1 + ELSE TRUNC(h.HOLDTXNDATE) + END AS hold_day, + CASE + WHEN h.RELEASETXNDATE IS NULL THEN NULL + WHEN TO_CHAR(h.RELEASETXNDATE, 'HH24MI') >= '0730' THEN TRUNC(h.RELEASETXNDATE) + 1 + ELSE TRUNC(h.RELEASETXNDATE) + END AS release_day, + h.HOLDTXNDATE, + h.RELEASETXNDATE, + h.CONTAINERID, + NVL(h.QTY, 0) AS qty, + h.HOLDREASONID, + h.HOLDREASONNAME, + CASE + WHEN h.FUTUREHOLDCOMMENTS IS NOT NULL THEN 1 + ELSE 0 + END AS is_future_hold, + ROW_NUMBER() OVER ( + PARTITION BY + h.CONTAINERID, + CASE + WHEN TO_CHAR(h.HOLDTXNDATE, 'HH24MI') >= '0730' THEN TRUNC(h.HOLDTXNDATE) + 1 + ELSE TRUNC(h.HOLDTXNDATE) + END + ORDER BY h.HOLDTXNDATE DESC + ) AS rn_hold_day, + ROW_NUMBER() OVER ( + PARTITION BY h.CONTAINERID, h.HOLDREASONID + ORDER BY h.HOLDTXNDATE + ) AS rn_future_reason + FROM DWH.DW_MES_HOLDRELEASEHISTORY h + WHERE ( + h.HOLDTXNDATE >= TO_DATE(:start_date || ' 073000', 'YYYY-MM-DD HH24MISS') - 1 + OR h.RELEASETXNDATE >= TO_DATE(:start_date || ' 073000', 'YYYY-MM-DD HH24MISS') - 1 + OR h.RELEASETXNDATE IS NULL + ) + AND ( + h.HOLDTXNDATE <= TO_DATE(:end_date || ' 073000', 'YYYY-MM-DD HH24MISS') + OR h.RELEASETXNDATE <= TO_DATE(:end_date || ' 073000', 'YYYY-MM-DD HH24MISS') + OR h.RELEASETXNDATE IS NULL + ) +), +history_enriched AS ( + SELECT + hold_day, + release_day, + HOLDTXNDATE, + RELEASETXNDATE, + CONTAINERID, + qty, + HOLDREASONID, + HOLDREASONNAME, + rn_hold_day, + CASE + WHEN is_future_hold = 1 AND rn_future_reason <> 1 THEN 0 + ELSE 1 + END AS future_hold_flag, + CASE + WHEN HOLDREASONNAME IN ({{ NON_QUALITY_REASONS }}) THEN 'non-quality' + ELSE 'quality' + END AS hold_type + FROM history_base +) +SELECT + TO_CHAR(c.day_date, 'YYYY-MM-DD') AS txn_date, + t.hold_type, + SUM( + CASE + WHEN (t.hold_type = 'all' OR h.hold_type = t.hold_type) + AND h.hold_day <= c.day_date + AND (h.release_day IS NULL OR c.day_date < h.release_day) + AND c.day_date <= TRUNC(SYSDATE) + AND h.rn_hold_day = 1 + THEN h.qty + ELSE 0 + END + ) AS hold_qty, + SUM( + CASE + WHEN (t.hold_type = 'all' OR h.hold_type = t.hold_type) + AND h.hold_day = c.day_date + AND (h.release_day IS NULL OR c.day_date <= h.release_day) + AND h.future_hold_flag = 1 + THEN h.qty + ELSE 0 + END + ) AS new_hold_qty, + SUM( + CASE + WHEN (t.hold_type = 'all' OR h.hold_type = t.hold_type) + AND h.release_day = c.day_date + AND h.release_day >= h.hold_day + THEN h.qty + ELSE 0 + END + ) AS release_qty, + SUM( + CASE + WHEN (t.hold_type = 'all' OR h.hold_type = t.hold_type) + AND h.hold_day = c.day_date + AND (h.release_day IS NULL OR c.day_date <= h.release_day) + AND h.rn_hold_day = 1 + AND h.future_hold_flag = 0 + THEN h.qty + ELSE 0 + END + ) AS future_hold_qty +FROM calendar c +CROSS JOIN hold_types t +LEFT JOIN history_enriched h ON 1 = 1 +GROUP BY c.day_date, t.hold_type +ORDER BY c.day_date, t.hold_type diff --git a/tests/test_hold_history_routes.py b/tests/test_hold_history_routes.py new file mode 100644 index 0000000..90be97b --- /dev/null +++ b/tests/test_hold_history_routes.py @@ -0,0 +1,256 @@ +# -*- coding: utf-8 -*- +"""Unit tests for Hold History 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 TestHoldHistoryRoutesBase(unittest.TestCase): + """Base class for Hold History 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 TestHoldHistoryPageRoute(TestHoldHistoryRoutesBase): + """Test GET /hold-history page route.""" + + @patch('mes_dashboard.routes.hold_history_routes.os.path.exists', return_value=False) + def test_hold_history_page_includes_vite_entry(self, _mock_exists): + with self.client.session_transaction() as sess: + sess['admin'] = {'displayName': 'Test Admin', 'employeeNo': 'A001'} + + response = self.client.get('/hold-history') + + self.assertEqual(response.status_code, 200) + self.assertIn(b'/static/dist/hold-history.js', response.data) + + @patch('mes_dashboard.routes.hold_history_routes.os.path.exists', return_value=False) + def test_hold_history_page_returns_403_without_admin(self, _mock_exists): + response = self.client.get('/hold-history') + self.assertEqual(response.status_code, 403) + + +class TestHoldHistoryTrendRoute(TestHoldHistoryRoutesBase): + """Test GET /api/hold-history/trend endpoint.""" + + @patch('mes_dashboard.routes.hold_history_routes.get_still_on_hold_count') + @patch('mes_dashboard.routes.hold_history_routes.get_hold_history_trend') + def test_trend_passes_date_range(self, mock_trend, mock_count): + mock_trend.return_value = { + 'days': [ + { + 'date': '2026-02-01', + 'quality': {'holdQty': 10, 'newHoldQty': 2, 'releaseQty': 3, 'futureHoldQty': 1}, + 'non_quality': {'holdQty': 5, 'newHoldQty': 1, 'releaseQty': 2, 'futureHoldQty': 0}, + 'all': {'holdQty': 15, 'newHoldQty': 3, 'releaseQty': 5, 'futureHoldQty': 1}, + } + ] + } + mock_count.return_value = {'quality': 4, 'non_quality': 2, 'all': 6} + + response = self.client.get('/api/hold-history/trend?start_date=2026-02-01&end_date=2026-02-07') + payload = json.loads(response.data) + + self.assertEqual(response.status_code, 200) + self.assertTrue(payload['success']) + self.assertEqual(payload['data']['stillOnHoldCount'], {'quality': 4, 'non_quality': 2, 'all': 6}) + mock_trend.assert_called_once_with('2026-02-01', '2026-02-07') + mock_count.assert_called_once_with() + + def test_trend_invalid_date_returns_400(self): + response = self.client.get('/api/hold-history/trend?start_date=2026/02/01&end_date=2026-02-07') + payload = json.loads(response.data) + + self.assertEqual(response.status_code, 400) + self.assertFalse(payload['success']) + + @patch('mes_dashboard.routes.hold_history_routes.get_still_on_hold_count') + @patch('mes_dashboard.routes.hold_history_routes.get_hold_history_trend') + @patch('mes_dashboard.core.rate_limit.check_and_record', return_value=(True, 8)) + def test_trend_rate_limited_returns_429(self, _mock_limit, mock_service, _mock_count): + response = self.client.get('/api/hold-history/trend?start_date=2026-02-01&end_date=2026-02-07') + 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'), '8') + mock_service.assert_not_called() + + +class TestHoldHistoryReasonParetoRoute(TestHoldHistoryRoutesBase): + """Test GET /api/hold-history/reason-pareto endpoint.""" + + @patch('mes_dashboard.routes.hold_history_routes.get_hold_history_reason_pareto') + def test_reason_pareto_passes_hold_type_and_record_type(self, mock_service): + mock_service.return_value = {'items': []} + + response = self.client.get( + '/api/hold-history/reason-pareto?start_date=2026-02-01&end_date=2026-02-07' + '&hold_type=non-quality&record_type=on_hold' + ) + + self.assertEqual(response.status_code, 200) + mock_service.assert_called_once_with('2026-02-01', '2026-02-07', 'non-quality', 'on_hold') + + @patch('mes_dashboard.routes.hold_history_routes.get_hold_history_reason_pareto') + def test_reason_pareto_defaults_record_type_to_new(self, mock_service): + mock_service.return_value = {'items': []} + + response = self.client.get( + '/api/hold-history/reason-pareto?start_date=2026-02-01&end_date=2026-02-07&hold_type=quality' + ) + + self.assertEqual(response.status_code, 200) + mock_service.assert_called_once_with('2026-02-01', '2026-02-07', 'quality', 'new') + + @patch('mes_dashboard.routes.hold_history_routes.get_hold_history_reason_pareto') + def test_reason_pareto_multi_record_type(self, mock_service): + mock_service.return_value = {'items': []} + + response = self.client.get( + '/api/hold-history/reason-pareto?start_date=2026-02-01&end_date=2026-02-07' + '&hold_type=quality&record_type=on_hold,released' + ) + + self.assertEqual(response.status_code, 200) + mock_service.assert_called_once_with('2026-02-01', '2026-02-07', 'quality', 'on_hold,released') + + def test_reason_pareto_invalid_record_type_returns_400(self): + response = self.client.get( + '/api/hold-history/reason-pareto?start_date=2026-02-01&end_date=2026-02-07&record_type=invalid' + ) + payload = json.loads(response.data) + + self.assertEqual(response.status_code, 400) + self.assertFalse(payload['success']) + + def test_reason_pareto_partial_invalid_record_type_returns_400(self): + response = self.client.get( + '/api/hold-history/reason-pareto?start_date=2026-02-01&end_date=2026-02-07&record_type=on_hold,bogus' + ) + payload = json.loads(response.data) + + self.assertEqual(response.status_code, 400) + self.assertFalse(payload['success']) + + +class TestHoldHistoryDurationRoute(TestHoldHistoryRoutesBase): + """Test GET /api/hold-history/duration endpoint.""" + + @patch('mes_dashboard.routes.hold_history_routes.get_hold_history_duration') + def test_duration_failure_returns_500(self, mock_service): + mock_service.return_value = None + + response = self.client.get('/api/hold-history/duration?start_date=2026-02-01&end_date=2026-02-07') + payload = json.loads(response.data) + + self.assertEqual(response.status_code, 500) + self.assertFalse(payload['success']) + + def test_duration_invalid_hold_type(self): + response = self.client.get( + '/api/hold-history/duration?start_date=2026-02-01&end_date=2026-02-07&hold_type=invalid' + ) + payload = json.loads(response.data) + + self.assertEqual(response.status_code, 400) + self.assertFalse(payload['success']) + + @patch('mes_dashboard.routes.hold_history_routes.get_hold_history_duration') + def test_duration_passes_record_type(self, mock_service): + mock_service.return_value = {'items': []} + + response = self.client.get( + '/api/hold-history/duration?start_date=2026-02-01&end_date=2026-02-07&record_type=released' + ) + + self.assertEqual(response.status_code, 200) + mock_service.assert_called_once_with('2026-02-01', '2026-02-07', 'quality', 'released') + + def test_duration_invalid_record_type_returns_400(self): + response = self.client.get( + '/api/hold-history/duration?start_date=2026-02-01&end_date=2026-02-07&record_type=bogus' + ) + payload = json.loads(response.data) + + self.assertEqual(response.status_code, 400) + self.assertFalse(payload['success']) + + +class TestHoldHistoryListRoute(TestHoldHistoryRoutesBase): + """Test GET /api/hold-history/list endpoint.""" + + @patch('mes_dashboard.routes.hold_history_routes.get_hold_history_list') + def test_list_caps_per_page_and_sets_page_floor(self, mock_service): + mock_service.return_value = { + 'items': [], + 'pagination': {'page': 1, 'perPage': 200, 'total': 0, 'totalPages': 1}, + } + + response = self.client.get( + '/api/hold-history/list?start_date=2026-02-01&end_date=2026-02-07' + '&hold_type=all&page=0&per_page=500&reason=品質確認' + ) + + self.assertEqual(response.status_code, 200) + mock_service.assert_called_once_with( + start_date='2026-02-01', + end_date='2026-02-07', + hold_type='all', + reason='品質確認', + record_type='new', + duration_range=None, + page=1, + per_page=200, + ) + + @patch('mes_dashboard.routes.hold_history_routes.get_hold_history_list') + def test_list_passes_duration_range(self, mock_service): + mock_service.return_value = { + 'items': [], + 'pagination': {'page': 1, 'perPage': 50, 'total': 0, 'totalPages': 1}, + } + + response = self.client.get( + '/api/hold-history/list?start_date=2026-02-01&end_date=2026-02-07&duration_range=<4h' + ) + + self.assertEqual(response.status_code, 200) + mock_service.assert_called_once_with( + start_date='2026-02-01', + end_date='2026-02-07', + hold_type='quality', + reason=None, + record_type='new', + duration_range='<4h', + page=1, + per_page=50, + ) + + def test_list_invalid_duration_range_returns_400(self): + response = self.client.get( + '/api/hold-history/list?start_date=2026-02-01&end_date=2026-02-07&duration_range=invalid' + ) + payload = json.loads(response.data) + + self.assertEqual(response.status_code, 400) + self.assertFalse(payload['success']) + + @patch('mes_dashboard.routes.hold_history_routes.get_hold_history_list') + @patch('mes_dashboard.core.rate_limit.check_and_record', return_value=(True, 5)) + def test_list_rate_limited_returns_429(self, _mock_limit, mock_service): + response = self.client.get('/api/hold-history/list?start_date=2026-02-01&end_date=2026-02-07') + 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'), '5') + mock_service.assert_not_called() diff --git a/tests/test_hold_history_service.py b/tests/test_hold_history_service.py new file mode 100644 index 0000000..aa1ce33 --- /dev/null +++ b/tests/test_hold_history_service.py @@ -0,0 +1,373 @@ +# -*- coding: utf-8 -*- +"""Unit tests for hold_history_service module.""" + +from __future__ import annotations + +import json +import unittest +from datetime import date, datetime, timedelta +from unittest.mock import MagicMock, patch + +import pandas as pd + +from mes_dashboard.services import hold_history_service + + +class TestHoldHistoryTrendCache(unittest.TestCase): + """Test trend cache hit/miss/cross-month behavior.""" + + def setUp(self): + hold_history_service._load_hold_history_sql.cache_clear() + + def _trend_rows_for_days(self, days: list[str]) -> pd.DataFrame: + rows = [] + for day in days: + rows.append( + { + 'TXN_DATE': day, + 'HOLD_TYPE': 'quality', + 'HOLD_QTY': 10, + 'NEW_HOLD_QTY': 2, + 'RELEASE_QTY': 3, + 'FUTURE_HOLD_QTY': 1, + } + ) + rows.append( + { + 'TXN_DATE': day, + 'HOLD_TYPE': 'non-quality', + 'HOLD_QTY': 4, + 'NEW_HOLD_QTY': 1, + 'RELEASE_QTY': 1, + 'FUTURE_HOLD_QTY': 0, + } + ) + rows.append( + { + 'TXN_DATE': day, + 'HOLD_TYPE': 'all', + 'HOLD_QTY': 14, + 'NEW_HOLD_QTY': 3, + 'RELEASE_QTY': 4, + 'FUTURE_HOLD_QTY': 1, + } + ) + return pd.DataFrame(rows) + + @patch('mes_dashboard.services.hold_history_service.read_sql_df') + @patch('mes_dashboard.services.hold_history_service.get_redis_client') + def test_trend_cache_hit_for_recent_month(self, mock_get_redis_client, mock_read_sql_df): + today = date.today() + start = today.replace(day=1) + end = start + timedelta(days=1) + + cached_days = [ + { + 'date': start.strftime('%Y-%m-%d'), + 'quality': {'holdQty': 11, 'newHoldQty': 2, 'releaseQty': 4, 'futureHoldQty': 1}, + 'non_quality': {'holdQty': 5, 'newHoldQty': 1, 'releaseQty': 1, 'futureHoldQty': 0}, + 'all': {'holdQty': 16, 'newHoldQty': 3, 'releaseQty': 5, 'futureHoldQty': 1}, + }, + { + 'date': end.strftime('%Y-%m-%d'), + 'quality': {'holdQty': 12, 'newHoldQty': 3, 'releaseQty': 5, 'futureHoldQty': 1}, + 'non_quality': {'holdQty': 4, 'newHoldQty': 1, 'releaseQty': 2, 'futureHoldQty': 0}, + 'all': {'holdQty': 16, 'newHoldQty': 4, 'releaseQty': 7, 'futureHoldQty': 1}, + }, + ] + + mock_redis = MagicMock() + mock_redis.get.return_value = json.dumps(cached_days) + mock_get_redis_client.return_value = mock_redis + + result = hold_history_service.get_hold_history_trend(start.isoformat(), end.isoformat()) + + self.assertIsNotNone(result) + self.assertEqual(len(result['days']), 2) + self.assertEqual(result['days'][0]['quality']['holdQty'], 11) + self.assertEqual(result['days'][1]['all']['releaseQty'], 7) + mock_read_sql_df.assert_not_called() + + @patch('mes_dashboard.services.hold_history_service.read_sql_df') + @patch('mes_dashboard.services.hold_history_service.get_redis_client') + def test_trend_cache_miss_populates_cache(self, mock_get_redis_client, mock_read_sql_df): + today = date.today() + start = today.replace(day=1) + end = start + timedelta(days=1) + + mock_redis = MagicMock() + mock_redis.get.return_value = None + mock_get_redis_client.return_value = mock_redis + + mock_read_sql_df.return_value = self._trend_rows_for_days([start.isoformat(), end.isoformat()]) + + result = hold_history_service.get_hold_history_trend(start.isoformat(), end.isoformat()) + + self.assertIsNotNone(result) + self.assertEqual(len(result['days']), 2) + self.assertEqual(result['days'][0]['all']['holdQty'], 14) + self.assertEqual(mock_read_sql_df.call_count, 1) + mock_redis.setex.assert_called_once() + cache_key = mock_redis.setex.call_args.args[0] + self.assertIn('hold_history:daily', cache_key) + + @patch('mes_dashboard.services.hold_history_service.read_sql_df') + @patch('mes_dashboard.services.hold_history_service.get_redis_client') + def test_trend_cross_month_assembly_from_cache(self, mock_get_redis_client, mock_read_sql_df): + today = date.today() + current_month_start = today.replace(day=1) + previous_month_end = current_month_start - timedelta(days=1) + + start = previous_month_end - timedelta(days=1) + end = current_month_start + timedelta(days=1) + + previous_cache = [ + { + 'date': start.strftime('%Y-%m-%d'), + 'quality': {'holdQty': 9, 'newHoldQty': 2, 'releaseQty': 1, 'futureHoldQty': 0}, + 'non_quality': {'holdQty': 3, 'newHoldQty': 1, 'releaseQty': 0, 'futureHoldQty': 0}, + 'all': {'holdQty': 12, 'newHoldQty': 3, 'releaseQty': 1, 'futureHoldQty': 0}, + }, + { + 'date': (start + timedelta(days=1)).strftime('%Y-%m-%d'), + 'quality': {'holdQty': 8, 'newHoldQty': 1, 'releaseQty': 2, 'futureHoldQty': 0}, + 'non_quality': {'holdQty': 2, 'newHoldQty': 1, 'releaseQty': 1, 'futureHoldQty': 0}, + 'all': {'holdQty': 10, 'newHoldQty': 2, 'releaseQty': 3, 'futureHoldQty': 0}, + }, + ] + + current_cache = [ + { + 'date': current_month_start.strftime('%Y-%m-%d'), + 'quality': {'holdQty': 7, 'newHoldQty': 2, 'releaseQty': 3, 'futureHoldQty': 1}, + 'non_quality': {'holdQty': 2, 'newHoldQty': 1, 'releaseQty': 1, 'futureHoldQty': 0}, + 'all': {'holdQty': 9, 'newHoldQty': 3, 'releaseQty': 4, 'futureHoldQty': 1}, + }, + { + 'date': (current_month_start + timedelta(days=1)).strftime('%Y-%m-%d'), + 'quality': {'holdQty': 6, 'newHoldQty': 1, 'releaseQty': 2, 'futureHoldQty': 0}, + 'non_quality': {'holdQty': 1, 'newHoldQty': 1, 'releaseQty': 0, 'futureHoldQty': 0}, + 'all': {'holdQty': 7, 'newHoldQty': 2, 'releaseQty': 2, 'futureHoldQty': 0}, + }, + ] + + mock_redis = MagicMock() + mock_redis.get.side_effect = [json.dumps(previous_cache), json.dumps(current_cache)] + mock_get_redis_client.return_value = mock_redis + + result = hold_history_service.get_hold_history_trend(start.isoformat(), end.isoformat()) + + self.assertIsNotNone(result) + self.assertEqual(len(result['days']), (end - start).days + 1) + self.assertEqual(result['days'][0]['date'], start.isoformat()) + self.assertEqual(result['days'][-1]['date'], end.isoformat()) + self.assertEqual(result['days'][0]['all']['holdQty'], 12) + self.assertEqual(result['days'][-1]['quality']['releaseQty'], 2) + mock_read_sql_df.assert_not_called() + + @patch('mes_dashboard.services.hold_history_service.read_sql_df') + @patch('mes_dashboard.services.hold_history_service.get_redis_client') + def test_trend_older_month_queries_oracle_without_cache(self, mock_get_redis_client, mock_read_sql_df): + today = date.today() + current_month_start = today.replace(day=1) + + old_month_start = (current_month_start - timedelta(days=100)).replace(day=1) + start = old_month_start + end = old_month_start + timedelta(days=1) + + mock_redis = MagicMock() + mock_get_redis_client.return_value = mock_redis + + mock_read_sql_df.return_value = self._trend_rows_for_days([start.isoformat(), end.isoformat()]) + + result = hold_history_service.get_hold_history_trend(start.isoformat(), end.isoformat()) + + self.assertIsNotNone(result) + self.assertEqual(len(result['days']), 2) + self.assertEqual(mock_read_sql_df.call_count, 1) + mock_redis.get.assert_not_called() + + +class TestHoldHistoryServiceFunctions(unittest.TestCase): + """Test non-trend service function formatting and behavior.""" + + def setUp(self): + hold_history_service._load_hold_history_sql.cache_clear() + + @patch('mes_dashboard.services.hold_history_service.read_sql_df') + def test_reason_pareto_formats_response(self, mock_read_sql_df): + mock_read_sql_df.return_value = pd.DataFrame( + [ + {'REASON': '品質確認', 'ITEM_COUNT': 10, 'QTY': 2000, 'PCT': 40.0, 'CUM_PCT': 40.0}, + {'REASON': '工程驗證', 'ITEM_COUNT': 8, 'QTY': 1800, 'PCT': 32.0, 'CUM_PCT': 72.0}, + ] + ) + + result = hold_history_service.get_hold_history_reason_pareto('2026-02-01', '2026-02-07', 'quality') + + self.assertIsNotNone(result) + self.assertEqual(len(result['items']), 2) + self.assertEqual(result['items'][0]['reason'], '品質確認') + self.assertEqual(result['items'][0]['count'], 10) + self.assertEqual(result['items'][1]['cumPct'], 72.0) + + @patch('mes_dashboard.services.hold_history_service.read_sql_df') + def test_reason_pareto_passes_record_type_flags(self, mock_read_sql_df): + mock_read_sql_df.return_value = pd.DataFrame([]) + + hold_history_service.get_hold_history_reason_pareto( + '2026-02-01', '2026-02-07', 'quality', record_type='on_hold' + ) + + params = mock_read_sql_df.call_args.args[1] + self.assertEqual(params['include_new'], 0) + self.assertEqual(params['include_on_hold'], 1) + self.assertEqual(params['include_released'], 0) + + @patch('mes_dashboard.services.hold_history_service.read_sql_df') + def test_reason_pareto_multi_record_type_flags(self, mock_read_sql_df): + mock_read_sql_df.return_value = pd.DataFrame([]) + + hold_history_service.get_hold_history_reason_pareto( + '2026-02-01', '2026-02-07', 'quality', record_type='on_hold,released' + ) + + params = mock_read_sql_df.call_args.args[1] + self.assertEqual(params['include_new'], 0) + self.assertEqual(params['include_on_hold'], 1) + self.assertEqual(params['include_released'], 1) + + @patch('mes_dashboard.services.hold_history_service.read_sql_df') + def test_reason_pareto_normalizes_invalid_hold_type(self, mock_read_sql_df): + mock_read_sql_df.return_value = pd.DataFrame([]) + + hold_history_service.get_hold_history_reason_pareto('2026-02-01', '2026-02-07', 'invalid') + + params = mock_read_sql_df.call_args.args[1] + self.assertEqual(params['hold_type'], 'quality') + + @patch('mes_dashboard.services.hold_history_service.read_sql_df') + def test_duration_formats_response(self, mock_read_sql_df): + mock_read_sql_df.return_value = pd.DataFrame( + [ + {'RANGE_LABEL': '<4h', 'ITEM_COUNT': 5, 'QTY': 500, 'PCT': 25.0}, + {'RANGE_LABEL': '4-24h', 'ITEM_COUNT': 7, 'QTY': 700, 'PCT': 35.0}, + {'RANGE_LABEL': '1-3d', 'ITEM_COUNT': 4, 'QTY': 400, 'PCT': 20.0}, + {'RANGE_LABEL': '>3d', 'ITEM_COUNT': 4, 'QTY': 400, 'PCT': 20.0}, + ] + ) + + result = hold_history_service.get_hold_history_duration('2026-02-01', '2026-02-07', 'quality') + + self.assertIsNotNone(result) + self.assertEqual(len(result['items']), 4) + self.assertEqual(result['items'][0]['range'], '<4h') + self.assertEqual(result['items'][0]['qty'], 500) + self.assertEqual(result['items'][1]['count'], 7) + + @patch('mes_dashboard.services.hold_history_service.read_sql_df') + def test_duration_passes_record_type_flags(self, mock_read_sql_df): + mock_read_sql_df.return_value = pd.DataFrame([]) + + hold_history_service.get_hold_history_duration( + '2026-02-01', '2026-02-07', 'quality', record_type='released' + ) + + params = mock_read_sql_df.call_args.args[1] + self.assertEqual(params['include_new'], 0) + self.assertEqual(params['include_on_hold'], 0) + self.assertEqual(params['include_released'], 1) + + @patch('mes_dashboard.services.hold_history_service._get_wc_group') + @patch('mes_dashboard.services.hold_history_service.read_sql_df') + def test_list_formats_response_and_pagination(self, mock_read_sql_df, mock_wc_group): + mock_wc_group.side_effect = lambda wc: {'WB': '焊接_WB', 'DB': '焊接_DB'}.get(wc) + mock_read_sql_df.return_value = pd.DataFrame( + [ + { + 'LOT_ID': 'LOT001', + 'WORKORDER': 'GA26010001', + 'WORKCENTER': 'WB', + 'HOLD_REASON': '品質確認', + 'QTY': 250, + 'HOLD_DATE': datetime(2026, 2, 1, 8, 30, 0), + 'HOLD_EMP': '王小明', + 'HOLD_COMMENT': '確認中', + 'RELEASE_DATE': None, + 'RELEASE_EMP': None, + 'RELEASE_COMMENT': None, + 'HOLD_HOURS': 12.345, + 'NCR_ID': 'NCR-001', + 'TOTAL_COUNT': 3, + }, + { + 'LOT_ID': 'LOT002', + 'WORKORDER': 'GA26010002', + 'WORKCENTER': 'DB', + 'HOLD_REASON': '工程驗證', + 'QTY': 100, + 'HOLD_DATE': datetime(2026, 2, 1, 9, 10, 0), + 'HOLD_EMP': '陳小華', + 'HOLD_COMMENT': '待確認', + 'RELEASE_DATE': datetime(2026, 2, 1, 12, 0, 0), + 'RELEASE_EMP': '李主管', + 'RELEASE_COMMENT': '已解除', + 'HOLD_HOURS': 2.5, + 'NCR_ID': None, + 'TOTAL_COUNT': 3, + }, + ] + ) + + result = hold_history_service.get_hold_history_list( + start_date='2026-02-01', + end_date='2026-02-07', + hold_type='quality', + reason=None, + page=1, + per_page=2, + ) + + self.assertIsNotNone(result) + self.assertEqual(len(result['items']), 2) + self.assertEqual(result['items'][0]['workcenter'], '焊接_WB') + self.assertEqual(result['items'][1]['workcenter'], '焊接_DB') + self.assertEqual(result['items'][0]['qty'], 250) + self.assertEqual(result['items'][1]['qty'], 100) + self.assertEqual(result['items'][0]['releaseDate'], None) + self.assertEqual(result['items'][0]['holdHours'], 12.35) + self.assertEqual(result['pagination']['total'], 3) + self.assertEqual(result['pagination']['totalPages'], 2) + + @patch('mes_dashboard.services.hold_history_service.read_sql_df') + def test_still_on_hold_count_formats_response(self, mock_read_sql_df): + mock_read_sql_df.return_value = pd.DataFrame( + [{'QUALITY_COUNT': 4, 'NON_QUALITY_COUNT': 2, 'ALL_COUNT': 6}] + ) + + result = hold_history_service.get_still_on_hold_count() + + self.assertIsNotNone(result) + self.assertEqual(result['quality'], 4) + self.assertEqual(result['non_quality'], 2) + self.assertEqual(result['all'], 6) + + @patch('mes_dashboard.services.hold_history_service.read_sql_df') + def test_still_on_hold_count_empty_returns_zeros(self, mock_read_sql_df): + mock_read_sql_df.return_value = pd.DataFrame() + + result = hold_history_service.get_still_on_hold_count() + + self.assertIsNotNone(result) + self.assertEqual(result, {'quality': 0, 'non_quality': 0, 'all': 0}) + + def test_trend_sql_contains_shift_boundary_logic(self): + sql = hold_history_service._load_hold_history_sql('trend') + + self.assertIn('0730', sql) + self.assertIn('ROW_NUMBER', sql) + self.assertIn('FUTUREHOLDCOMMENTS', sql) + + +if __name__ == '__main__': # pragma: no cover + unittest.main() diff --git a/tests/test_wip_service.py b/tests/test_wip_service.py index 59fea8a..398c6ad 100644 --- a/tests/test_wip_service.py +++ b/tests/test_wip_service.py @@ -9,36 +9,36 @@ 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_hold_detail_summary, - get_hold_detail_lots, - get_hold_overview_treemap, - 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): - """Decorator to disable Redis cache for Oracle fallback tests.""" - @wraps(func) - def wrapper(*args, **kwargs): - 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() - with patch('mes_dashboard.services.wip_service.get_cached_wip_data', return_value=None): - with patch('mes_dashboard.services.wip_service.get_cached_sys_date', return_value=None): - return func(*args, **kwargs) - return wrapper +def disable_cache(func): + """Decorator to disable Redis cache for Oracle fallback tests.""" + @wraps(func) + def wrapper(*args, **kwargs): + 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() + with patch('mes_dashboard.services.wip_service.get_cached_wip_data', return_value=None): + with patch('mes_dashboard.services.wip_service.get_cached_sys_date', return_value=None): + return func(*args, **kwargs) + return wrapper class TestWipServiceConfig(unittest.TestCase): @@ -392,7 +392,7 @@ class TestSearchWorkorders(unittest.TestCase): self.assertNotIn("LOTID NOT LIKE '%DUMMY%'", call_args) -class TestSearchLotIds(unittest.TestCase): +class TestSearchLotIds(unittest.TestCase): """Test search_lot_ids function.""" @disable_cache @@ -448,40 +448,40 @@ class TestSearchLotIds(unittest.TestCase): search_lot_ids('GA26') - call_args = mock_read_sql.call_args[0][0] - self.assertIn("LOTID NOT LIKE '%DUMMY%'", call_args) - - -class TestWipSearchIndexShortcut(unittest.TestCase): - """Test derived search index fast-path behavior.""" - - @patch('mes_dashboard.services.wip_service._search_workorders_from_oracle') - @patch('mes_dashboard.services.wip_service._get_wip_search_index') - def test_workorder_search_uses_index_without_cross_filters(self, mock_index, mock_oracle): - mock_index.return_value = { - "workorders": ["GA26012001", "GA26012002", "GB00000001"] - } - - result = search_workorders("GA26", limit=10) - - self.assertEqual(result, ["GA26012001", "GA26012002"]) - mock_oracle.assert_not_called() - - @patch('mes_dashboard.services.wip_service._search_workorders_from_oracle') - @patch('mes_dashboard.services.wip_service._get_wip_search_index') - def test_workorder_search_with_cross_filters_falls_back(self, mock_index, mock_oracle): - mock_index.return_value = { - "workorders": ["GA26012001", "GA26012002"] - } - mock_oracle.return_value = ["GA26012001"] - - result = search_workorders("GA26", package="SOT-23") - - self.assertEqual(result, ["GA26012001"]) - mock_oracle.assert_called_once() - - -class TestDummyExclusionInAllFunctions(unittest.TestCase): + call_args = mock_read_sql.call_args[0][0] + self.assertIn("LOTID NOT LIKE '%DUMMY%'", call_args) + + +class TestWipSearchIndexShortcut(unittest.TestCase): + """Test derived search index fast-path behavior.""" + + @patch('mes_dashboard.services.wip_service._search_workorders_from_oracle') + @patch('mes_dashboard.services.wip_service._get_wip_search_index') + def test_workorder_search_uses_index_without_cross_filters(self, mock_index, mock_oracle): + mock_index.return_value = { + "workorders": ["GA26012001", "GA26012002", "GB00000001"] + } + + result = search_workorders("GA26", limit=10) + + self.assertEqual(result, ["GA26012001", "GA26012002"]) + mock_oracle.assert_not_called() + + @patch('mes_dashboard.services.wip_service._search_workorders_from_oracle') + @patch('mes_dashboard.services.wip_service._get_wip_search_index') + def test_workorder_search_with_cross_filters_falls_back(self, mock_index, mock_oracle): + mock_index.return_value = { + "workorders": ["GA26012001", "GA26012002"] + } + mock_oracle.return_value = ["GA26012001"] + + result = search_workorders("GA26", package="SOT-23") + + self.assertEqual(result, ["GA26012001"]) + mock_oracle.assert_called_once() + + +class TestDummyExclusionInAllFunctions(unittest.TestCase): """Test DUMMY exclusion is applied in all WIP functions.""" @disable_cache @@ -657,140 +657,155 @@ class TestMultipleFilterConditions(unittest.TestCase): -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 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'], + 'COMMENT_FUTURE': ['FC1', None, 'FC3', None, 'FC5'], + 'PRODUCT': ['PROD-A', 'PROD-B', 'PROD-A', 'PROD-Z', 'PROD-C'], + '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_hold_detail_lots_includes_product_and_future_hold_comment(self, mock_cached_wip): + mock_cached_wip.return_value = self._sample_hold_df() + + result = get_hold_detail_lots(reason='品質確認', page=1, page_size=10) + lot = result['lots'][0] + self.assertEqual(lot['product'], 'PROD-A') + self.assertEqual(lot['futureHoldComment'], 'FC3') + + lot2 = result['lots'][1] + self.assertEqual(lot2['product'], 'PROD-A') + self.assertEqual(lot2['futureHoldComment'], 'FC1') + + @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: