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 @@
+
+
+
+
+
+
+
{{ errorMessage }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Loading...
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+ | Lot ID |
+ WorkOrder |
+ 站別 |
+ Hold Reason |
+ 數量 |
+ Hold 時間 |
+ Hold 人員 |
+ Hold Comment |
+ Release 時間 |
+ Release 人員 |
+ Release Comment |
+ 時長(hr) |
+ NCR |
+
+
+
+
+ | Loading... |
+
+
+ | {{ errorMessage }} |
+
+
+ | No data |
+
+
+ | {{ item.lotId || '-' }} |
+ {{ item.workorder || '-' }} |
+ {{ item.workcenter || '-' }} |
+ {{ item.holdReason || '-' }} |
+ {{ formatNumber(item.qty) }} |
+ {{ item.holdDate || '-' }} |
+ {{ item.holdEmp || '-' }} |
+
+ {{ item.releaseDate || '仍在 Hold' }} |
+ {{ item.releaseEmp || '-' }} |
+
+ {{ formatHours(item.holdHours) }} |
+ {{ item.ncr || '-' }} |
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+ Reason 篩選:{{ reason }}
+
+ 時長篩選:{{ durationRange }}
+
+
+
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 @@
+
+
+
+
+
+ Release 數量
+ {{ formatNumber(summary.releaseQty) }}
+
+
+
+ New Hold 數量
+ {{ formatNumber(summary.newHoldQty) }}
+
+
+
+ Future Hold 數量
+ {{ formatNumber(summary.futureHoldQty) }}
+
+
+
+ On Hold 數量
+ {{ formatNumber(summary.stillOnHoldCount) }}
+
+
+
+ 淨變動 (Release - New - Future)
+
+ {{ formatNumber(summary.netChange) }}
+
+
+
+
+ 平均 Hold 時長
+ {{ formatHours(summary.avgHoldHours) }}
+
+
+
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: