From 05d907ac720bc5fc9f0e6c17e5d2d42bd014c9f5 Mon Sep 17 00:00:00 2001 From: egg Date: Fri, 13 Feb 2026 20:35:52 +0800 Subject: [PATCH] feat(reject-history): ship report page and archive openspec change --- data/page_status.json | 9 +- docs/reject_history_performance.md | 58 ++ .../src/portal-shell/nativeModuleRegistry.js | 4 + frontend/src/portal-shell/routeContracts.js | 12 + frontend/src/reject-history/App.vue | 968 ++++++++++++++++++ frontend/src/reject-history/index.html | 12 + frontend/src/reject-history/main.js | 7 + frontend/src/reject-history/style.css | 418 ++++++++ frontend/vite.config.js | 1 + .../.openspec.yaml | 0 .../design.md | 60 +- .../proposal.md | 10 +- .../specs/field-name-consistency/spec.md | 0 .../specs/reject-history-api/spec.md | 29 + .../specs/reject-history-page/spec.md | 33 + .../specs/reject-metric-semantics/spec.md | 15 + .../unified-shell-route-coverage/spec.md | 0 .../specs/vue-vite-page-architecture/spec.md | 0 .../tasks.md | 60 ++ .../reject-history-query-page/tasks.md | 53 - openspec/specs/field-name-consistency/spec.md | 25 +- openspec/specs/reject-history-api/spec.md | 117 +++ openspec/specs/reject-history-page/spec.md | 142 +++ .../specs/reject-metric-semantics/spec.md | 77 ++ .../unified-shell-route-coverage/spec.md | 24 + .../specs/vue-vite-page-architecture/spec.md | 39 + src/mes_dashboard/app.py | 31 + src/mes_dashboard/routes/__init__.py | 3 + .../routes/reject_history_routes.py | 358 +++++++ .../services/reject_history_service.py | 676 ++++++++++++ .../services/scrap_reason_exclusion_cache.py | 236 +++++ .../sql/reject_history/export.sql | 36 + src/mes_dashboard/sql/reject_history/list.sql | 48 + .../reject_history/material_reason_option.sql | 19 + .../sql/reject_history/package_options.sql | 15 + .../sql/reject_history/performance_daily.sql | 12 +- .../sql/reject_history/reason_options.sql | 14 + .../sql/reject_history/reason_pareto.sql | 41 + .../sql/reject_history/summary.sql | 31 + .../sql/reject_history/trend.sql | 32 + tests/test_app_factory.py | 2 + .../test_portal_shell_wave_b_native_smoke.py | 130 +++ tests/test_reject_history_routes.py | 150 +++ tests/test_reject_history_service.py | 360 +++++++ tests/test_reject_history_shell_coverage.py | 38 + tests/test_scrap_reason_exclusion_cache.py | 67 ++ tests/test_template_integration.py | 20 +- 47 files changed, 4419 insertions(+), 73 deletions(-) create mode 100644 frontend/src/reject-history/App.vue create mode 100644 frontend/src/reject-history/index.html create mode 100644 frontend/src/reject-history/main.js create mode 100644 frontend/src/reject-history/style.css rename openspec/changes/{reject-history-query-page => archive/2026-02-13-reject-history-query-page}/.openspec.yaml (100%) rename openspec/changes/{reject-history-query-page => archive/2026-02-13-reject-history-query-page}/design.md (62%) rename openspec/changes/{reject-history-query-page => archive/2026-02-13-reject-history-query-page}/proposal.md (76%) rename openspec/changes/{reject-history-query-page => archive/2026-02-13-reject-history-query-page}/specs/field-name-consistency/spec.md (100%) rename openspec/changes/{reject-history-query-page => archive/2026-02-13-reject-history-query-page}/specs/reject-history-api/spec.md (73%) rename openspec/changes/{reject-history-query-page => archive/2026-02-13-reject-history-query-page}/specs/reject-history-page/spec.md (75%) rename openspec/changes/{reject-history-query-page => archive/2026-02-13-reject-history-query-page}/specs/reject-metric-semantics/spec.md (78%) rename openspec/changes/{reject-history-query-page => archive/2026-02-13-reject-history-query-page}/specs/unified-shell-route-coverage/spec.md (100%) rename openspec/changes/{reject-history-query-page => archive/2026-02-13-reject-history-query-page}/specs/vue-vite-page-architecture/spec.md (100%) create mode 100644 openspec/changes/archive/2026-02-13-reject-history-query-page/tasks.md delete mode 100644 openspec/changes/reject-history-query-page/tasks.md create mode 100644 openspec/specs/reject-history-api/spec.md create mode 100644 openspec/specs/reject-history-page/spec.md create mode 100644 openspec/specs/reject-metric-semantics/spec.md create mode 100644 src/mes_dashboard/routes/reject_history_routes.py create mode 100644 src/mes_dashboard/services/reject_history_service.py create mode 100644 src/mes_dashboard/services/scrap_reason_exclusion_cache.py create mode 100644 src/mes_dashboard/sql/reject_history/export.sql create mode 100644 src/mes_dashboard/sql/reject_history/list.sql create mode 100644 src/mes_dashboard/sql/reject_history/material_reason_option.sql create mode 100644 src/mes_dashboard/sql/reject_history/package_options.sql create mode 100644 src/mes_dashboard/sql/reject_history/reason_options.sql create mode 100644 src/mes_dashboard/sql/reject_history/reason_pareto.sql create mode 100644 src/mes_dashboard/sql/reject_history/summary.sql create mode 100644 src/mes_dashboard/sql/reject_history/trend.sql create mode 100644 tests/test_reject_history_routes.py create mode 100644 tests/test_reject_history_service.py create mode 100644 tests/test_reject_history_shell_coverage.py create mode 100644 tests/test_scrap_reason_exclusion_cache.py diff --git a/data/page_status.json b/data/page_status.json index dc37fa7..6925e2f 100644 --- a/data/page_status.json +++ b/data/page_status.json @@ -26,6 +26,13 @@ "drawer_id": "drawer-2", "order": 3 }, + { + "route": "/reject-history", + "name": "報廢歷史查詢", + "status": "dev", + "drawer_id": "drawer-2", + "order": 4 + }, { "route": "/wip-detail", "name": "WIP 明細", @@ -147,4 +154,4 @@ "admin_only": false } ] -} \ No newline at end of file +} diff --git a/docs/reject_history_performance.md b/docs/reject_history_performance.md index 97d4820..2b44621 100644 --- a/docs/reject_history_performance.md +++ b/docs/reject_history_performance.md @@ -7,6 +7,7 @@ - `DWH.DW_MES_LOTREJECTHISTORY`: 不良/報廢事實表(主來源) - `DWH.DW_MES_CONTAINER`: 補齊 `PJ_TYPE`、`PRODUCTLINENAME`、`MFGORDERNAME` - `DWH.DW_MES_SPEC_WORKCENTER_V`: 對應 `WORKCENTER_GROUP` 與排序欄位 +- `DWH.ERP_PJ_WIP_SCRAP_REASONS_EXCLUDE`: 良率排除政策表(`ENABLE_FLAG='Y'` 代表不納入良率計算) ## 資料評估重點(2026-02-13,近 30 天樣本) - `DW_MES_LOTREJECTHISTORY` 共 `230,074` 筆;`HISTORYMAINLINEID` 僅 `75,683` 個。 @@ -33,12 +34,69 @@ - `DEFECT_RATE_PCT = DEFECT_QTY / MOVEIN_QTY * 100` - `REJECT_SHARE_PCT = REJECT_TOTAL_QTY / (REJECT_TOTAL_QTY + DEFECT_QTY) * 100` +## 排除政策與前端開關 +- 預設模式:排除 `ERP_PJ_WIP_SCRAP_REASONS_EXCLUDE` 中 `ENABLE_FLAG='Y'` 的報廢原因。 +- 可切換模式:提供 `include_excluded_scrap=true|false` 讓使用者決定是否納入。 +- 前端頁面提供「納入不計良率報廢」開關,並同步影響 summary/trend/pareto/list/export。 +- 排除原因清單採全表快取,預設每日刷新一次(Redis 優先、記憶體 fallback)。 + +## API 與欄位契約 +- `GET /api/reject-history/options` + - 回傳 `workcenter_groups`、`reasons` 與政策 `meta` +- `GET /api/reject-history/summary` + - 回傳 `MOVEIN_QTY`、`REJECT_TOTAL_QTY`、`DEFECT_QTY`、`REJECT_RATE_PCT`、`DEFECT_RATE_PCT`、`REJECT_SHARE_PCT`、`AFFECTED_LOT_COUNT`、`AFFECTED_WORKORDER_COUNT` +- `GET /api/reject-history/trend` + - 回傳趨勢 `items[]`,每筆含 `bucket_date`、`REJECT_TOTAL_QTY`、`DEFECT_QTY`、`REJECT_RATE_PCT`、`DEFECT_RATE_PCT` +- `GET /api/reject-history/reason-pareto` + - 支援 `metric_mode=reject_total|defect` + - 支援 `pareto_scope=top80|all`(預設 `top80`) +- `GET /api/reject-history/list` + - 分頁回傳 `items[]` 與 `pagination` + - 明細保留五個 reject 欄位(`REJECT_QTY`、`STANDBY_QTY`、`QTYTOPROCESS_QTY`、`INPROCESS_QTY`、`PROCESSED_QTY`)與 `DEFECT_QTY` +- `GET /api/reject-history/export` + - CSV 欄位與 list 語義一致,含 `REJECT_TOTAL_QTY` 與 `DEFECT_QTY` + +## 前端視覺與互動 +- 主要區塊: + - Header(語義 badge + 更新時間) + - 篩選區(時間、原因、`WORKCENTER_GROUP`、政策開關、Pareto 前 80% 開關) + - KPI(8 張卡,Reject 暖色語義 / Defect 冷色語義) + - 趨勢圖(報廢量與報廢率分圖) + - Pareto(柱狀 + 累積線)與明細表 +- 互動規則: + - Pareto 點選原因後,會套用為 active filter chip 並重查 + - 再次點選同原因會取消篩選 + - 預設僅顯示累計前 80%,可切換顯示完整 Pareto + - 匯出 CSV 使用目前畫面相同篩選條件 + ## 交付檔案 - 建表 + 刷新 SQL:`docs/reject_history_performance.sql` - 可被應用層直接載入的查詢 SQL:`src/mes_dashboard/sql/reject_history/performance_daily.sql` +## 上線與回滾策略 +- 上線策略: + - 先維持 `data/page_status.json` 中 `/reject-history` 為 `dev` + - 完成 UAT 後再改為 `released` +- 回滾策略: + - 將 `/reject-history` 狀態切回 `dev` 或移除導航入口 + - 保留 API 與既有頁面,不影響既有報表 +- 快取策略: + - 排除政策表每日全表刷新(預設 86400 秒) + - Redis 異常時退回記憶體快取,不阻斷查詢 + +## 驗證紀錄(2026-02-13) +- 後端/整合測試: + - `pytest -q tests/test_reject_history_service.py tests/test_scrap_reason_exclusion_cache.py tests/test_reject_history_routes.py tests/test_reject_history_shell_coverage.py tests/test_portal_shell_wave_b_native_smoke.py::test_reject_history_native_smoke_query_sections_and_export tests/test_app_factory.py::AppFactoryTests::test_routes_registered` + - 結果:`22 passed` +- 前端建置: + - `cd frontend && npm run build` + - 結果:成功產出 `reject-history.html/js/css`,並完成 dist 複製流程 + ## 建議排程 - 每日跑前一日增量: - `:start_date = TRUNC(SYSDATE - 1)` - `:end_date = TRUNC(SYSDATE - 1)` - 每月第一天補跑前 31 天,避免補數漏失。 + +## 已知環境備註 +- `tests/test_navigation_contract.py` 需要 `docs/migration/portal-no-iframe/baseline_drawer_visibility.json`。目前工作區缺少此 baseline 檔案,屬既有環境缺口,與本次 reject-history 開發內容無直接耦合。 diff --git a/frontend/src/portal-shell/nativeModuleRegistry.js b/frontend/src/portal-shell/nativeModuleRegistry.js index 5a16462..9e8e3d3 100644 --- a/frontend/src/portal-shell/nativeModuleRegistry.js +++ b/frontend/src/portal-shell/nativeModuleRegistry.js @@ -34,6 +34,10 @@ const NATIVE_MODULE_LOADERS = Object.freeze({ () => import('../hold-history/App.vue'), [() => import('../wip-shared/styles.css'), () => import('../hold-history/style.css')], ), + '/reject-history': createNativeLoader( + () => import('../reject-history/App.vue'), + [() => import('../wip-shared/styles.css'), () => import('../reject-history/style.css')], + ), '/resource': createNativeLoader( () => import('../resource-status/App.vue'), [() => import('../resource-shared/styles.css'), () => import('../resource-status/style.css')], diff --git a/frontend/src/portal-shell/routeContracts.js b/frontend/src/portal-shell/routeContracts.js index 21a54ab..9da0493 100644 --- a/frontend/src/portal-shell/routeContracts.js +++ b/frontend/src/portal-shell/routeContracts.js @@ -4,6 +4,7 @@ const IN_SCOPE_REPORT_ROUTES = Object.freeze([ '/hold-overview', '/hold-detail', '/hold-history', + '/reject-history', '/resource', '/resource-history', '/qc-gate', @@ -109,6 +110,17 @@ const ROUTE_CONTRACTS = Object.freeze({ scope: 'in-scope', compatibilityPolicy: 'redirect_to_shell_when_spa_enabled', }), + '/reject-history': buildContract({ + route: '/reject-history', + routeId: 'reject-history', + renderMode: 'native', + owner: 'frontend-mes-reporting', + title: '報廢歷史查詢', + rollbackStrategy: 'fallback_to_legacy_route', + visibilityPolicy: 'released_or_admin', + scope: 'in-scope', + compatibilityPolicy: 'redirect_to_shell_when_spa_enabled', + }), '/resource': buildContract({ route: '/resource', routeId: 'resource', diff --git a/frontend/src/reject-history/App.vue b/frontend/src/reject-history/App.vue new file mode 100644 index 0000000..5a50e5d --- /dev/null +++ b/frontend/src/reject-history/App.vue @@ -0,0 +1,968 @@ + + + diff --git a/frontend/src/reject-history/index.html b/frontend/src/reject-history/index.html new file mode 100644 index 0000000..bdaa38c --- /dev/null +++ b/frontend/src/reject-history/index.html @@ -0,0 +1,12 @@ + + + + + + 報廢歷史查詢 + + +
+ + + diff --git a/frontend/src/reject-history/main.js b/frontend/src/reject-history/main.js new file mode 100644 index 0000000..badc9a1 --- /dev/null +++ b/frontend/src/reject-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/reject-history/style.css b/frontend/src/reject-history/style.css new file mode 100644 index 0000000..917705f --- /dev/null +++ b/frontend/src/reject-history/style.css @@ -0,0 +1,418 @@ +.reject-history-header { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); +} + +.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-panel { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 14px; + align-items: end; +} + +.filter-group { + display: flex; + flex-direction: column; + gap: 8px; +} + +.filter-group-wide { + grid-column: span 2; +} + +.filter-label { + font-size: 12px; + font-weight: 700; + color: #475569; +} + +.filter-input { + width: 100%; + padding: 8px 10px; + border: 1px solid var(--border); + border-radius: 8px; + font-size: 13px; + background: #fff; +} + +.filter-input:focus { + outline: none; + border-color: #0ea5e9; + box-shadow: 0 0 0 2px rgba(14, 165, 233, 0.18); +} + +.filter-group .multi-select { + width: 100%; + min-width: 0; +} + +.inline-toggle-group { + align-self: center; +} + +.checkbox-row { + display: inline-flex; + align-items: center; + flex-wrap: wrap; + gap: 10px; +} + +.checkbox-pill { + display: inline-flex; + align-items: center; + gap: 6px; + border: 1px solid var(--border); + border-radius: 999px; + padding: 6px 10px; + font-size: 13px; + color: #334155; + background: #f8fafc; +} + +.checkbox-pill input[type='checkbox'] { + margin: 0; + width: 14px; + height: 14px; + accent-color: #2563eb; +} + +.filter-actions { + display: flex; + gap: 10px; + justify-content: flex-end; + grid-column: span 2; +} + +.active-filter-chip-row { + display: flex; + flex-direction: column; + gap: 8px; + border-top: 1px solid var(--border); +} + +.chip-list { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.filter-chip { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 5px 10px; + border-radius: 999px; + border: 1px solid #cbd5e1; + background: #f8fafc; + font-size: 12px; + color: #334155; +} + +.chip-remove { + border: 0; + background: transparent; + color: #475569; + cursor: pointer; + font-size: 14px; + line-height: 1; +} + +.btn-export { + background: #0f766e; + color: #fff; +} + +.btn-export:hover { + background: #0b5e59; +} + +.reject-summary-row { + grid-template-columns: repeat(6, minmax(0, 1fr)); +} + +.lane-reject { + border-top: 3px solid #dc2626; +} + +.lane-defect { + border-top: 3px solid #0284c7; +} + +.lane-neutral { + border-top: 3px solid #64748b; +} + +.chart-grid { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 14px; + margin-bottom: 14px; +} + +.chart-wrap, +.pareto-chart-wrap { + height: 340px; + position: relative; +} + +.pareto-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; +} + +.pareto-layout { + display: grid; + grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr); + gap: 12px; +} + +.pareto-table-wrap { + overflow: auto; +} + +.detail-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; +} + +.detail-table th, +.detail-table td { + border-bottom: 1px solid var(--border); + padding: 8px 10px; + text-align: left; + vertical-align: middle; + white-space: nowrap; +} + +.detail-table thead th { + position: sticky; + top: 0; + background: #f8fafc; + z-index: 1; +} + +.reason-link { + border: none; + background: transparent; + color: #1d4ed8; + cursor: pointer; + text-decoration: underline; + font-size: 12px; +} + +.pareto-table tbody tr.active { + background: #eff6ff; +} + +.detail-table-wrap { + overflow: auto; +} + +/* ---- MultiSelect component styles (shared-ui compatible) ---- */ + +.multi-select { + position: relative; + min-width: 160px; +} + +.multi-select-trigger { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + border: 1px solid var(--border); + border-radius: 8px; + padding: 8px 10px; + font-size: 13px; + color: #1f2937; + background: #ffffff; + cursor: pointer; +} + +.multi-select-trigger:disabled { + cursor: not-allowed; + opacity: 0.7; +} + +.multi-select-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: left; +} + +.multi-select-arrow { + color: #64748b; + font-size: 11px; +} + +.multi-select-dropdown { + position: absolute; + top: calc(100% + 4px); + left: 0; + right: 0; + z-index: 20; + border: 1px solid var(--border); + border-radius: 8px; + background: #ffffff; + box-shadow: 0 12px 24px rgba(15, 23, 42, 0.14); + overflow: hidden; +} + +.multi-select-search { + display: block; + width: 100%; + border: none; + border-bottom: 1px solid var(--border); + padding: 8px 12px; + font-size: 13px; + color: #1f2937; + outline: none; + background: #f8fafc; +} + +.multi-select-search::placeholder { + color: #94a3b8; +} + +.multi-select-options { + max-height: 250px; + overflow-y: auto; + padding: 8px 0; +} + +.multi-select-option { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 6px 12px; + border: none; + background: transparent; + font-size: 13px; + color: #334155; + cursor: pointer; + text-align: left; +} + +.multi-select-option:hover { + background: #f8fafc; +} + +.multi-select-option input[type='checkbox'] { + margin: 0; + width: 14px; + height: 14px; + accent-color: #2563eb; +} + +.multi-select-empty { + padding: 12px; + text-align: center; + color: #94a3b8; + font-size: 13px; +} + +.multi-select-actions { + display: flex; + gap: 8px; + padding: 8px 10px; + border-top: 1px solid var(--border); + background: #f8fafc; +} + +.btn-sm { + padding: 4px 10px; + border: 1px solid var(--border); + border-radius: 6px; + background: #f8fafc; + color: var(--text); + cursor: pointer; + font-size: 12px; +} + +.btn-sm:hover { + border-color: #c2d0e0; + background: #eef4fb; +} + +@media (max-width: 1400px) { + .reject-summary-row { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +@media (max-width: 1180px) { + .filter-panel { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .filter-group-wide { + grid-column: span 2; + } + + .filter-actions { + grid-column: span 2; + justify-content: flex-start; + } + + .pareto-layout { + grid-template-columns: 1fr; + } +} + +@media (max-width: 760px) { + .reject-summary-row { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .filter-panel { + grid-template-columns: 1fr; + } + + .filter-group-wide, + .filter-actions { + grid-column: span 1; + } + + .checkbox-row { + flex-direction: column; + align-items: flex-start; + } +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js index baf4096..8d13109 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -19,6 +19,7 @@ export default defineConfig(({ mode }) => ({ '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'), + 'reject-history': resolve(__dirname, 'src/reject-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/reject-history-query-page/.openspec.yaml b/openspec/changes/archive/2026-02-13-reject-history-query-page/.openspec.yaml similarity index 100% rename from openspec/changes/reject-history-query-page/.openspec.yaml rename to openspec/changes/archive/2026-02-13-reject-history-query-page/.openspec.yaml diff --git a/openspec/changes/reject-history-query-page/design.md b/openspec/changes/archive/2026-02-13-reject-history-query-page/design.md similarity index 62% rename from openspec/changes/reject-history-query-page/design.md rename to openspec/changes/archive/2026-02-13-reject-history-query-page/design.md index edde5da..a09440d 100644 --- a/openspec/changes/reject-history-query-page/design.md +++ b/openspec/changes/archive/2026-02-13-reject-history-query-page/design.md @@ -1,6 +1,6 @@ ## Context -目前 `query-tool` 僅提供單點查詢 reject 資訊,沒有針對歷史趨勢、原因分布與績效指標的完整頁面。`DW_MES_LOTREJECTHISTORY` 存在同一 `HISTORYMAINLINEID` 對應多筆原因紀錄的特性,直接彙總 `MOVEINQTY` 會造成分母膨脹,讓報廢率失真。另一方面,現有語意中「reject 五欄合計」與 `DEFECTQTY` 曾被混用,導致跨頁面解讀不一致。 +目前 `query-tool` 僅提供單點查詢 reject 資訊,沒有針對歷史趨勢、原因分布與績效指標的完整頁面。`DW_MES_LOTREJECTHISTORY` 存在同一 `HISTORYMAINLINEID` 對應多筆原因紀錄的特性,直接彙總 `MOVEINQTY` 會造成分母膨脹,讓報廢率失真。另一方面,現有語意中「reject 五欄合計」與 `DEFECTQTY` 曾被混用,導致跨頁面解讀不一致。新增可用資料表 `ERP_PJ_WIP_SCRAP_REASONS_EXCLUDE` 也帶來新政策需求:`ENABLE_FLAG='Y'` 的報廢原因應預設不納入良率計算,並允許使用者切換是否納入。 此變更需要跨越前端(新報表頁)、後端(新 API + service + SQL)、與治理層(route contract、drawer/page registry、coverage test),屬於跨模組整合型設計。 @@ -11,6 +11,7 @@ - 固化兩條指標語義並列: - 扣帳報廢 `REJECT_TOTAL_QTY` - 不扣帳報廢 `DEFECT_QTY` +- 將 `ERP_PJ_WIP_SCRAP_REASONS_EXCLUDE`(`ENABLE_FLAG='Y'`)納入良率排除政策,且提供 UI/API 可切換納入模式。 - 以事件層級去重 `MOVEIN_QTY`,避免因 `HISTORYMAINLINEID` 多筆造成比率失真。 - 完整納入現有 pure Vite + portal-shell + route contract 治理流程。 - 在視覺上清楚區分 reject 與 defect,避免操作端誤判。 @@ -63,12 +64,18 @@ **Visual structure:** - Header:漸層標題區,顯示頁名、資料更新時間、語義說明 badge(扣帳/不扣帳)。 -- Filter Card:日期區間 + 維度篩選(站群/站點/產品線/原因),含查詢與清除。 +- Filter Card:至少提供三個核心篩選器並含查詢/清除動作: + - 時間篩選(`start_date`/`end_date`) + - 原因篩選(`LOSSREASONNAME`) + - `WORKCENTER_GROUP` 篩選(沿用既有頁面篩選體驗與資料來源) - KPI Row(8 卡):`MOVEIN_QTY`、`REJECT_TOTAL_QTY`、`DEFECT_QTY`、兩種 rate、`REJECT_SHARE_PCT`、受影響 lot/workorder。 - Trend Row: - 左圖:`REJECT_TOTAL_QTY` vs `DEFECT_QTY`(量) - 右圖:`REJECT_RATE_PCT` vs `DEFECT_RATE_PCT`(率) -- Pareto + Detail:原因 Pareto(支援 metric mode 切換)與可分頁明細表。 +- Pareto + Detail:報廢量 vs 報廢原因 Pareto(支援 metric mode)與可分頁明細表。 + - Pareto 預設啟用「僅顯示累計前 80%」模式(以目前篩選後資料集計算)。 + - 提供開關切換完整 Pareto;關閉前 80% 模式時,顯示篩選後的全部原因。 + - 視覺與互動可參考 `WIP OVER VIEW` 既有 Pareto 呈現方式,保持使用者認知一致。 **Visual semantics:** - Reject(扣帳)使用暖色語義(紅/橘系) @@ -96,21 +103,60 @@ - 報表是對外分析依據,語義清晰優先於短期縮寫便利。 - 與 field-name-consistency 治理要求一致。 +### D7: 納入「不計良率報廢」政策並提供可切換模式 + +**Decision:** 以 `ERP_PJ_WIP_SCRAP_REASONS_EXCLUDE` 中 `ENABLE_FLAG='Y'` 為政策清單,預設排除該類報廢於良率相關計算,並新增 `include_excluded_scrap` 參數(預設 `false`)讓使用者可選擇納入。 + +**Policy scope:** +- 影響 summary/trend/reason-pareto/list/export 的同一套查詢語義。 +- 預設模式下,標記為排除的報廢原因不進入良率計算;切換納入後,回到完整資料集計算。 +- API 回應 `meta` 顯示目前是否啟用排除政策,前端在 filter 區顯示「納入不計良率報廢」切換開關。 + +**Why:** +- 業務規則已明示 `ENABLE_FLAG='Y'` 代表不納入良率計算,應在報表層落地。 +- 提供切換能兼顧日常看板(排除模式)與稽核追查(納入模式)。 + +**Alternatives considered:** +- 永久硬排除:簡單但不利追溯與跨單位對帳。 +- 完全不排除:違反既有良率定義。 + +### D8: 排除清單採「L2 Redis + L1 記憶體」每日全表快取 + +**Decision:** 新增 `scrap_reason_exclusion_cache` 模組,採現有快取分層模式:Redis 作為跨 worker 共用快取(L2),process memory 作為快速讀取層(L1);每日全表刷新一次,啟動時先載入。 + +**Refresh policy:** +- 啟動時進行首次載入。 +- 每 24 小時刷新一次(可由環境變數覆寫)。 +- Redis 不可用時,自動退化為 in-memory 快取,並在健康檢查/日誌揭露降級狀態。 + +**Why:** +- 表筆數小(目前約 36 筆),適合全表快取,不需每次 query join DB。 +- 共享式 Redis 可避免多 gunicorn worker 間資料不一致。 +- 延續專案既有快取策略,降低維運認知成本。 + +**Alternatives considered:** +- 僅記憶體快取:實作最簡單,但多 worker 會各自持有版本。 +- 每次即時查表:邏輯單純,但額外增加 Oracle 往返成本。 + ## Risks / Trade-offs - **[基底 SQL 單一來源造成查詢負載偏高]** → 先以日期與維度條件收斂、list/export 加 rate limit,必要時再追加快取或物化。 - **[使用者沿用舊語意理解 defect]** → UI 顯示語義說明 badge + tooltip,匯出欄位採顯式命名。 - **[Pareto 指標切換造成理解成本]** → 預設以 `REJECT_TOTAL_QTY` 顯示,並保留清楚的 toggle label。 +- **[報廢原因對應鍵格式不一致]** → 在 service 層加入 reason normalization 規則(trim/大小寫一致化,必要時切取代碼前綴),並在測試覆蓋。 +- **[排除政策切換導致跨報表數值差異爭議]** → API/前端都回傳並顯示 `include_excluded_scrap` 狀態與政策提示文字。 +- **[Redis 不可用導致快取行為不一致]** → 採 L1 fallback,並透過 health/admin 狀態揭露快取降級。 - **[路由治理漏登記導致 shell 無法導航]** → contract parity test + page_status 驗證列為必做任務。 - **[明細資料量大造成前端卡頓]** → 後端分頁、預設 `per_page=50`,並避免一次性全量載入。 ## Migration Plan 1. 建立後端 SQL/service/routes(先讓 API 可單獨驗證)。 -2. 建立前端 reject-history 頁面與元件(先接 summary/trend,再接 pareto/list/export)。 -3. 整合 shell 治理資產:`routeContracts`、`nativeModuleRegistry`、`page_status`、Flask page route。 -4. 補齊測試:service、routes、route-contract parity、前端 smoke。 -5. 先以 `dev` 狀態上線到抽屜,完成 UAT 後調整為 `released`。 +2. 建立 `scrap_reason_exclusion_cache`(全表快取 + 每日刷新 + fallback)。 +3. 建立前端 reject-history 頁面與元件(先接 summary/trend,再接 pareto/list/export)。 +4. 整合 shell 治理資產:`routeContracts`、`nativeModuleRegistry`、`page_status`、Flask page route。 +5. 補齊測試:service、routes、cache、route-contract parity、前端 smoke。 +6. 先以 `dev` 狀態上線到抽屜,完成 UAT 後調整為 `released`。 **Rollback strategy:** - 將 `/reject-history` 從 page registry 標記為隱藏或 `dev` 並停用導航入口。 diff --git a/openspec/changes/reject-history-query-page/proposal.md b/openspec/changes/archive/2026-02-13-reject-history-query-page/proposal.md similarity index 76% rename from openspec/changes/reject-history-query-page/proposal.md rename to openspec/changes/archive/2026-02-13-reject-history-query-page/proposal.md index b9fa7b1..4257f7f 100644 --- a/openspec/changes/reject-history-query-page/proposal.md +++ b/openspec/changes/archive/2026-02-13-reject-history-query-page/proposal.md @@ -1,6 +1,6 @@ ## Why -目前專案僅在 `query-tool` 提供偏即時/點查型的報廢資訊,缺少可追蹤趨勢與績效的「報廢歷史」專用報表。資料評估也顯示 `DW_MES_LOTREJECTHISTORY` 同一 `HISTORYMAINLINEID` 會對應多筆原因紀錄,若直接加總 `MOVEINQTY` 會造成分母重複、報廢率失真;同時既有查詢對 reject/defect 命名語義不一致,容易誤解指標。現在應在既有 portal-shell + Vite + route contract 架構下,建立一個語義明確且可治理的歷史報表頁。 +目前專案僅在 `query-tool` 提供偏即時/點查型的報廢資訊,缺少可追蹤趨勢與績效的「報廢歷史」專用報表。資料評估也顯示 `DW_MES_LOTREJECTHISTORY` 同一 `HISTORYMAINLINEID` 會對應多筆原因紀錄,若直接加總 `MOVEINQTY` 會造成分母重複、報廢率失真;同時既有查詢對 reject/defect 命名語義不一致,容易誤解指標。另 IT 新開放 `ERP_PJ_WIP_SCRAP_REASONS_EXCLUDE`,其中 `ENABLE_FLAG='Y'` 代表「不納入良率計算」報廢原因,提案需把此政策納入計算流程。現在應在既有 portal-shell + Vite + route contract 架構下,建立一個語義明確且可治理的歷史報表頁。 ## What Changes @@ -10,6 +10,10 @@ - 扣帳報廢:`REJECT_TOTAL_QTY = REJECTQTY + STANDBYQTY + QTYTOPROCESS + INPROCESSQTY + PROCESSEDQTY` - 不扣帳報廢:`DEFECT_QTY = DEFECTQTY` - 以事件層級去重規則處理分母(`MOVEIN_QTY` 以 `HISTORYMAINLINEID` 為主鍵去重),避免多原因拆單導致比率失真。 +- 納入 `ERP_PJ_WIP_SCRAP_REASONS_EXCLUDE` 政策(`ENABLE_FLAG='Y'`): + - 預設排除「不納入良率計算」報廢原因 + - 提供可選開關讓使用者決定是否納入該類報廢 +- 新增排除清單全表快取(每日一次)機制,採共享快取優先策略,降低每次查詢重複讀表成本。 - 明確定義 UI/API/匯出欄位語義,避免沿用「defect=五欄合計」這類歷史命名混淆,確保報表對外語意一致。 - 不變更既有 `query-tool` 現有頁面行為與既有 API 回應欄位(此變更先聚焦新頁能力)。 @@ -19,6 +23,7 @@ - `reject-history-page`: 新增報廢歷史查詢頁面,提供篩選、KPI、趨勢、原因分析、明細查詢與匯出。 - `reject-history-api`: 新增報廢歷史 API 能力與資料聚合邏輯,支援報表層的摘要、趨勢、Pareto、明細資料來源。 - `reject-metric-semantics`: 新增 reject/defect 指標語義規範,要求五個 reject 欄位合計與 `DEFECTQTY` 必須分開呈現、分開計算、分開命名。 +- `reject-yield-exclusion-policy`: 新增「不納入良率計算」政策能力,依 `ERP_PJ_WIP_SCRAP_REASONS_EXCLUDE.ENABLE_FLAG='Y'` 控制預設排除,並支援使用者切換是否納入。 ### Modified Capabilities - `unified-shell-route-coverage`: 新增 `/reject-history` 後,路由契約清單與前後端契約對照規則需同步更新。 @@ -35,6 +40,7 @@ - 後端: - 新增 `src/mes_dashboard/routes/reject_history_routes.py` - 新增 `src/mes_dashboard/services/reject_history_service.py` + - 新增 `src/mes_dashboard/services/scrap_reason_exclusion_cache.py` - 新增 `src/mes_dashboard/sql/reject_history/*.sql` - 更新 `src/mes_dashboard/routes/__init__.py` - 更新 `src/mes_dashboard/app.py`(`/reject-history` 靜態頁 route) @@ -44,9 +50,11 @@ - 測試: - 新增 `tests/test_reject_history_service.py` - 新增 `tests/test_reject_history_routes.py` + - 新增 `tests/test_scrap_reason_exclusion_cache.py` - 補充 route coverage / contract parity / e2e smoke - 資料語義: - 報表需同時呈現 `REJECT_TOTAL_QTY`(扣帳報廢)與 `DEFECT_QTY`(不扣帳報廢) + - `ENABLE_FLAG='Y'` 報廢原因預設不納入良率計算,且可由使用者選擇改為納入 - 不以單一欄位混用兩種語義,避免誤判製程損失 - 依賴: - 不新增第三方套件,沿用現有 Flask + Vue + Vite + SQLLoader + QueryBuilder 架構 diff --git a/openspec/changes/reject-history-query-page/specs/field-name-consistency/spec.md b/openspec/changes/archive/2026-02-13-reject-history-query-page/specs/field-name-consistency/spec.md similarity index 100% rename from openspec/changes/reject-history-query-page/specs/field-name-consistency/spec.md rename to openspec/changes/archive/2026-02-13-reject-history-query-page/specs/field-name-consistency/spec.md diff --git a/openspec/changes/reject-history-query-page/specs/reject-history-api/spec.md b/openspec/changes/archive/2026-02-13-reject-history-query-page/specs/reject-history-api/spec.md similarity index 73% rename from openspec/changes/reject-history-query-page/specs/reject-history-api/spec.md rename to openspec/changes/archive/2026-02-13-reject-history-query-page/specs/reject-history-api/spec.md index 81f2390..0b731d6 100644 --- a/openspec/changes/reject-history-query-page/specs/reject-history-api/spec.md +++ b/openspec/changes/archive/2026-02-13-reject-history-query-page/specs/reject-history-api/spec.md @@ -19,6 +19,23 @@ The API SHALL provide aggregated summary metrics for the selected filter context - **THEN** response SHALL be `{ success: true, data: { ... } }` - **THEN** data SHALL include `MOVEIN_QTY`, `REJECT_TOTAL_QTY`, `DEFECT_QTY`, `REJECT_RATE_PCT`, `DEFECT_RATE_PCT`, `REJECT_SHARE_PCT`, `AFFECTED_LOT_COUNT`, and `AFFECTED_WORKORDER_COUNT` +### Requirement: Reject History API SHALL support yield-exclusion policy toggle +The API SHALL support excluding or including policy-marked scrap reasons through a shared query parameter. + +#### Scenario: Default policy mode +- **WHEN** reject-history endpoints are called without `include_excluded_scrap` +- **THEN** `include_excluded_scrap` SHALL default to `false` +- **THEN** rows mapped to `ERP_PJ_WIP_SCRAP_REASONS_EXCLUDE.ENABLE_FLAG='Y'` SHALL be excluded from yield-related calculations + +#### Scenario: Explicitly include policy-marked scrap +- **WHEN** `include_excluded_scrap=true` is provided +- **THEN** policy-marked rows SHALL be included in summary/trend/pareto/list/export calculations +- **THEN** API response `meta` SHALL include the effective `include_excluded_scrap` value + +#### Scenario: Invalid toggle value +- **WHEN** `include_excluded_scrap` is not parseable as boolean +- **THEN** the API SHALL return HTTP 400 with a descriptive validation error + ### Requirement: Reject History API SHALL provide trend endpoint The API SHALL return time-series trend data for quantity and rate metrics. @@ -76,6 +93,18 @@ The service SHALL load SQL from dedicated files under `src/mes_dashboard/sql/rej - **THEN** user-supplied filters SHALL be passed through bind parameters - **THEN** user input SHALL NOT be interpolated into SQL strings directly +### Requirement: Reject History API SHALL use cached exclusion-policy source +The API SHALL read exclusion-policy reasons from cached `ERP_PJ_WIP_SCRAP_REASONS_EXCLUDE` data instead of querying Oracle on every request. + +#### Scenario: Enabled exclusions only +- **WHEN** exclusion-policy data is loaded +- **THEN** only rows with `ENABLE_FLAG='Y'` SHALL be treated as active exclusions + +#### Scenario: Daily full-table cache refresh +- **WHEN** exclusion cache is initialized +- **THEN** the full table SHALL be loaded and refreshed at least once per 24 hours +- **THEN** Redis SHOULD be used as shared cache when available, with in-memory fallback when unavailable + ### Requirement: Reject History API SHALL apply rate limiting on expensive endpoints The API SHALL rate-limit high-cost endpoints to protect Oracle and application resources. diff --git a/openspec/changes/reject-history-query-page/specs/reject-history-page/spec.md b/openspec/changes/archive/2026-02-13-reject-history-query-page/specs/reject-history-page/spec.md similarity index 75% rename from openspec/changes/reject-history-query-page/specs/reject-history-page/spec.md rename to openspec/changes/archive/2026-02-13-reject-history-query-page/specs/reject-history-page/spec.md index c71fadc..94332f3 100644 --- a/openspec/changes/reject-history-query-page/specs/reject-history-page/spec.md +++ b/openspec/changes/archive/2026-02-13-reject-history-query-page/specs/reject-history-page/spec.md @@ -14,6 +14,29 @@ The page SHALL provide a filter area for date range and major production dimensi - **WHEN** user clicks "清除條件" - **THEN** all filters SHALL reset to defaults and all sections SHALL reload +#### Scenario: Required core filters are present +- **WHEN** the filter panel is rendered +- **THEN** it SHALL include `start_date/end_date` time filter controls +- **THEN** it SHALL include reason filter control +- **THEN** it SHALL include `WORKCENTER_GROUP` filter control + +### Requirement: Reject History page SHALL expose yield-exclusion toggle control +The page SHALL let users decide whether to include policy-marked scrap in yield calculations. + +#### Scenario: Default toggle state +- **WHEN** the page is first loaded +- **THEN** "納入不計良率報廢" toggle SHALL default to OFF +- **THEN** requests SHALL be sent with `include_excluded_scrap=false` + +#### Scenario: Toggle affects all sections +- **WHEN** user turns ON/OFF the toggle and clicks "查詢" +- **THEN** summary, trend, pareto, and list sections SHALL reload under the selected policy mode +- **THEN** export action SHALL use the same toggle state + +#### Scenario: Policy status visibility +- **WHEN** data is rendered +- **THEN** the UI SHALL show a clear badge/text indicating whether policy-marked scrap is currently excluded or included + ### Requirement: Reject History page SHALL present KPI cards with split reject/defect semantics The page SHALL display KPI cards that simultaneously show charge-off reject and non-charge-off defect metrics. @@ -49,6 +72,16 @@ The page SHALL provide a Pareto view for loss reasons and support downstream fil - **THEN** items SHALL be sorted by selected metric descending - **THEN** a cumulative percentage line SHALL be shown +#### Scenario: Default 80% cumulative display mode +- **WHEN** the page first loads Pareto +- **THEN** it SHALL default to "only cumulative top 80%" mode +- **THEN** Pareto SHALL only render categories within the cumulative 80% threshold under current filters + +#### Scenario: Full Pareto toggle mode +- **WHEN** user turns OFF the 80% cumulative display mode +- **THEN** Pareto SHALL render all categories after applying current filters +- **THEN** switching mode SHALL NOT reset existing time/reason/workcenter-group filters + #### Scenario: Pareto click filtering - **WHEN** user clicks a Pareto bar or row - **THEN** the selected reason SHALL become an active filter chip diff --git a/openspec/changes/reject-history-query-page/specs/reject-metric-semantics/spec.md b/openspec/changes/archive/2026-02-13-reject-history-query-page/specs/reject-metric-semantics/spec.md similarity index 78% rename from openspec/changes/reject-history-query-page/specs/reject-metric-semantics/spec.md rename to openspec/changes/archive/2026-02-13-reject-history-query-page/specs/reject-metric-semantics/spec.md index 09f6b21..35dae4e 100644 --- a/openspec/changes/reject-history-query-page/specs/reject-metric-semantics/spec.md +++ b/openspec/changes/archive/2026-02-13-reject-history-query-page/specs/reject-metric-semantics/spec.md @@ -16,6 +16,21 @@ The system SHALL compute `DEFECT_QTY` only from `DEFECTQTY` and SHALL NOT merge - **THEN** `DEFECT_QTY` SHALL be non-zero - **THEN** `REJECT_TOTAL_QTY` SHALL remain 0 +### Requirement: Yield-exclusion policy SHALL follow ERP exclusion table +The system SHALL use `ERP_PJ_WIP_SCRAP_REASONS_EXCLUDE` as the policy source for "not included in yield" scrap reasons. + +#### Scenario: Enabled policy rows +- **WHEN** exclusion policy is evaluated +- **THEN** only rows with `ENABLE_FLAG='Y'` SHALL be considered exclusion rules + +#### Scenario: Default exclusion behavior +- **WHEN** `include_excluded_scrap=false` (default) +- **THEN** source rows matching enabled exclusion reasons SHALL be excluded before computing yield-related metrics + +#### Scenario: Optional inclusion override +- **WHEN** `include_excluded_scrap=true` +- **THEN** the same matched rows SHALL be included back into metric calculations + ### Requirement: Move-in denominator SHALL be deduplicated at event level The system SHALL deduplicate `MOVEIN_QTY` by event key before rate calculations. diff --git a/openspec/changes/reject-history-query-page/specs/unified-shell-route-coverage/spec.md b/openspec/changes/archive/2026-02-13-reject-history-query-page/specs/unified-shell-route-coverage/spec.md similarity index 100% rename from openspec/changes/reject-history-query-page/specs/unified-shell-route-coverage/spec.md rename to openspec/changes/archive/2026-02-13-reject-history-query-page/specs/unified-shell-route-coverage/spec.md diff --git a/openspec/changes/reject-history-query-page/specs/vue-vite-page-architecture/spec.md b/openspec/changes/archive/2026-02-13-reject-history-query-page/specs/vue-vite-page-architecture/spec.md similarity index 100% rename from openspec/changes/reject-history-query-page/specs/vue-vite-page-architecture/spec.md rename to openspec/changes/archive/2026-02-13-reject-history-query-page/specs/vue-vite-page-architecture/spec.md diff --git a/openspec/changes/archive/2026-02-13-reject-history-query-page/tasks.md b/openspec/changes/archive/2026-02-13-reject-history-query-page/tasks.md new file mode 100644 index 0000000..3d849c0 --- /dev/null +++ b/openspec/changes/archive/2026-02-13-reject-history-query-page/tasks.md @@ -0,0 +1,60 @@ +## 1. Contract and Skeleton Setup + +- [x] 1.1 Create backend blueprint scaffold `src/mes_dashboard/routes/reject_history_routes.py` and register it in `src/mes_dashboard/routes/__init__.py` +- [x] 1.2 Create service scaffold `src/mes_dashboard/services/reject_history_service.py` with SQL loader helpers +- [x] 1.3 Create frontend entry scaffold `frontend/src/reject-history/index.html`, `frontend/src/reject-history/main.js`, and `frontend/src/reject-history/App.vue` +- [x] 1.4 Add Vite input for `reject-history` in `frontend/vite.config.js` + +## 2. SQL and Metric Semantics Implementation + +- [x] 2.1 Finalize base query `src/mes_dashboard/sql/reject_history/performance_daily.sql` for five-reject-sum + defect separation +- [x] 2.2 Add API-specific SQL files in `src/mes_dashboard/sql/reject_history/` (summary, trend, reason_pareto, list, export) +- [x] 2.3 Implement `MOVEIN_QTY` dedupe by `HISTORYMAINLINEID` with deterministic fallback key +- [x] 2.4 Implement consistent rate calculations (`REJECT_RATE_PCT`, `DEFECT_RATE_PCT`, `REJECT_SHARE_PCT`) with zero-denominator handling +- [x] 2.5 Integrate `ERP_PJ_WIP_SCRAP_REASONS_EXCLUDE` policy mapping (`ENABLE_FLAG='Y'`) into reject-history aggregation flow +- [x] 2.6 Create `src/mes_dashboard/services/scrap_reason_exclusion_cache.py` with daily full-table refresh (Redis preferred + in-memory fallback) + +## 3. Backend API Routes + +- [x] 3.1 Implement `GET /api/reject-history/summary` with date/filter validation +- [x] 3.2 Implement `GET /api/reject-history/trend` with `granularity` validation (`day|week|month`) +- [x] 3.3 Implement `GET /api/reject-history/reason-pareto` with `metric_mode` validation (`reject_total|defect`) +- [x] 3.4 Implement `GET /api/reject-history/list` with paging bounds and reason/category filters +- [x] 3.5 Implement `GET /api/reject-history/export` and CSV output contract +- [x] 3.6 Apply configured rate limiting to list/export endpoints +- [x] 3.7 Add shared query param `include_excluded_scrap` (default false) and return effective policy mode in response meta + +## 4. Frontend Visual and Interaction Implementation + +- [x] 4.1 Build page header with title, data timestamp, and semantic badges for charge-off reject vs non-charge-off defect +- [x] 4.2 Build filter panel with required controls (`start_date/end_date`, reason, `WORKCENTER_GROUP`) plus query/clear actions, and wire it to all API calls +- [x] 4.3 Implement KPI card row (8 cards) with warm/cool semantic color lanes and zh-TW number formatting +- [x] 4.4 Implement dual trend charts (quantity trend + rate trend) using ECharts with synchronized date buckets +- [x] 4.5 Implement reason Pareto chart/table with `metric_mode` switch and cumulative percentage line, referencing WIP OVER VIEW interaction pattern +- [x] 4.6 Add Pareto mode toggle: default "top cumulative 80%" and optional "show all filtered categories" +- [x] 4.7 Implement detail table with pagination, active filter chips, and empty/error states +- [x] 4.8 Implement CSV export action using current filter context +- [x] 4.9 Add responsive rules so filter/cards/charts/table stay usable on tablet/mobile widths +- [x] 4.10 Add "納入不計良率報廢" toggle in filter panel and wire to all API calls + export + +## 5. Shell and Route Governance Integration + +- [x] 5.1 Add `/reject-history` contract entry to `frontend/src/portal-shell/routeContracts.js` +- [x] 5.2 Add `/reject-history` loader to `frontend/src/portal-shell/nativeModuleRegistry.js` +- [x] 5.3 Add `/reject-history` page metadata (drawer/order/status) to `data/page_status.json` +- [x] 5.4 Add Flask page route `/reject-history` using `send_from_directory` with dist fallback HTML + +## 6. Tests and Quality Gates + +- [x] 6.1 Add service tests in `tests/test_reject_history_service.py` covering formulas, dedupe, and edge cases +- [x] 6.2 Add route tests in `tests/test_reject_history_routes.py` covering validation, payload shape, and rate-limit behavior +- [x] 6.3 Add/extend route-contract parity and shell coverage tests for `/reject-history` +- [x] 6.4 Add frontend smoke/integration test for query flow and major visual sections +- [x] 6.5 Add exclusion-policy tests (`ENABLE_FLAG` handling, default exclude, include override, cache fallback path) + +## 7. Documentation and Rollout + +- [x] 7.1 Update implementation notes under `docs/reject_history_performance.md` to match API/UI field names +- [x] 7.2 Document exclusion-policy behavior and user toggle semantics in reject-history docs +- [x] 7.3 Document rollout policy (`dev` visibility first, then `released`) and rollback path +- [x] 7.4 Run end-to-end verification checklist and capture evidence before implementation handoff diff --git a/openspec/changes/reject-history-query-page/tasks.md b/openspec/changes/reject-history-query-page/tasks.md deleted file mode 100644 index 6516db2..0000000 --- a/openspec/changes/reject-history-query-page/tasks.md +++ /dev/null @@ -1,53 +0,0 @@ -## 1. Contract and Skeleton Setup - -- [ ] 1.1 Create backend blueprint scaffold `src/mes_dashboard/routes/reject_history_routes.py` and register it in `src/mes_dashboard/routes/__init__.py` -- [ ] 1.2 Create service scaffold `src/mes_dashboard/services/reject_history_service.py` with SQL loader helpers -- [ ] 1.3 Create frontend entry scaffold `frontend/src/reject-history/index.html`, `frontend/src/reject-history/main.js`, and `frontend/src/reject-history/App.vue` -- [ ] 1.4 Add Vite input for `reject-history` in `frontend/vite.config.js` - -## 2. SQL and Metric Semantics Implementation - -- [ ] 2.1 Finalize base query `src/mes_dashboard/sql/reject_history/performance_daily.sql` for five-reject-sum + defect separation -- [ ] 2.2 Add API-specific SQL files in `src/mes_dashboard/sql/reject_history/` (summary, trend, reason_pareto, list, export) -- [ ] 2.3 Implement `MOVEIN_QTY` dedupe by `HISTORYMAINLINEID` with deterministic fallback key -- [ ] 2.4 Implement consistent rate calculations (`REJECT_RATE_PCT`, `DEFECT_RATE_PCT`, `REJECT_SHARE_PCT`) with zero-denominator handling - -## 3. Backend API Routes - -- [ ] 3.1 Implement `GET /api/reject-history/summary` with date/filter validation -- [ ] 3.2 Implement `GET /api/reject-history/trend` with `granularity` validation (`day|week|month`) -- [ ] 3.3 Implement `GET /api/reject-history/reason-pareto` with `metric_mode` validation (`reject_total|defect`) -- [ ] 3.4 Implement `GET /api/reject-history/list` with paging bounds and reason/category filters -- [ ] 3.5 Implement `GET /api/reject-history/export` and CSV output contract -- [ ] 3.6 Apply configured rate limiting to list/export endpoints - -## 4. Frontend Visual and Interaction Implementation - -- [ ] 4.1 Build page header with title, data timestamp, and semantic badges for charge-off reject vs non-charge-off defect -- [ ] 4.2 Build filter panel (date range + dimensions + query/clear actions) and wire it to all API calls -- [ ] 4.3 Implement KPI card row (8 cards) with warm/cool semantic color lanes and zh-TW number formatting -- [ ] 4.4 Implement dual trend charts (quantity trend + rate trend) using ECharts with synchronized date buckets -- [ ] 4.5 Implement reason Pareto chart/table with `metric_mode` switch and cumulative percentage line -- [ ] 4.6 Implement detail table with pagination, active filter chips, and empty/error states -- [ ] 4.7 Implement CSV export action using current filter context -- [ ] 4.8 Add responsive rules so filter/cards/charts/table stay usable on tablet/mobile widths - -## 5. Shell and Route Governance Integration - -- [ ] 5.1 Add `/reject-history` contract entry to `frontend/src/portal-shell/routeContracts.js` -- [ ] 5.2 Add `/reject-history` loader to `frontend/src/portal-shell/nativeModuleRegistry.js` -- [ ] 5.3 Add `/reject-history` page metadata (drawer/order/status) to `data/page_status.json` -- [ ] 5.4 Add Flask page route `/reject-history` using `send_from_directory` with dist fallback HTML - -## 6. Tests and Quality Gates - -- [ ] 6.1 Add service tests in `tests/test_reject_history_service.py` covering formulas, dedupe, and edge cases -- [ ] 6.2 Add route tests in `tests/test_reject_history_routes.py` covering validation, payload shape, and rate-limit behavior -- [ ] 6.3 Add/extend route-contract parity and shell coverage tests for `/reject-history` -- [ ] 6.4 Add frontend smoke/integration test for query flow and major visual sections - -## 7. Documentation and Rollout - -- [ ] 7.1 Update implementation notes under `docs/reject_history_performance.md` to match API/UI field names -- [ ] 7.2 Document rollout policy (`dev` visibility first, then `released`) and rollback path -- [ ] 7.3 Run end-to-end verification checklist and capture evidence before implementation handoff diff --git a/openspec/specs/field-name-consistency/spec.md b/openspec/specs/field-name-consistency/spec.md index 7392305..fc08b80 100644 --- a/openspec/specs/field-name-consistency/spec.md +++ b/openspec/specs/field-name-consistency/spec.md @@ -1,9 +1,6 @@ ## Purpose Define stable requirements for field-name-consistency. - ## Requirements - - ### Requirement: UI and Export Fields SHALL Have a Consistent Contract The system SHALL define and apply a consistent contract among UI column labels, API keys, and export headers for report/query pages. @@ -14,3 +11,25 @@ The system SHALL define and apply a consistent contract among UI column labels, #### Scenario: Resource history field alignment - **WHEN** resource history detail table shows KPI columns - **THEN** columns required by export semantics (including Availability%) SHALL be present or explicitly mapped + +### Requirement: Reject and defect metric names SHALL remain semantically consistent across UI/API/export +The system SHALL use explicit, stable names for charge-off reject and non-charge-off defect metrics across all output surfaces. + +#### Scenario: UI and API key alignment +- **WHEN** summary/trend/list payloads are rendered on reject-history page +- **THEN** UI labels for reject metrics SHALL map to `REJECT_TOTAL_QTY` and related reject-rate fields +- **THEN** UI labels for defect metrics SHALL map to `DEFECT_QTY` and defect-rate fields + +#### Scenario: Export header alignment +- **WHEN** reject-history CSV export is generated +- **THEN** CSV headers SHALL include both `REJECT_TOTAL_QTY` and `DEFECT_QTY` +- **THEN** header names SHALL preserve the same semantic meaning as API fields + +### Requirement: Reject component columns SHALL be explicitly distinguished from defect columns +The system SHALL prevent ambiguous naming that collapses reject components and defect into a single term. + +#### Scenario: Component and aggregate coexistence +- **WHEN** detailed records are presented +- **THEN** reject component fields (`REJECTQTY`, `STANDBYQTY`, `QTYTOPROCESS`, `INPROCESSQTY`, `PROCESSEDQTY`) SHALL be distinguishable from `DEFECT_QTY` +- **THEN** aggregate `REJECT_TOTAL_QTY` SHALL be clearly identified as component sum, not defect + diff --git a/openspec/specs/reject-history-api/spec.md b/openspec/specs/reject-history-api/spec.md new file mode 100644 index 0000000..0e6d543 --- /dev/null +++ b/openspec/specs/reject-history-api/spec.md @@ -0,0 +1,117 @@ +# reject-history-api Specification + +## Purpose +TBD - created by archiving change reject-history-query-page. Update Purpose after archive. +## Requirements +### Requirement: Reject History API SHALL validate required query parameters +The API SHALL validate date parameters and basic paging bounds before executing database work. + +#### Scenario: Missing required dates +- **WHEN** a reject-history endpoint requiring date range is called without `start_date` or `end_date` +- **THEN** the API SHALL return HTTP 400 with a descriptive validation error + +#### Scenario: Invalid date order +- **WHEN** `end_date` is earlier than `start_date` +- **THEN** the API SHALL return HTTP 400 and SHALL NOT run SQL queries + +### Requirement: Reject History API SHALL provide summary metrics endpoint +The API SHALL provide aggregated summary metrics for the selected filter context. + +#### Scenario: Summary response payload +- **WHEN** `GET /api/reject-history/summary` is called with valid filters +- **THEN** response SHALL be `{ success: true, data: { ... } }` +- **THEN** data SHALL include `MOVEIN_QTY`, `REJECT_TOTAL_QTY`, `DEFECT_QTY`, `REJECT_RATE_PCT`, `DEFECT_RATE_PCT`, `REJECT_SHARE_PCT`, `AFFECTED_LOT_COUNT`, and `AFFECTED_WORKORDER_COUNT` + +### Requirement: Reject History API SHALL support yield-exclusion policy toggle +The API SHALL support excluding or including policy-marked scrap reasons through a shared query parameter. + +#### Scenario: Default policy mode +- **WHEN** reject-history endpoints are called without `include_excluded_scrap` +- **THEN** `include_excluded_scrap` SHALL default to `false` +- **THEN** rows mapped to `ERP_PJ_WIP_SCRAP_REASONS_EXCLUDE.ENABLE_FLAG='Y'` SHALL be excluded from yield-related calculations + +#### Scenario: Explicitly include policy-marked scrap +- **WHEN** `include_excluded_scrap=true` is provided +- **THEN** policy-marked rows SHALL be included in summary/trend/pareto/list/export calculations +- **THEN** API response `meta` SHALL include the effective `include_excluded_scrap` value + +#### Scenario: Invalid toggle value +- **WHEN** `include_excluded_scrap` is not parseable as boolean +- **THEN** the API SHALL return HTTP 400 with a descriptive validation error + +### Requirement: Reject History API SHALL provide trend endpoint +The API SHALL return time-series trend data for quantity and rate metrics. + +#### Scenario: Trend response structure +- **WHEN** `GET /api/reject-history/trend` is called +- **THEN** response SHALL be `{ success: true, data: { items: [...] } }` +- **THEN** each trend item SHALL contain bucket date, `REJECT_TOTAL_QTY`, `DEFECT_QTY`, `REJECT_RATE_PCT`, and `DEFECT_RATE_PCT` + +#### Scenario: Trend granularity +- **WHEN** `granularity` is provided as `day`, `week`, or `month` +- **THEN** the API SHALL aggregate by the requested granularity +- **THEN** invalid granularity SHALL return HTTP 400 + +### Requirement: Reject History API SHALL provide reason Pareto endpoint +The API SHALL return sorted reason distribution data with cumulative percentages. + +#### Scenario: Pareto response payload +- **WHEN** `GET /api/reject-history/reason-pareto` is called +- **THEN** each item SHALL include `reason`, `category`, selected metric value, `pct`, and `cumPct` +- **THEN** items SHALL be sorted descending by selected metric + +#### Scenario: Metric mode validation +- **WHEN** `metric_mode` is provided +- **THEN** accepted values SHALL be `reject_total` or `defect` +- **THEN** invalid `metric_mode` SHALL return HTTP 400 + +### Requirement: Reject History API SHALL provide paginated detail endpoint +The API SHALL return paginated detailed rows for the selected filter context. + +#### Scenario: List response payload +- **WHEN** `GET /api/reject-history/list?page=1&per_page=50` is called +- **THEN** response SHALL include `{ items: [...], pagination: { page, perPage, total, totalPages } }` +- **THEN** each row SHALL include date, process dimensions, reason fields, `MOVEIN_QTY`, `REJECT_TOTAL_QTY`, `DEFECT_QTY`, and reject component columns + +#### Scenario: Paging bounds +- **WHEN** `page < 1` +- **THEN** page SHALL be treated as 1 +- **WHEN** `per_page > 200` +- **THEN** `per_page` SHALL be capped at 200 + +### Requirement: Reject History API SHALL provide CSV export endpoint +The API SHALL provide CSV export using the same filter and metric semantics as list/query APIs. + +#### Scenario: Export payload consistency +- **WHEN** `GET /api/reject-history/export` is called with valid filters +- **THEN** CSV headers SHALL include both `REJECT_TOTAL_QTY` and `DEFECT_QTY` +- **THEN** export rows SHALL follow the same semantic definitions as summary/list endpoints + +### Requirement: Reject History API SHALL centralize SQL in reject_history SQL directory +The service SHALL load SQL from dedicated files under `src/mes_dashboard/sql/reject_history/`. + +#### Scenario: SQL file loading +- **WHEN** reject-history service executes queries +- **THEN** SQL SHALL be loaded from files in `sql/reject_history` +- **THEN** user-supplied filters SHALL be passed through bind parameters +- **THEN** user input SHALL NOT be interpolated into SQL strings directly + +### Requirement: Reject History API SHALL use cached exclusion-policy source +The API SHALL read exclusion-policy reasons from cached `ERP_PJ_WIP_SCRAP_REASONS_EXCLUDE` data instead of querying Oracle on every request. + +#### Scenario: Enabled exclusions only +- **WHEN** exclusion-policy data is loaded +- **THEN** only rows with `ENABLE_FLAG='Y'` SHALL be treated as active exclusions + +#### Scenario: Daily full-table cache refresh +- **WHEN** exclusion cache is initialized +- **THEN** the full table SHALL be loaded and refreshed at least once per 24 hours +- **THEN** Redis SHOULD be used as shared cache when available, with in-memory fallback when unavailable + +### Requirement: Reject History API SHALL apply rate limiting on expensive endpoints +The API SHALL rate-limit high-cost endpoints to protect Oracle and application resources. + +#### Scenario: List and export rate limiting +- **WHEN** `/api/reject-history/list` or `/api/reject-history/export` receives excessive requests +- **THEN** configured rate limiting SHALL reject requests beyond the threshold within the time window + diff --git a/openspec/specs/reject-history-page/spec.md b/openspec/specs/reject-history-page/spec.md new file mode 100644 index 0000000..00543d3 --- /dev/null +++ b/openspec/specs/reject-history-page/spec.md @@ -0,0 +1,142 @@ +# reject-history-page Specification + +## Purpose +TBD - created by archiving change reject-history-query-page. Update Purpose after archive. +## Requirements +### Requirement: Reject History page SHALL provide filterable historical query controls +The page SHALL provide a filter area for date range and major production dimensions to drive all report sections. + +#### Scenario: Default filter values +- **WHEN** the page is first loaded +- **THEN** `start_date` and `end_date` SHALL default to a valid recent range +- **THEN** all other dimension filters SHALL default to empty (no restriction) + +#### Scenario: Apply and clear filters +- **WHEN** user clicks "查詢" +- **THEN** summary, trend, pareto, and list sections SHALL reload with the same filter set +- **WHEN** user clicks "清除條件" +- **THEN** all filters SHALL reset to defaults and all sections SHALL reload + +#### Scenario: Required core filters are present +- **WHEN** the filter panel is rendered +- **THEN** it SHALL include `start_date/end_date` time filter controls +- **THEN** it SHALL include reason filter control +- **THEN** it SHALL include `WORKCENTER_GROUP` filter control + +### Requirement: Reject History page SHALL expose yield-exclusion toggle control +The page SHALL let users decide whether to include policy-marked scrap in yield calculations. + +#### Scenario: Default toggle state +- **WHEN** the page is first loaded +- **THEN** "納入不計良率報廢" toggle SHALL default to OFF +- **THEN** requests SHALL be sent with `include_excluded_scrap=false` + +#### Scenario: Toggle affects all sections +- **WHEN** user turns ON/OFF the toggle and clicks "查詢" +- **THEN** summary, trend, pareto, and list sections SHALL reload under the selected policy mode +- **THEN** export action SHALL use the same toggle state + +#### Scenario: Policy status visibility +- **WHEN** data is rendered +- **THEN** the UI SHALL show a clear badge/text indicating whether policy-marked scrap is currently excluded or included + +### Requirement: Reject History page SHALL present KPI cards with split reject/defect semantics +The page SHALL display KPI cards that simultaneously show charge-off reject and non-charge-off defect metrics. + +#### Scenario: KPI cards render core metrics +- **WHEN** summary data is loaded +- **THEN** cards SHALL include `MOVEIN_QTY`, `REJECT_TOTAL_QTY`, `DEFECT_QTY`, `REJECT_RATE_PCT`, `DEFECT_RATE_PCT`, `REJECT_SHARE_PCT`, `AFFECTED_LOT_COUNT`, and `AFFECTED_WORKORDER_COUNT` +- **THEN** numbers SHALL use zh-TW formatting + +#### Scenario: Visual distinction for semantic lanes +- **WHEN** KPI cards are rendered +- **THEN** reject-related cards SHALL use a warm-color visual lane +- **THEN** defect-related cards SHALL use a cool-color visual lane +- **THEN** page legend/badge text SHALL explicitly indicate charge-off vs non-charge-off meaning + +### Requirement: Reject History page SHALL display quantity and rate trends in separate charts +The page SHALL show both quantity trend and rate trend to avoid mixing unit scales. + +#### Scenario: Quantity trend chart +- **WHEN** trend data is loaded +- **THEN** the quantity trend chart SHALL show `REJECT_TOTAL_QTY` and `DEFECT_QTY` over time +- **THEN** the chart SHALL use a shared X-axis by date bucket + +#### Scenario: Rate trend chart +- **WHEN** trend data is loaded +- **THEN** the rate trend chart SHALL show `REJECT_RATE_PCT` and `DEFECT_RATE_PCT` over time +- **THEN** rate values SHALL be displayed as percentages + +### Requirement: Reject History page SHALL provide reason Pareto analysis +The page SHALL provide a Pareto view for loss reasons and support downstream filtering. + +#### Scenario: Pareto rendering and ordering +- **WHEN** reason Pareto data is loaded +- **THEN** items SHALL be sorted by selected metric descending +- **THEN** a cumulative percentage line SHALL be shown + +#### Scenario: Default 80% cumulative display mode +- **WHEN** the page first loads Pareto +- **THEN** it SHALL default to "only cumulative top 80%" mode +- **THEN** Pareto SHALL only render categories within the cumulative 80% threshold under current filters + +#### Scenario: Full Pareto toggle mode +- **WHEN** user turns OFF the 80% cumulative display mode +- **THEN** Pareto SHALL render all categories after applying current filters +- **THEN** switching mode SHALL NOT reset existing time/reason/workcenter-group filters + +#### Scenario: Pareto click filtering +- **WHEN** user clicks a Pareto bar or row +- **THEN** the selected reason SHALL become an active filter chip +- **THEN** detail list SHALL reload with that reason +- **THEN** clicking the same reason again SHALL clear the reason filter + +### Requirement: Reject History page SHALL show paginated detail rows +The page SHALL provide a paginated detail table for investigation and traceability. + +#### Scenario: Detail columns +- **WHEN** list data is loaded +- **THEN** each row SHALL include date, workcenter group, workcenter, product dimensions, reason/category, `MOVEIN_QTY`, `REJECT_TOTAL_QTY`, `DEFECT_QTY`, and component reject columns + +#### Scenario: Pagination behavior +- **WHEN** total records exceed per-page size +- **THEN** Prev/Next and page summary SHALL be shown +- **THEN** changing any filter SHALL reset page to 1 + +### Requirement: Reject History page SHALL support CSV export from current filter context +The page SHALL allow users to export records using the exact active filters. + +#### Scenario: Export with current filters +- **WHEN** user clicks "匯出 CSV" +- **THEN** export request SHALL include the current filter state and active reason filter +- **THEN** downloaded file SHALL contain both `REJECT_TOTAL_QTY` and `DEFECT_QTY` + +### Requirement: Reject History page SHALL provide robust feedback states +The page SHALL provide loading, empty, and error states without breaking interactions. + +#### Scenario: Initial loading +- **WHEN** first query is running +- **THEN** a loading overlay or skeleton SHALL be visible until required data sections are ready + +#### Scenario: API failure +- **WHEN** any section API fails +- **THEN** a visible error banner SHALL be shown +- **THEN** already loaded sections SHALL remain interactive + +#### Scenario: Empty dataset +- **WHEN** query returns no rows +- **THEN** chart and table areas SHALL show explicit empty-state messages + +### Requirement: Reject History page SHALL maintain responsive visual hierarchy +The page SHALL keep the same semantic grouping across desktop and mobile layouts. + +#### Scenario: Desktop layout +- **WHEN** viewport is desktop width +- **THEN** KPI cards SHALL render in multi-column layout +- **THEN** trend and pareto sections SHALL render as two-column analytical panels + +#### Scenario: Mobile layout +- **WHEN** viewport width is below responsive breakpoint +- **THEN** cards and chart panels SHALL stack in a single column +- **THEN** filter controls SHALL remain operable without horizontal overflow + diff --git a/openspec/specs/reject-metric-semantics/spec.md b/openspec/specs/reject-metric-semantics/spec.md new file mode 100644 index 0000000..11bf54e --- /dev/null +++ b/openspec/specs/reject-metric-semantics/spec.md @@ -0,0 +1,77 @@ +# reject-metric-semantics Specification + +## Purpose +TBD - created by archiving change reject-history-query-page. Update Purpose after archive. +## Requirements +### Requirement: Charge-off reject metric SHALL be computed from five reject component columns +The system SHALL compute `REJECT_TOTAL_QTY` as the sum of five reject-related quantity columns. + +#### Scenario: Reject total formula +- **WHEN** a source record is transformed +- **THEN** `REJECT_TOTAL_QTY` SHALL equal `REJECTQTY + STANDBYQTY + QTYTOPROCESS + INPROCESSQTY + PROCESSEDQTY` +- **THEN** null component values SHALL be treated as zero + +### Requirement: Defect metric SHALL remain independent from reject total +The system SHALL compute `DEFECT_QTY` only from `DEFECTQTY` and SHALL NOT merge it into `REJECT_TOTAL_QTY`. + +#### Scenario: Defect independence +- **WHEN** a record has `DEFECTQTY > 0` and reject component sum equals 0 +- **THEN** `DEFECT_QTY` SHALL be non-zero +- **THEN** `REJECT_TOTAL_QTY` SHALL remain 0 + +### Requirement: Yield-exclusion policy SHALL follow ERP exclusion table +The system SHALL use `ERP_PJ_WIP_SCRAP_REASONS_EXCLUDE` as the policy source for "not included in yield" scrap reasons. + +#### Scenario: Enabled policy rows +- **WHEN** exclusion policy is evaluated +- **THEN** only rows with `ENABLE_FLAG='Y'` SHALL be considered exclusion rules + +#### Scenario: Default exclusion behavior +- **WHEN** `include_excluded_scrap=false` (default) +- **THEN** source rows matching enabled exclusion reasons SHALL be excluded before computing yield-related metrics + +#### Scenario: Optional inclusion override +- **WHEN** `include_excluded_scrap=true` +- **THEN** the same matched rows SHALL be included back into metric calculations + +### Requirement: Move-in denominator SHALL be deduplicated at event level +The system SHALL deduplicate `MOVEIN_QTY` by event key before rate calculations. + +#### Scenario: Primary dedupe key +- **WHEN** `HISTORYMAINLINEID` is present +- **THEN** only one row per `HISTORYMAINLINEID` SHALL contribute `MOVEIN_QTY` + +#### Scenario: Fallback dedupe key +- **WHEN** `HISTORYMAINLINEID` is missing +- **THEN** fallback dedupe key SHALL use a deterministic composite key from transaction context + +### Requirement: Reject and defect rates SHALL use the same deduplicated denominator +The system SHALL calculate percentage rates from deduplicated `MOVEIN_QTY` to ensure comparability. + +#### Scenario: Reject rate formula +- **WHEN** `MOVEIN_QTY > 0` +- **THEN** `REJECT_RATE_PCT` SHALL equal `REJECT_TOTAL_QTY / MOVEIN_QTY * 100` + +#### Scenario: Defect rate formula +- **WHEN** `MOVEIN_QTY > 0` +- **THEN** `DEFECT_RATE_PCT` SHALL equal `DEFECT_QTY / MOVEIN_QTY * 100` + +#### Scenario: Zero denominator handling +- **WHEN** `MOVEIN_QTY = 0` +- **THEN** both rate fields SHALL return 0 and SHALL NOT raise divide-by-zero errors + +### Requirement: Reject share SHALL describe reject proportion within total loss +The system SHALL calculate reject share against combined reject and defect loss quantities. + +#### Scenario: Reject share formula +- **WHEN** `REJECT_TOTAL_QTY + DEFECT_QTY > 0` +- **THEN** `REJECT_SHARE_PCT` SHALL equal `REJECT_TOTAL_QTY / (REJECT_TOTAL_QTY + DEFECT_QTY) * 100` + +### Requirement: Metric naming SHALL preserve semantic meaning across transformations +The system SHALL keep explicit names for charge-off reject and non-charge-off defect metrics. + +#### Scenario: No ambiguous remapping +- **WHEN** service or export fields are generated +- **THEN** `REJECT_TOTAL_QTY` SHALL NOT be renamed to `DEFECT_QTY` +- **THEN** `DEFECT_QTY` SHALL refer only to `DEFECTQTY` + diff --git a/openspec/specs/unified-shell-route-coverage/spec.md b/openspec/specs/unified-shell-route-coverage/spec.md index 817029b..27cb2ec 100644 --- a/openspec/specs/unified-shell-route-coverage/spec.md +++ b/openspec/specs/unified-shell-route-coverage/spec.md @@ -45,3 +45,27 @@ When contract loading falls back from the primary modernization contract artifac #### Scenario: Legacy contract fallback path selected - **WHEN** the primary contract artifact is unavailable and a legacy contract file is loaded - **THEN** the system SHALL log a warning that includes the selected legacy source path + +### Requirement: Reject History route SHALL be included in governed shell route inventory +The `/reject-history` route SHALL be represented in shell route contracts with complete governance metadata. + +#### Scenario: Frontend route contract entry +- **WHEN** route contract validation runs against `frontend/src/portal-shell/routeContracts.js` +- **THEN** `/reject-history` SHALL exist with route id, title, owner, render mode, visibility policy, scope, and compatibility policy + +#### Scenario: Native loader coverage +- **WHEN** native module loader registry is validated +- **THEN** `/reject-history` SHALL be resolvable in `nativeModuleRegistry` + +### Requirement: Reject History governance metadata SHALL be parity-validated across sources +Shell governance checks SHALL enforce parity for `/reject-history` between frontend and backend contract inventories. + +#### Scenario: Contract parity for reject-history route +- **WHEN** contract parity checks execute +- **THEN** frontend and backend route inventories SHALL both include `/reject-history` +- **THEN** metadata mismatch or missing route SHALL fail governance checks + +#### Scenario: Navigation visibility governance +- **WHEN** page status/navigation config is evaluated +- **THEN** `/reject-history` SHALL have governed drawer assignment and ordering metadata + diff --git a/openspec/specs/vue-vite-page-architecture/spec.md b/openspec/specs/vue-vite-page-architecture/spec.md index 788176b..fd0618b 100644 --- a/openspec/specs/vue-vite-page-architecture/spec.md +++ b/openspec/specs/vue-vite-page-architecture/spec.md @@ -137,3 +137,42 @@ The mid-section defect page SHALL use `AbortController` to cancel in-flight API - **THEN** the query SHALL NOT be cancelled by the pagination request - **THEN** the pagination SHALL use a separate abort key from the query +### Requirement: Reject History page SHALL be a pure Vite HTML entry +The reject-history page SHALL be built from an HTML entry and emitted as static dist assets. + +#### Scenario: Vite entry registration +- **WHEN** Vite config inputs are evaluated +- **THEN** `reject-history` SHALL map to `frontend/src/reject-history/index.html` + +#### Scenario: Build output artifacts +- **WHEN** `vite build` completes +- **THEN** output SHALL include `reject-history.html`, `reject-history.js`, and `reject-history.css` in `static/dist/` + +### Requirement: Reject History route SHALL serve static dist HTML +The Flask route for `/reject-history` SHALL serve pre-built static HTML through `send_from_directory`. + +#### Scenario: Static page serving +- **WHEN** user navigates to `/reject-history` +- **THEN** Flask SHALL serve `static/dist/reject-history.html` when the file exists +- **THEN** HTML SHALL NOT be rendered through Jinja template interpolation + +#### Scenario: Dist fallback response +- **WHEN** `reject-history.html` is missing in dist +- **THEN** route SHALL return a minimal fallback HTML that still references `/static/dist/reject-history.js` + +### Requirement: Reject History shell integration SHALL use native module loading +The page SHALL integrate with portal-shell native module loading policy. + +#### Scenario: Native module registration +- **WHEN** shell resolves a route component for `/reject-history` +- **THEN** it SHALL dynamically import `frontend/src/reject-history/App.vue` +- **THEN** the route style bundle SHALL be loaded via registered style loaders + +### Requirement: Reject History page SHALL call APIs through shared core API module +The page SHALL call backend APIs via `frontend/src/core/api.js` without legacy global dependencies. + +#### Scenario: API call path +- **WHEN** reject-history page executes GET or export requests +- **THEN** requests SHALL use shared API utilities (`apiGet`/equivalent) +- **THEN** page behavior SHALL NOT depend on `window.MesApi` + diff --git a/src/mes_dashboard/app.py b/src/mes_dashboard/app.py index d223841..028a9c9 100644 --- a/src/mes_dashboard/app.py +++ b/src/mes_dashboard/app.py @@ -44,6 +44,10 @@ from mes_dashboard.services.realtime_equipment_cache import ( init_realtime_equipment_cache, stop_equipment_status_sync_worker, ) +from mes_dashboard.services.scrap_reason_exclusion_cache import ( + init_scrap_reason_exclusion_cache, + stop_scrap_reason_exclusion_cache_worker, +) from mes_dashboard.core.modernization_policy import ( get_deferred_routes as get_deferred_routes_from_scope_matrix, get_missing_in_scope_assets, @@ -286,6 +290,11 @@ def _shutdown_runtime_resources() -> None: except Exception as exc: logger.warning("Error stopping equipment sync worker: %s", exc) + try: + stop_scrap_reason_exclusion_cache_worker() + except Exception as exc: + logger.warning("Error stopping scrap exclusion cache worker: %s", exc) + try: close_redis() except Exception as exc: @@ -380,6 +389,7 @@ def create_app(config_name: str | None = None) -> Flask: start_keepalive() # Keep database connections alive start_cache_updater() # Start Redis cache updater init_realtime_equipment_cache(app) # Start realtime equipment status cache + init_scrap_reason_exclusion_cache(app) # Start exclusion-policy cache sync _register_shutdown_hooks(app) # Register API routes @@ -796,6 +806,27 @@ def create_app(config_name: str | None = None) -> Flask: 200, )) + @app.route('/reject-history') + def reject_history_page(): + """Reject history analysis page served as pure Vite HTML output.""" + canonical_redirect = maybe_redirect_to_canonical_shell('/reject-history') + if canonical_redirect is not None: + return canonical_redirect + + dist_dir = os.path.join(app.static_folder or "", "dist") + dist_html = os.path.join(dist_dir, "reject-history.html") + if os.path.exists(dist_html): + return send_from_directory(dist_dir, 'reject-history.html') + + return missing_in_scope_asset_response('/reject-history', ( + "" + "" + "報廢歷史查詢" + "" + "
", + 200, + )) + @app.route('/tmtt-defect') def tmtt_defect_page(): """TMTT printing & lead form defect analysis page.""" diff --git a/src/mes_dashboard/routes/__init__.py b/src/mes_dashboard/routes/__init__.py index 60446c6..44d46f4 100644 --- a/src/mes_dashboard/routes/__init__.py +++ b/src/mes_dashboard/routes/__init__.py @@ -20,6 +20,7 @@ from .tmtt_defect_routes import tmtt_defect_bp from .qc_gate_routes import qc_gate_bp from .mid_section_defect_routes import mid_section_defect_bp from .trace_routes import trace_bp +from .reject_history_routes import reject_history_bp def register_routes(app) -> None: @@ -38,6 +39,7 @@ def register_routes(app) -> None: app.register_blueprint(qc_gate_bp) app.register_blueprint(mid_section_defect_bp) app.register_blueprint(trace_bp) + app.register_blueprint(reject_history_bp) __all__ = [ 'wip_bp', @@ -56,5 +58,6 @@ __all__ = [ 'qc_gate_bp', 'mid_section_defect_bp', 'trace_bp', + 'reject_history_bp', 'register_routes', ] diff --git a/src/mes_dashboard/routes/reject_history_routes.py b/src/mes_dashboard/routes/reject_history_routes.py new file mode 100644 index 0000000..072b562 --- /dev/null +++ b/src/mes_dashboard/routes/reject_history_routes.py @@ -0,0 +1,358 @@ +# -*- coding: utf-8 -*- +"""Reject-history page API routes.""" + +from __future__ import annotations + +from datetime import date, timedelta +from typing import Optional + +from flask import Blueprint, Response, jsonify, request + +from mes_dashboard.core.rate_limit import configured_rate_limit +from mes_dashboard.services.reject_history_service import ( + export_csv, + get_filter_options, + query_list, + query_reason_pareto, + query_summary, + query_trend, +) + +reject_history_bp = Blueprint("reject_history", __name__) + +_REJECT_HISTORY_LIST_RATE_LIMIT = configured_rate_limit( + bucket="reject-history-list", + max_attempts_env="REJECT_HISTORY_LIST_RATE_LIMIT_MAX_REQUESTS", + window_seconds_env="REJECT_HISTORY_LIST_RATE_LIMIT_WINDOW_SECONDS", + default_max_attempts=90, + default_window_seconds=60, +) + +_REJECT_HISTORY_EXPORT_RATE_LIMIT = configured_rate_limit( + bucket="reject-history-export", + max_attempts_env="REJECT_HISTORY_EXPORT_RATE_LIMIT_MAX_REQUESTS", + window_seconds_env="REJECT_HISTORY_EXPORT_RATE_LIMIT_WINDOW_SECONDS", + default_max_attempts=30, + default_window_seconds=60, +) + + +def _default_date_range() -> tuple[str, str]: + end = date.today() + start = end - timedelta(days=29) + return start.strftime("%Y-%m-%d"), end.strftime("%Y-%m-%d") + + +def _parse_date_range(required: bool = True) -> 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: + if required: + return None, None, ({"success": False, "error": "缺少必要參數: start_date, end_date"}, 400) + start_date, end_date = _default_date_range() + + return start_date, end_date, None + + +def _parse_bool(value: str, *, name: str) -> tuple[Optional[bool], Optional[tuple[dict, int]]]: + normalized = str(value or "").strip().lower() + if normalized in {"", "0", "false", "no", "n", "off"}: + return False, None + if normalized in {"1", "true", "yes", "y", "on"}: + return True, None + return None, ({"success": False, "error": f"Invalid {name}, use true/false"}, 400) + + +def _parse_multi_param(name: str) -> list[str]: + values = [] + for raw in request.args.getlist(name): + for token in str(raw).split(","): + item = token.strip() + if item: + values.append(item) + # Deduplicate while preserving order. + seen = set() + deduped = [] + for value in values: + if value in seen: + continue + seen.add(value) + deduped.append(value) + return deduped + + +def _extract_meta( + payload: dict, + include_excluded_scrap: bool, + exclude_material_scrap: bool, +) -> tuple[dict, dict]: + data = dict(payload or {}) + meta = data.pop("meta", {}) if isinstance(data.get("meta"), dict) else {} + meta["include_excluded_scrap"] = bool(include_excluded_scrap) + meta["exclude_material_scrap"] = bool(exclude_material_scrap) + return data, meta + + +@reject_history_bp.route("/api/reject-history/options", methods=["GET"]) +def api_reject_history_options(): + start_date, end_date, date_error = _parse_date_range(required=False) + if date_error: + return jsonify(date_error[0]), date_error[1] + + include_excluded_scrap, bool_error = _parse_bool( + request.args.get("include_excluded_scrap", ""), + name="include_excluded_scrap", + ) + if bool_error: + return jsonify(bool_error[0]), bool_error[1] + exclude_material_scrap, material_bool_error = _parse_bool( + request.args.get("exclude_material_scrap", "true"), + name="exclude_material_scrap", + ) + if material_bool_error: + return jsonify(material_bool_error[0]), material_bool_error[1] + + try: + result = get_filter_options( + start_date=start_date, + end_date=end_date, + include_excluded_scrap=bool(include_excluded_scrap), + exclude_material_scrap=bool(exclude_material_scrap), + ) + data, meta = _extract_meta( + result, + bool(include_excluded_scrap), + bool(exclude_material_scrap), + ) + return jsonify({"success": True, "data": data, "meta": meta}) + except ValueError as exc: + return jsonify({"success": False, "error": str(exc)}), 400 + except Exception: + return jsonify({"success": False, "error": "查詢篩選選項失敗"}), 500 + + +@reject_history_bp.route("/api/reject-history/summary", methods=["GET"]) +def api_reject_history_summary(): + start_date, end_date, date_error = _parse_date_range(required=True) + if date_error: + return jsonify(date_error[0]), date_error[1] + + include_excluded_scrap, bool_error = _parse_bool( + request.args.get("include_excluded_scrap", ""), + name="include_excluded_scrap", + ) + if bool_error: + return jsonify(bool_error[0]), bool_error[1] + exclude_material_scrap, material_bool_error = _parse_bool( + request.args.get("exclude_material_scrap", "true"), + name="exclude_material_scrap", + ) + if material_bool_error: + return jsonify(material_bool_error[0]), material_bool_error[1] + + try: + result = query_summary( + start_date=start_date, + end_date=end_date, + workcenter_groups=_parse_multi_param("workcenter_groups") or None, + packages=_parse_multi_param("packages") or None, + reasons=_parse_multi_param("reasons") or None, + categories=_parse_multi_param("categories") or None, + include_excluded_scrap=bool(include_excluded_scrap), + exclude_material_scrap=bool(exclude_material_scrap), + ) + data, meta = _extract_meta( + result, + bool(include_excluded_scrap), + bool(exclude_material_scrap), + ) + return jsonify({"success": True, "data": data, "meta": meta}) + except ValueError as exc: + return jsonify({"success": False, "error": str(exc)}), 400 + except Exception: + return jsonify({"success": False, "error": "查詢摘要資料失敗"}), 500 + + +@reject_history_bp.route("/api/reject-history/trend", methods=["GET"]) +def api_reject_history_trend(): + start_date, end_date, date_error = _parse_date_range(required=True) + if date_error: + return jsonify(date_error[0]), date_error[1] + + include_excluded_scrap, bool_error = _parse_bool( + request.args.get("include_excluded_scrap", ""), + name="include_excluded_scrap", + ) + if bool_error: + return jsonify(bool_error[0]), bool_error[1] + exclude_material_scrap, material_bool_error = _parse_bool( + request.args.get("exclude_material_scrap", "true"), + name="exclude_material_scrap", + ) + if material_bool_error: + return jsonify(material_bool_error[0]), material_bool_error[1] + + granularity = request.args.get("granularity", "day").strip().lower() or "day" + try: + result = query_trend( + start_date=start_date, + end_date=end_date, + granularity=granularity, + workcenter_groups=_parse_multi_param("workcenter_groups") or None, + packages=_parse_multi_param("packages") or None, + reasons=_parse_multi_param("reasons") or None, + categories=_parse_multi_param("categories") or None, + include_excluded_scrap=bool(include_excluded_scrap), + exclude_material_scrap=bool(exclude_material_scrap), + ) + data, meta = _extract_meta( + result, + bool(include_excluded_scrap), + bool(exclude_material_scrap), + ) + return jsonify({"success": True, "data": data, "meta": meta}) + except ValueError as exc: + return jsonify({"success": False, "error": str(exc)}), 400 + except Exception: + return jsonify({"success": False, "error": "查詢趨勢資料失敗"}), 500 + + +@reject_history_bp.route("/api/reject-history/reason-pareto", methods=["GET"]) +def api_reject_history_reason_pareto(): + start_date, end_date, date_error = _parse_date_range(required=True) + if date_error: + return jsonify(date_error[0]), date_error[1] + + include_excluded_scrap, bool_error = _parse_bool( + request.args.get("include_excluded_scrap", ""), + name="include_excluded_scrap", + ) + if bool_error: + return jsonify(bool_error[0]), bool_error[1] + exclude_material_scrap, material_bool_error = _parse_bool( + request.args.get("exclude_material_scrap", "true"), + name="exclude_material_scrap", + ) + if material_bool_error: + return jsonify(material_bool_error[0]), material_bool_error[1] + + metric_mode = request.args.get("metric_mode", "reject_total").strip().lower() or "reject_total" + pareto_scope = request.args.get("pareto_scope", "top80").strip().lower() or "top80" + + try: + result = query_reason_pareto( + start_date=start_date, + end_date=end_date, + metric_mode=metric_mode, + pareto_scope=pareto_scope, + workcenter_groups=_parse_multi_param("workcenter_groups") or None, + packages=_parse_multi_param("packages") or None, + reasons=_parse_multi_param("reasons") or None, + categories=_parse_multi_param("categories") or None, + include_excluded_scrap=bool(include_excluded_scrap), + exclude_material_scrap=bool(exclude_material_scrap), + ) + data, meta = _extract_meta( + result, + bool(include_excluded_scrap), + bool(exclude_material_scrap), + ) + return jsonify({"success": True, "data": data, "meta": meta}) + except ValueError as exc: + return jsonify({"success": False, "error": str(exc)}), 400 + except Exception: + return jsonify({"success": False, "error": "查詢柏拉圖資料失敗"}), 500 + + +@reject_history_bp.route("/api/reject-history/list", methods=["GET"]) +@_REJECT_HISTORY_LIST_RATE_LIMIT +def api_reject_history_list(): + start_date, end_date, date_error = _parse_date_range(required=True) + if date_error: + return jsonify(date_error[0]), date_error[1] + + include_excluded_scrap, bool_error = _parse_bool( + request.args.get("include_excluded_scrap", ""), + name="include_excluded_scrap", + ) + if bool_error: + return jsonify(bool_error[0]), bool_error[1] + exclude_material_scrap, material_bool_error = _parse_bool( + request.args.get("exclude_material_scrap", "true"), + name="exclude_material_scrap", + ) + if material_bool_error: + return jsonify(material_bool_error[0]), material_bool_error[1] + + page = request.args.get("page", 1, type=int) or 1 + per_page = request.args.get("per_page", 50, type=int) or 50 + + try: + result = query_list( + start_date=start_date, + end_date=end_date, + page=page, + per_page=per_page, + workcenter_groups=_parse_multi_param("workcenter_groups") or None, + packages=_parse_multi_param("packages") or None, + reasons=_parse_multi_param("reasons") or None, + categories=_parse_multi_param("categories") or None, + include_excluded_scrap=bool(include_excluded_scrap), + exclude_material_scrap=bool(exclude_material_scrap), + ) + data, meta = _extract_meta( + result, + bool(include_excluded_scrap), + bool(exclude_material_scrap), + ) + return jsonify({"success": True, "data": data, "meta": meta}) + except ValueError as exc: + return jsonify({"success": False, "error": str(exc)}), 400 + except Exception: + return jsonify({"success": False, "error": "查詢明細資料失敗"}), 500 + + +@reject_history_bp.route("/api/reject-history/export", methods=["GET"]) +@_REJECT_HISTORY_EXPORT_RATE_LIMIT +def api_reject_history_export(): + start_date, end_date, date_error = _parse_date_range(required=True) + if date_error: + return jsonify(date_error[0]), date_error[1] + + include_excluded_scrap, bool_error = _parse_bool( + request.args.get("include_excluded_scrap", ""), + name="include_excluded_scrap", + ) + if bool_error: + return jsonify(bool_error[0]), bool_error[1] + exclude_material_scrap, material_bool_error = _parse_bool( + request.args.get("exclude_material_scrap", "true"), + name="exclude_material_scrap", + ) + if material_bool_error: + return jsonify(material_bool_error[0]), material_bool_error[1] + + filename = f"reject_history_{start_date}_to_{end_date}.csv" + try: + return Response( + export_csv( + start_date=start_date, + end_date=end_date, + workcenter_groups=_parse_multi_param("workcenter_groups") or None, + packages=_parse_multi_param("packages") or None, + reasons=_parse_multi_param("reasons") or None, + categories=_parse_multi_param("categories") or None, + include_excluded_scrap=bool(include_excluded_scrap), + exclude_material_scrap=bool(exclude_material_scrap), + ), + mimetype="text/csv", + headers={ + "Content-Disposition": f"attachment; filename={filename}", + "Content-Type": "text/csv; charset=utf-8-sig", + }, + ) + except ValueError as exc: + return jsonify({"success": False, "error": str(exc)}), 400 + except Exception: + return jsonify({"success": False, "error": "匯出 CSV 失敗"}), 500 diff --git a/src/mes_dashboard/services/reject_history_service.py b/src/mes_dashboard/services/reject_history_service.py new file mode 100644 index 0000000..7233b35 --- /dev/null +++ b/src/mes_dashboard/services/reject_history_service.py @@ -0,0 +1,676 @@ +# -*- coding: utf-8 -*- +"""Service layer for reject-history page APIs.""" + +from __future__ import annotations + +import csv +import io +import logging +from datetime import date, datetime +from typing import Any, Dict, Generator, Iterable, Optional + +import pandas as pd + +from mes_dashboard.core.database import read_sql_df +from mes_dashboard.services.filter_cache import get_workcenter_groups +from mes_dashboard.services.scrap_reason_exclusion_cache import get_excluded_reasons +from mes_dashboard.sql import QueryBuilder, SQLLoader + +logger = logging.getLogger("mes_dashboard.reject_history_service") + +MAX_QUERY_DAYS = 730 +VALID_GRANULARITY = {"day", "week", "month"} +VALID_METRIC_MODE = {"reject_total", "defect"} +MATERIAL_REASON_OPTION = "原物料報廢" + + +def _parse_date(value: str) -> date: + return datetime.strptime(value, "%Y-%m-%d").date() + + +def _validate_range(start_date: str, end_date: str) -> None: + start = _parse_date(start_date) + end = _parse_date(end_date) + if end < start: + raise ValueError("end_date 不可早於 start_date") + if (end - start).days > MAX_QUERY_DAYS: + raise ValueError(f"日期範圍不可超過 {MAX_QUERY_DAYS} 天") + + +def _normalize_text(value: Any) -> str: + return str(value or "").strip() + + +def _as_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 _as_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 _to_date_str(value: Any) -> str: + if isinstance(value, datetime): + return value.strftime("%Y-%m-%d") + if isinstance(value, date): + return value.strftime("%Y-%m-%d") + if isinstance(value, pd.Timestamp): + return value.to_pydatetime().strftime("%Y-%m-%d") + text = _normalize_text(value) + if not text: + return "" + try: + return pd.to_datetime(text).strftime("%Y-%m-%d") + except Exception: + return text + + +def _date_bucket_expr(granularity: str) -> str: + if granularity == "week": + return "TRUNC(b.TXN_DAY, 'IW')" + if granularity == "month": + return "TRUNC(b.TXN_DAY, 'MM')" + return "TRUNC(b.TXN_DAY)" + + +def _metric_column(metric_mode: str) -> str: + if metric_mode == "defect": + return "b.DEFECT_QTY" + return "b.REJECT_TOTAL_QTY" + + +def _load_sql(name: str) -> str: + return SQLLoader.load(f"reject_history/{name}") + + +def _base_query_sql() -> str: + sql = _load_sql("performance_daily").strip().rstrip(";") + # Strip leading comment/blank lines so WITH parsing can detect the first SQL token. + lines = sql.splitlines() + first_sql_line = 0 + for index, line in enumerate(lines): + token = line.strip() + if not token or token.startswith("--"): + continue + first_sql_line = index + break + return "\n".join(lines[first_sql_line:]).strip() + + +def _split_with_query(sql: str) -> tuple[str, str] | None: + """Split a top-level WITH query into (cte_segment, final_select).""" + text = (sql or "").strip() + if not text.lower().startswith("with "): + return None + + depth = 0 + in_string = False + i = 0 + while i < len(text): + ch = text[i] + if ch == "'": + if in_string and i + 1 < len(text) and text[i + 1] == "'": + i += 2 + continue + in_string = not in_string + i += 1 + continue + if in_string: + i += 1 + continue + + if ch == "(": + depth += 1 + elif ch == ")": + depth = max(depth - 1, 0) + elif depth == 0: + head = text[i : i + 6] + if head.lower() == "select": + prev_ok = i == 0 or not (text[i - 1].isalnum() or text[i - 1] == "_") + next_idx = i + 6 + next_ok = next_idx >= len(text) or not ( + text[next_idx].isalnum() or text[next_idx] == "_" + ) + if prev_ok and next_ok: + cte_segment = text[5:i].strip().rstrip(",") + final_select = text[i:].strip() + if cte_segment and final_select: + return cte_segment, final_select + return None + i += 1 + return None + + +def _base_with_cte_sql(alias: str = "base") -> str: + base_sql = _base_query_sql() + split = _split_with_query(base_sql) + if split is None: + return f"WITH {alias} AS (\n{base_sql}\n)" + cte_segment, final_select = split + return f"WITH {cte_segment},\n{alias} AS (\n{final_select}\n)" + + +def _build_where_clause( + *, + workcenter_groups: Optional[list[str]] = None, + packages: Optional[list[str]] = None, + reasons: Optional[list[str]] = None, + categories: Optional[list[str]] = None, + include_excluded_scrap: bool = False, + exclude_material_scrap: bool = True, +) -> tuple[str, dict[str, Any], dict[str, Any]]: + builder = QueryBuilder() + + normalized_wc_groups = sorted({_normalize_text(v) for v in (workcenter_groups or []) if _normalize_text(v)}) + normalized_packages = sorted({_normalize_text(v) for v in (packages or []) if _normalize_text(v)}) + normalized_reasons = sorted({_normalize_text(v) for v in (reasons or []) if _normalize_text(v)}) + material_reason_selected = MATERIAL_REASON_OPTION in normalized_reasons + reason_name_filters = [value for value in normalized_reasons if value != MATERIAL_REASON_OPTION] + normalized_categories = sorted({_normalize_text(v) for v in (categories or []) if _normalize_text(v)}) + + if normalized_wc_groups: + builder.add_in_condition("b.WORKCENTER_GROUP", normalized_wc_groups) + if normalized_packages: + builder.add_in_condition("b.PRODUCTLINENAME", normalized_packages) + if reason_name_filters: + builder.add_in_condition("b.LOSSREASONNAME", reason_name_filters) + if material_reason_selected: + builder.add_condition("UPPER(NVL(TRIM(b.SCRAP_OBJECTTYPE), '-')) = 'MATERIAL'") + material_exclusion_applied = False + if exclude_material_scrap and not material_reason_selected: + builder.add_condition("UPPER(NVL(TRIM(b.SCRAP_OBJECTTYPE), '-')) <> 'MATERIAL'") + material_exclusion_applied = True + if normalized_categories: + builder.add_in_condition("b.REJECTCATEGORYNAME", normalized_categories) + + exclusions_applied = False + excluded_reason_codes = [] + reason_name_prefix_policy_applied = False + if not include_excluded_scrap: + excluded_reason_codes = sorted(get_excluded_reasons()) + reason_name_prefix_policy_applied = True + if excluded_reason_codes: + exclusions_applied = True + # Support exclusion matching by both normalized reason code and full reason text. + builder.add_not_in_condition( + "UPPER(NVL(TRIM(b.LOSSREASON_CODE), '-'))", + excluded_reason_codes, + ) + builder.add_not_in_condition( + "UPPER(NVL(TRIM(b.LOSSREASONNAME), '-'))", + excluded_reason_codes, + ) + # Exclude reason labels that are not "NNN_*", and always exclude XXX_/ZZZ_ prefixes. + builder.add_condition( + "REGEXP_LIKE(UPPER(NVL(TRIM(b.LOSSREASONNAME), '')), '^[0-9]{3}_')" + ) + builder.add_condition( + "NOT REGEXP_LIKE(UPPER(NVL(TRIM(b.LOSSREASONNAME), '')), '^(XXX|ZZZ)_')" + ) + exclusions_applied = True + + where_clause, params = builder.build_where_only() + meta = { + "include_excluded_scrap": bool(include_excluded_scrap), + "exclusion_applied": exclusions_applied, + "reason_name_prefix_policy_applied": reason_name_prefix_policy_applied, + "exclude_material_scrap": bool(exclude_material_scrap), + "material_exclusion_applied": material_exclusion_applied, + "excluded_reason_count": len(excluded_reason_codes), + "workcenter_group_count": len(normalized_wc_groups), + "package_filter_count": len(normalized_packages), + "reason_filter_count": len(reason_name_filters), + "material_reason_selected": material_reason_selected, + } + return where_clause, params, meta + + +def _prepare_sql( + name: str, + *, + where_clause: str = "", + bucket_expr: str = "", + metric_column: str = "", +) -> str: + sql = _load_sql(name) + sql = sql.replace("{{ BASE_QUERY }}", _base_query_sql()) + sql = sql.replace("{{ BASE_WITH_CTE }}", _base_with_cte_sql("base")) + sql = sql.replace("{{ WHERE_CLAUSE }}", where_clause or "") + sql = sql.replace("{{ BUCKET_EXPR }}", bucket_expr or "TRUNC(b.TXN_DAY)") + sql = sql.replace("{{ METRIC_COLUMN }}", metric_column or "b.REJECT_TOTAL_QTY") + return sql + + +def _common_params(start_date: str, end_date: str, extra: Optional[dict[str, Any]] = None) -> dict[str, Any]: + params = {"start_date": start_date, "end_date": end_date} + if extra: + params.update(extra) + return params + + +def _list_to_csv( + rows: Iterable[dict[str, Any]], + headers: list[str], +) -> Generator[str, None, None]: + buffer = io.StringIO() + writer = csv.DictWriter(buffer, fieldnames=headers) + writer.writeheader() + yield buffer.getvalue() + buffer.seek(0) + buffer.truncate(0) + + for row in rows: + writer.writerow(row) + yield buffer.getvalue() + buffer.seek(0) + buffer.truncate(0) + + +def get_filter_options( + *, + start_date: str, + end_date: str, + include_excluded_scrap: bool = False, + exclude_material_scrap: bool = True, +) -> dict[str, Any]: + """Return workcenter-group / package / reason options.""" + _validate_range(start_date, end_date) + + where_clause, params, meta = _build_where_clause( + include_excluded_scrap=include_excluded_scrap, + exclude_material_scrap=exclude_material_scrap, + ) + reason_sql = _prepare_sql("reason_options", where_clause=where_clause) + reason_df = read_sql_df(reason_sql, _common_params(start_date, end_date, params)) + reasons = [] + if reason_df is not None and not reason_df.empty: + reasons = [ + _normalize_text(v) + for v in reason_df.get("REASON", []) + if _normalize_text(v) + ] + + material_sql = _prepare_sql("material_reason_option", where_clause=where_clause) + material_df = read_sql_df(material_sql, _common_params(start_date, end_date, params)) + has_material_option = False + if material_df is not None and not material_df.empty: + has_material_option = _as_int(material_df.iloc[0].get("HAS_MATERIAL")) > 0 + + package_sql = _prepare_sql("package_options", where_clause=where_clause) + package_df = read_sql_df(package_sql, _common_params(start_date, end_date, params)) + packages = [] + if package_df is not None and not package_df.empty: + packages = [ + _normalize_text(v) + for v in package_df.get("PACKAGE", []) + if _normalize_text(v) + ] + + groups_raw = get_workcenter_groups() or [] + workcenter_groups = [] + for item in groups_raw: + name = _normalize_text(item.get("name")) + if not name: + continue + workcenter_groups.append( + { + "name": name, + "sequence": _as_int(item.get("sequence")), + } + ) + + reason_options = sorted(set(reasons)) + if has_material_option and MATERIAL_REASON_OPTION not in reason_options: + reason_options.append(MATERIAL_REASON_OPTION) + return { + "workcenter_groups": workcenter_groups, + "packages": sorted(set(packages)), + "reasons": reason_options, + "meta": meta, + } + + +def query_summary( + *, + start_date: str, + end_date: str, + workcenter_groups: Optional[list[str]] = None, + packages: Optional[list[str]] = None, + reasons: Optional[list[str]] = None, + categories: Optional[list[str]] = None, + include_excluded_scrap: bool = False, + exclude_material_scrap: bool = True, +) -> dict[str, Any]: + _validate_range(start_date, end_date) + where_clause, params, meta = _build_where_clause( + workcenter_groups=workcenter_groups, + packages=packages, + reasons=reasons, + categories=categories, + include_excluded_scrap=include_excluded_scrap, + exclude_material_scrap=exclude_material_scrap, + ) + sql = _prepare_sql("summary", where_clause=where_clause) + df = read_sql_df(sql, _common_params(start_date, end_date, params)) + row = (df.iloc[0] if df is not None and not df.empty else {}) + + return { + "MOVEIN_QTY": _as_int(row.get("MOVEIN_QTY")), + "REJECT_TOTAL_QTY": _as_int(row.get("REJECT_TOTAL_QTY")), + "DEFECT_QTY": _as_int(row.get("DEFECT_QTY")), + "REJECT_RATE_PCT": round(_as_float(row.get("REJECT_RATE_PCT")), 4), + "DEFECT_RATE_PCT": round(_as_float(row.get("DEFECT_RATE_PCT")), 4), + "REJECT_SHARE_PCT": round(_as_float(row.get("REJECT_SHARE_PCT")), 4), + "AFFECTED_LOT_COUNT": _as_int(row.get("AFFECTED_LOT_COUNT")), + "AFFECTED_WORKORDER_COUNT": _as_int(row.get("AFFECTED_WORKORDER_COUNT")), + "meta": meta, + } + + +def query_trend( + *, + start_date: str, + end_date: str, + granularity: str = "day", + workcenter_groups: Optional[list[str]] = None, + packages: Optional[list[str]] = None, + reasons: Optional[list[str]] = None, + categories: Optional[list[str]] = None, + include_excluded_scrap: bool = False, + exclude_material_scrap: bool = True, +) -> dict[str, Any]: + _validate_range(start_date, end_date) + normalized_granularity = _normalize_text(granularity).lower() or "day" + if normalized_granularity not in VALID_GRANULARITY: + raise ValueError("Invalid granularity. Use day, week, or month") + + where_clause, params, meta = _build_where_clause( + workcenter_groups=workcenter_groups, + packages=packages, + reasons=reasons, + categories=categories, + include_excluded_scrap=include_excluded_scrap, + exclude_material_scrap=exclude_material_scrap, + ) + sql = _prepare_sql( + "trend", + where_clause=where_clause, + bucket_expr=_date_bucket_expr(normalized_granularity), + ) + df = read_sql_df(sql, _common_params(start_date, end_date, params)) + items = [] + if df is not None and not df.empty: + for _, row in df.iterrows(): + items.append( + { + "bucket_date": _to_date_str(row.get("BUCKET_DATE")), + "MOVEIN_QTY": _as_int(row.get("MOVEIN_QTY")), + "REJECT_TOTAL_QTY": _as_int(row.get("REJECT_TOTAL_QTY")), + "DEFECT_QTY": _as_int(row.get("DEFECT_QTY")), + "REJECT_RATE_PCT": round(_as_float(row.get("REJECT_RATE_PCT")), 4), + "DEFECT_RATE_PCT": round(_as_float(row.get("DEFECT_RATE_PCT")), 4), + } + ) + + return { + "items": items, + "granularity": normalized_granularity, + "meta": meta, + } + + +def query_reason_pareto( + *, + start_date: str, + end_date: str, + metric_mode: str = "reject_total", + pareto_scope: str = "top80", + workcenter_groups: Optional[list[str]] = None, + packages: Optional[list[str]] = None, + reasons: Optional[list[str]] = None, + categories: Optional[list[str]] = None, + include_excluded_scrap: bool = False, + exclude_material_scrap: bool = True, +) -> dict[str, Any]: + _validate_range(start_date, end_date) + normalized_metric = _normalize_text(metric_mode).lower() or "reject_total" + if normalized_metric not in VALID_METRIC_MODE: + raise ValueError("Invalid metric_mode. Use reject_total or defect") + + normalized_scope = _normalize_text(pareto_scope).lower() or "top80" + if normalized_scope not in {"top80", "all"}: + raise ValueError("Invalid pareto_scope. Use top80 or all") + + where_clause, params, meta = _build_where_clause( + workcenter_groups=workcenter_groups, + packages=packages, + reasons=reasons, + categories=categories, + include_excluded_scrap=include_excluded_scrap, + exclude_material_scrap=exclude_material_scrap, + ) + sql = _prepare_sql( + "reason_pareto", + where_clause=where_clause, + metric_column=_metric_column(normalized_metric), + ) + df = read_sql_df(sql, _common_params(start_date, end_date, params)) + all_items = [] + if df is not None and not df.empty: + for _, row in df.iterrows(): + all_items.append( + { + "reason": _normalize_text(row.get("REASON")) or "(未填寫)", + "metric_value": _as_float(row.get("METRIC_VALUE")), + "MOVEIN_QTY": _as_int(row.get("MOVEIN_QTY")), + "REJECT_TOTAL_QTY": _as_int(row.get("REJECT_TOTAL_QTY")), + "DEFECT_QTY": _as_int(row.get("DEFECT_QTY")), + "count": _as_int(row.get("AFFECTED_LOT_COUNT")), + "pct": round(_as_float(row.get("PCT")), 4), + "cumPct": round(_as_float(row.get("CUM_PCT")), 4), + } + ) + + items = list(all_items) + if normalized_scope == "top80" and items: + top_items = [item for item in items if _as_float(item.get("cumPct")) <= 80.0] + # Keep strict top-80% behavior, but still return one row when first item already exceeds 80%. + if not top_items: + top_items = [items[0]] + items = top_items + + return { + "items": items, + "metric_mode": normalized_metric, + "pareto_scope": normalized_scope, + "meta": { + **meta, + "total_items_after_filter": len(all_items), + "displayed_items": len(items), + }, + } + + +def query_list( + *, + start_date: str, + end_date: str, + page: int = 1, + per_page: int = 50, + workcenter_groups: Optional[list[str]] = None, + packages: Optional[list[str]] = None, + reasons: Optional[list[str]] = None, + categories: Optional[list[str]] = None, + include_excluded_scrap: bool = False, + exclude_material_scrap: bool = True, +) -> dict[str, Any]: + _validate_range(start_date, end_date) + + page = max(int(page or 1), 1) + per_page = min(max(int(per_page or 50), 1), 200) + offset = (page - 1) * per_page + + where_clause, params, meta = _build_where_clause( + workcenter_groups=workcenter_groups, + packages=packages, + reasons=reasons, + categories=categories, + include_excluded_scrap=include_excluded_scrap, + exclude_material_scrap=exclude_material_scrap, + ) + sql = _prepare_sql("list", where_clause=where_clause) + query_params = _common_params( + start_date, + end_date, + { + **params, + "offset": offset, + "limit": per_page, + }, + ) + df = read_sql_df(sql, query_params) + + items = [] + total = 0 + if df is not None and not df.empty: + total = _as_int(df.iloc[0].get("TOTAL_COUNT")) + for _, row in df.iterrows(): + items.append( + { + "TXN_DAY": _to_date_str(row.get("TXN_DAY")), + "TXN_MONTH": _normalize_text(row.get("TXN_MONTH")), + "WORKCENTER_GROUP": _normalize_text(row.get("WORKCENTER_GROUP")), + "WORKCENTERNAME": _normalize_text(row.get("WORKCENTERNAME")), + "SPECNAME": _normalize_text(row.get("SPECNAME")), + "PRODUCTLINENAME": _normalize_text(row.get("PRODUCTLINENAME")), + "PJ_TYPE": _normalize_text(row.get("PJ_TYPE")), + "LOSSREASONNAME": _normalize_text(row.get("LOSSREASONNAME")), + "LOSSREASON_CODE": _normalize_text(row.get("LOSSREASON_CODE")), + "MOVEIN_QTY": _as_int(row.get("MOVEIN_QTY")), + "REJECT_QTY": _as_int(row.get("REJECT_QTY")), + "STANDBY_QTY": _as_int(row.get("STANDBY_QTY")), + "QTYTOPROCESS_QTY": _as_int(row.get("QTYTOPROCESS_QTY")), + "INPROCESS_QTY": _as_int(row.get("INPROCESS_QTY")), + "PROCESSED_QTY": _as_int(row.get("PROCESSED_QTY")), + "REJECT_TOTAL_QTY": _as_int(row.get("REJECT_TOTAL_QTY")), + "DEFECT_QTY": _as_int(row.get("DEFECT_QTY")), + "REJECT_RATE_PCT": round(_as_float(row.get("REJECT_RATE_PCT")), 4), + "DEFECT_RATE_PCT": round(_as_float(row.get("DEFECT_RATE_PCT")), 4), + "REJECT_SHARE_PCT": round(_as_float(row.get("REJECT_SHARE_PCT")), 4), + "AFFECTED_LOT_COUNT": _as_int(row.get("AFFECTED_LOT_COUNT")), + "AFFECTED_WORKORDER_COUNT": _as_int(row.get("AFFECTED_WORKORDER_COUNT")), + } + ) + + total_pages = max((total + per_page - 1) // per_page, 1) if total else 1 + return { + "items": items, + "pagination": { + "page": page, + "perPage": per_page, + "total": total, + "totalPages": total_pages, + }, + "meta": meta, + } + + +def export_csv( + *, + start_date: str, + end_date: str, + workcenter_groups: Optional[list[str]] = None, + packages: Optional[list[str]] = None, + reasons: Optional[list[str]] = None, + categories: Optional[list[str]] = None, + include_excluded_scrap: bool = False, + exclude_material_scrap: bool = True, +) -> Generator[str, None, None]: + _validate_range(start_date, end_date) + + where_clause, params, _meta = _build_where_clause( + workcenter_groups=workcenter_groups, + packages=packages, + reasons=reasons, + categories=categories, + include_excluded_scrap=include_excluded_scrap, + exclude_material_scrap=exclude_material_scrap, + ) + sql = _prepare_sql("export", where_clause=where_clause) + df = read_sql_df(sql, _common_params(start_date, end_date, params)) + + rows = [] + if df is not None and not df.empty: + for _, row in df.iterrows(): + rows.append( + { + "TXN_DAY": _to_date_str(row.get("TXN_DAY")), + "TXN_MONTH": _normalize_text(row.get("TXN_MONTH")), + "WORKCENTER_GROUP": _normalize_text(row.get("WORKCENTER_GROUP")), + "WORKCENTERNAME": _normalize_text(row.get("WORKCENTERNAME")), + "SPECNAME": _normalize_text(row.get("SPECNAME")), + "PRODUCTLINENAME": _normalize_text(row.get("PRODUCTLINENAME")), + "PJ_TYPE": _normalize_text(row.get("PJ_TYPE")), + "LOSSREASONNAME": _normalize_text(row.get("LOSSREASONNAME")), + "LOSSREASON_CODE": _normalize_text(row.get("LOSSREASON_CODE")), + "MOVEIN_QTY": _as_int(row.get("MOVEIN_QTY")), + "REJECT_QTY": _as_int(row.get("REJECT_QTY")), + "STANDBY_QTY": _as_int(row.get("STANDBY_QTY")), + "QTYTOPROCESS_QTY": _as_int(row.get("QTYTOPROCESS_QTY")), + "INPROCESS_QTY": _as_int(row.get("INPROCESS_QTY")), + "PROCESSED_QTY": _as_int(row.get("PROCESSED_QTY")), + "REJECT_TOTAL_QTY": _as_int(row.get("REJECT_TOTAL_QTY")), + "DEFECT_QTY": _as_int(row.get("DEFECT_QTY")), + "REJECT_RATE_PCT": round(_as_float(row.get("REJECT_RATE_PCT")), 4), + "DEFECT_RATE_PCT": round(_as_float(row.get("DEFECT_RATE_PCT")), 4), + "REJECT_SHARE_PCT": round(_as_float(row.get("REJECT_SHARE_PCT")), 4), + "AFFECTED_LOT_COUNT": _as_int(row.get("AFFECTED_LOT_COUNT")), + "AFFECTED_WORKORDER_COUNT": _as_int(row.get("AFFECTED_WORKORDER_COUNT")), + } + ) + + headers = [ + "TXN_DAY", + "TXN_MONTH", + "WORKCENTER_GROUP", + "WORKCENTERNAME", + "SPECNAME", + "PRODUCTLINENAME", + "PJ_TYPE", + "LOSSREASONNAME", + "LOSSREASON_CODE", + "MOVEIN_QTY", + "REJECT_QTY", + "STANDBY_QTY", + "QTYTOPROCESS_QTY", + "INPROCESS_QTY", + "PROCESSED_QTY", + "REJECT_TOTAL_QTY", + "DEFECT_QTY", + "REJECT_RATE_PCT", + "DEFECT_RATE_PCT", + "REJECT_SHARE_PCT", + "AFFECTED_LOT_COUNT", + "AFFECTED_WORKORDER_COUNT", + ] + return _list_to_csv(rows, headers=headers) diff --git a/src/mes_dashboard/services/scrap_reason_exclusion_cache.py b/src/mes_dashboard/services/scrap_reason_exclusion_cache.py new file mode 100644 index 0000000..b57cd87 --- /dev/null +++ b/src/mes_dashboard/services/scrap_reason_exclusion_cache.py @@ -0,0 +1,236 @@ +# -*- coding: utf-8 -*- +"""Cache for ERP scrap reasons excluded from yield calculations. + +Policy source: DWH.ERP_PJ_WIP_SCRAP_REASONS_EXCLUDE (ENABLE_FLAG='Y'). +Cache strategy: +- L2 Redis shared cache when available +- L1 process memory cache fallback +- Daily full-table refresh (default every 24 hours) +""" + +from __future__ import annotations + +import json +import logging +import os +import threading +import time +from datetime import datetime +from typing import Iterable + +from mes_dashboard.core.database import read_sql_df +from mes_dashboard.core.redis_client import get_key, get_redis_client, try_acquire_lock, release_lock + +logger = logging.getLogger("mes_dashboard.scrap_reason_exclusion_cache") + +_REFRESH_SECONDS = max(int(os.getenv("SCRAP_REASON_EXCLUSION_REFRESH_SECONDS", "86400")), 60) +_REDIS_DATA_KEY = get_key("scrap_exclusion:enabled_reasons") +_REDIS_META_KEY = get_key("scrap_exclusion:updated_at") +_LOCK_NAME = "scrap_reason_exclusion_cache_refresh" + +_CACHE_LOCK = threading.Lock() +_CACHE: dict[str, object] = { + "reasons": set(), + "updated_at": None, + "loaded": False, + "source": None, +} + +_WORKER_THREAD: threading.Thread | None = None +_STOP_EVENT = threading.Event() + + +def _normalize_reason(value: object) -> str: + return str(value or "").strip().upper() + + +def _extract_reason_codes(values: Iterable[object]) -> set[str]: + normalized = {_normalize_reason(v) for v in values} + normalized.discard("") + return normalized + + +def _load_from_redis() -> tuple[set[str], str | None] | None: + client = get_redis_client() + if client is None: + return None + + try: + raw = client.get(_REDIS_DATA_KEY) + if not raw: + return None + data = json.loads(raw) + if not isinstance(data, list): + return None + reasons = _extract_reason_codes(data) + updated = client.get(_REDIS_META_KEY) + if not reasons: + return None + return reasons, updated + except Exception as exc: # pragma: no cover - defensive + logger.warning("Failed to read scrap exclusion cache from Redis: %s", exc) + return None + + +def _save_to_redis(reasons: set[str], updated_at: str) -> None: + client = get_redis_client() + if client is None: + return + + try: + payload = json.dumps(sorted(reasons), ensure_ascii=False) + # Keep redis key slightly longer than refresh interval to cover worker restarts. + ttl = int(_REFRESH_SECONDS * 1.5) + client.setex(_REDIS_DATA_KEY, ttl, payload) + client.setex(_REDIS_META_KEY, ttl, updated_at) + except Exception as exc: # pragma: no cover - defensive + logger.warning("Failed to write scrap exclusion cache to Redis: %s", exc) + + +def _read_enabled_reasons_from_oracle() -> set[str]: + sql = """ + SELECT TRIM(REASON_NAME) AS REASON_NAME + FROM DWH.ERP_PJ_WIP_SCRAP_REASONS_EXCLUDE + WHERE NVL(ENABLE_FLAG, 'N') = 'Y' + """ + df = read_sql_df(sql) + if df is None or df.empty: + return set() + return _extract_reason_codes(df.get("REASON_NAME", [])) + + +def _set_local_cache(reasons: set[str], *, source: str, updated_at: str) -> None: + with _CACHE_LOCK: + _CACHE["reasons"] = set(reasons) + _CACHE["updated_at"] = updated_at + _CACHE["loaded"] = True + _CACHE["source"] = source + + +def _seconds_since_update() -> float: + with _CACHE_LOCK: + updated_at = _CACHE.get("updated_at") + if not isinstance(updated_at, str) or not updated_at: + return float("inf") + try: + dt = datetime.fromisoformat(updated_at) + return max((datetime.now() - dt).total_seconds(), 0.0) + except ValueError: + return float("inf") + + +def refresh_cache(force: bool = False) -> bool: + """Refresh exclusion policy cache from Oracle. + + Returns: + True if cache contains usable data after refresh attempt. + """ + if not force and _seconds_since_update() < _REFRESH_SECONDS: + return True + + # Cross-worker lock (fail-open when Redis unavailable). + if not try_acquire_lock(_LOCK_NAME, ttl_seconds=120): + logger.debug("Scrap exclusion cache refresh skipped (lock held by another worker)") + return bool(get_excluded_reasons()) + + try: + reasons = _read_enabled_reasons_from_oracle() + updated_at = datetime.now().isoformat() + _set_local_cache(reasons, source="oracle", updated_at=updated_at) + _save_to_redis(reasons, updated_at) + logger.info("Scrap exclusion cache refreshed: %s reasons", len(reasons)) + return True + except Exception as exc: + # Fallback to Redis payload if local refresh fails. + redis_payload = _load_from_redis() + if redis_payload is not None: + reasons, updated = redis_payload + _set_local_cache(reasons, source="redis", updated_at=updated or datetime.now().isoformat()) + logger.warning("Scrap exclusion cache fallback to Redis: %s", exc) + return True + + with _CACHE_LOCK: + loaded = bool(_CACHE.get("loaded")) + logger.error("Scrap exclusion cache refresh failed: %s", exc) + return loaded + finally: + release_lock(_LOCK_NAME) + + +def get_excluded_reasons(force_refresh: bool = False) -> set[str]: + """Get currently cached exclusion reason codes.""" + with _CACHE_LOCK: + loaded = bool(_CACHE.get("loaded")) + reasons = set(_CACHE.get("reasons", set())) + + if force_refresh: + refresh_cache(force=True) + elif not loaded: + # Try Redis before Oracle for fast startup in secondary workers. + redis_payload = _load_from_redis() + if redis_payload is not None: + redis_reasons, updated = redis_payload + _set_local_cache( + redis_reasons, + source="redis", + updated_at=updated or datetime.now().isoformat(), + ) + return redis_reasons + refresh_cache(force=True) + elif _seconds_since_update() >= _REFRESH_SECONDS: + refresh_cache(force=True) + + with _CACHE_LOCK: + return set(_CACHE.get("reasons", set())) + + +def get_cache_status() -> dict[str, object]: + """Expose cache diagnostics for health/admin pages.""" + with _CACHE_LOCK: + return { + "loaded": bool(_CACHE.get("loaded")), + "updated_at": _CACHE.get("updated_at"), + "source": _CACHE.get("source"), + "reason_count": len(_CACHE.get("reasons", set())), + "refresh_interval_seconds": _REFRESH_SECONDS, + } + + +def _worker_loop() -> None: + logger.info("Scrap exclusion sync worker started (interval: %ss)", _REFRESH_SECONDS) + while not _STOP_EVENT.is_set(): + try: + refresh_cache(force=True) + except Exception as exc: # pragma: no cover - defensive + logger.warning("Scrap exclusion worker refresh failed: %s", exc) + _STOP_EVENT.wait(_REFRESH_SECONDS) + logger.info("Scrap exclusion sync worker stopped") + + +def init_scrap_reason_exclusion_cache(app=None) -> None: + """Initialize cache and start background sync worker.""" + refresh_cache(force=False) + + global _WORKER_THREAD + if app is not None and app.config.get("TESTING"): + return + + if _WORKER_THREAD and _WORKER_THREAD.is_alive(): + return + + _STOP_EVENT.clear() + _WORKER_THREAD = threading.Thread( + target=_worker_loop, + daemon=True, + name="scrap-exclusion-cache-sync", + ) + _WORKER_THREAD.start() + + +def stop_scrap_reason_exclusion_cache_worker(timeout: int = 5) -> None: + global _WORKER_THREAD + if not _WORKER_THREAD: + return + _STOP_EVENT.set() + _WORKER_THREAD.join(timeout=timeout) + _WORKER_THREAD = None diff --git a/src/mes_dashboard/sql/reject_history/export.sql b/src/mes_dashboard/sql/reject_history/export.sql new file mode 100644 index 0000000..dbadf9c --- /dev/null +++ b/src/mes_dashboard/sql/reject_history/export.sql @@ -0,0 +1,36 @@ +-- Reject History Export (Unpaginated) +-- Template slots: +-- BASE_QUERY (base reject-history daily dataset SQL) +-- WHERE_CLAUSE (QueryBuilder-generated WHERE clause against alias b) + +{{ BASE_WITH_CTE }} +SELECT + b.TXN_DAY, + b.TXN_MONTH, + b.WORKCENTER_GROUP, + b.WORKCENTERNAME, + b.SPECNAME, + b.PRODUCTLINENAME, + b.PJ_TYPE, + b.LOSSREASONNAME, + b.LOSSREASON_CODE, + b.MOVEIN_QTY, + b.REJECT_QTY, + b.STANDBY_QTY, + b.QTYTOPROCESS_QTY, + b.INPROCESS_QTY, + b.PROCESSED_QTY, + b.REJECT_TOTAL_QTY, + b.DEFECT_QTY, + b.REJECT_RATE_PCT, + b.DEFECT_RATE_PCT, + b.REJECT_SHARE_PCT, + b.AFFECTED_LOT_COUNT, + b.AFFECTED_WORKORDER_COUNT +FROM base b +{{ WHERE_CLAUSE }} +ORDER BY + b.TXN_DAY DESC, + b.WORKCENTERSEQUENCE_GROUP ASC, + b.WORKCENTERNAME ASC, + b.REJECT_TOTAL_QTY DESC diff --git a/src/mes_dashboard/sql/reject_history/list.sql b/src/mes_dashboard/sql/reject_history/list.sql new file mode 100644 index 0000000..54b38f0 --- /dev/null +++ b/src/mes_dashboard/sql/reject_history/list.sql @@ -0,0 +1,48 @@ +-- Reject History Detail List (Paginated) +-- Template slots: +-- BASE_QUERY (base reject-history daily dataset SQL) +-- WHERE_CLAUSE (QueryBuilder-generated WHERE clause against alias b) + +{{ BASE_WITH_CTE }}, +filtered AS ( + SELECT + b.*, + COUNT(*) OVER () AS TOTAL_COUNT + FROM base b + {{ WHERE_CLAUSE }} +) +SELECT + TXN_DAY, + TXN_MONTH, + WORKCENTER_GROUP, + WORKCENTERSEQUENCE_GROUP, + WORKCENTERNAME, + SPECNAME, + EQUIPMENTNAME, + PRIMARY_EQUIPMENTNAME, + PRODUCTLINENAME, + PJ_TYPE, + LOSSREASONNAME, + LOSSREASON_CODE, + REJECT_EVENT_ROWS, + AFFECTED_LOT_COUNT, + AFFECTED_WORKORDER_COUNT, + MOVEIN_QTY, + REJECT_QTY, + REJECT_TOTAL_QTY, + DEFECT_QTY, + STANDBY_QTY, + QTYTOPROCESS_QTY, + INPROCESS_QTY, + PROCESSED_QTY, + REJECT_RATE_PCT, + DEFECT_RATE_PCT, + REJECT_SHARE_PCT, + TOTAL_COUNT +FROM filtered +ORDER BY + TXN_DAY DESC, + WORKCENTERSEQUENCE_GROUP ASC, + WORKCENTERNAME ASC, + REJECT_TOTAL_QTY DESC +OFFSET :offset ROWS FETCH NEXT :limit ROWS ONLY diff --git a/src/mes_dashboard/sql/reject_history/material_reason_option.sql b/src/mes_dashboard/sql/reject_history/material_reason_option.sql new file mode 100644 index 0000000..2d4b1d7 --- /dev/null +++ b/src/mes_dashboard/sql/reject_history/material_reason_option.sql @@ -0,0 +1,19 @@ +-- Reject History Material Reason Option +-- Template slots: +-- BASE_QUERY (base reject-history daily dataset SQL) +-- WHERE_CLAUSE (QueryBuilder-generated WHERE clause against alias b) + +{{ BASE_WITH_CTE }} +SELECT + CASE + WHEN SUM( + CASE + WHEN UPPER(NVL(TRIM(b.SCRAP_OBJECTTYPE), '-')) = 'MATERIAL' + THEN NVL(b.REJECT_TOTAL_QTY, 0) + NVL(b.DEFECT_QTY, 0) + ELSE 0 + END + ) > 0 THEN 1 + ELSE 0 + END AS HAS_MATERIAL +FROM base b +{{ WHERE_CLAUSE }} diff --git a/src/mes_dashboard/sql/reject_history/package_options.sql b/src/mes_dashboard/sql/reject_history/package_options.sql new file mode 100644 index 0000000..cadcda8 --- /dev/null +++ b/src/mes_dashboard/sql/reject_history/package_options.sql @@ -0,0 +1,15 @@ +-- Reject History Package Options +-- Template slots: +-- BASE_QUERY (base reject-history daily dataset SQL) +-- WHERE_CLAUSE (QueryBuilder-generated WHERE clause against alias b) + +{{ BASE_WITH_CTE }} +SELECT + b.PRODUCTLINENAME AS PACKAGE, + SUM(b.REJECT_TOTAL_QTY) AS REJECT_TOTAL_QTY, + SUM(b.DEFECT_QTY) AS DEFECT_QTY +FROM base b +{{ WHERE_CLAUSE }} +GROUP BY b.PRODUCTLINENAME +HAVING SUM(b.REJECT_TOTAL_QTY) > 0 OR SUM(b.DEFECT_QTY) > 0 +ORDER BY PACKAGE diff --git a/src/mes_dashboard/sql/reject_history/performance_daily.sql b/src/mes_dashboard/sql/reject_history/performance_daily.sql index 5927956..4a65514 100644 --- a/src/mes_dashboard/sql/reject_history/performance_daily.sql +++ b/src/mes_dashboard/sql/reject_history/performance_daily.sql @@ -35,6 +35,7 @@ reject_raw AS ( NVL(TRIM(r.PJ_WORKORDER), TRIM(c.MFGORDERNAME)) AS PJ_WORKORDER, NVL(TRIM(c.PJ_TYPE), '(NA)') AS PJ_TYPE, NVL(TRIM(c.PRODUCTLINENAME), '(NA)') AS PRODUCTLINENAME, + NVL(TRIM(c.OBJECTTYPE), '(NA)') AS SCRAP_OBJECTTYPE, NVL(TRIM(r.WORKCENTERNAME), '(NA)') AS WORKCENTERNAME, NVL(TRIM(wm.WORKCENTER_GROUP), NVL(TRIM(r.WORKCENTERNAME), '(NA)')) AS WORKCENTER_GROUP, NVL(wm.WORKCENTERSEQUENCE_GROUP, 999) AS WORKCENTERSEQUENCE_GROUP, @@ -45,6 +46,10 @@ reject_raw AS ( NVL(TRIM(r.EQUIPMENTNAME), '(NA)') ) AS PRIMARY_EQUIPMENTNAME, NVL(TRIM(r.LOSSREASONNAME), '(未填寫)') AS LOSSREASONNAME, + NVL( + TRIM(REGEXP_SUBSTR(NVL(TRIM(r.LOSSREASONNAME), '(未填寫)'), '^[^_[:space:]-]+')), + NVL(TRIM(r.LOSSREASONNAME), '(未填寫)') + ) AS LOSSREASON_CODE, NVL(TRIM(r.REJECTCATEGORYNAME), '(未填寫)') AS REJECTCATEGORYNAME, NVL(r.MOVEINQTY, 0) AS MOVEINQTY, NVL(r.REJECTQTY, 0) AS REJECT_QTY, @@ -84,8 +89,10 @@ daily_agg AS ( EQUIPMENTNAME, PRIMARY_EQUIPMENTNAME, PRODUCTLINENAME, + SCRAP_OBJECTTYPE, PJ_TYPE, LOSSREASONNAME, + LOSSREASON_CODE, REJECTCATEGORYNAME, COUNT(*) AS REJECT_EVENT_ROWS, COUNT(DISTINCT CONTAINERID) AS AFFECTED_LOT_COUNT, @@ -109,8 +116,10 @@ daily_agg AS ( EQUIPMENTNAME, PRIMARY_EQUIPMENTNAME, PRODUCTLINENAME, + SCRAP_OBJECTTYPE, PJ_TYPE, LOSSREASONNAME, + LOSSREASON_CODE, REJECTCATEGORYNAME ) SELECT @@ -123,8 +132,10 @@ SELECT EQUIPMENTNAME, PRIMARY_EQUIPMENTNAME, PRODUCTLINENAME, + SCRAP_OBJECTTYPE, PJ_TYPE, LOSSREASONNAME, + LOSSREASON_CODE, REJECTCATEGORYNAME, REJECT_EVENT_ROWS, AFFECTED_LOT_COUNT, @@ -150,4 +161,3 @@ SELECT ELSE ROUND(REJECT_TOTAL_QTY * 100 / (REJECT_TOTAL_QTY + DEFECT_QTY), 4) END AS REJECT_SHARE_PCT FROM daily_agg -ORDER BY TXN_DAY DESC, WORKCENTERSEQUENCE_GROUP, WORKCENTERNAME, DEFECT_QTY DESC diff --git a/src/mes_dashboard/sql/reject_history/reason_options.sql b/src/mes_dashboard/sql/reject_history/reason_options.sql new file mode 100644 index 0000000..03c8e93 --- /dev/null +++ b/src/mes_dashboard/sql/reject_history/reason_options.sql @@ -0,0 +1,14 @@ +-- Reject History Reason Options +-- Template slots: +-- BASE_QUERY (base reject-history daily dataset SQL) + +{{ BASE_WITH_CTE }} +SELECT + b.LOSSREASONNAME AS REASON, + SUM(b.REJECT_TOTAL_QTY) AS REJECT_TOTAL_QTY, + SUM(b.DEFECT_QTY) AS DEFECT_QTY +FROM base b +{{ WHERE_CLAUSE }} +GROUP BY b.LOSSREASONNAME +HAVING SUM(b.REJECT_TOTAL_QTY) > 0 OR SUM(b.DEFECT_QTY) > 0 +ORDER BY REASON diff --git a/src/mes_dashboard/sql/reject_history/reason_pareto.sql b/src/mes_dashboard/sql/reject_history/reason_pareto.sql new file mode 100644 index 0000000..345aa2a --- /dev/null +++ b/src/mes_dashboard/sql/reject_history/reason_pareto.sql @@ -0,0 +1,41 @@ +-- Reject History Reason Pareto +-- Template slots: +-- BASE_QUERY (base reject-history daily dataset SQL) +-- METRIC_COLUMN (metric expression: b.REJECT_TOTAL_QTY or b.DEFECT_QTY) +-- WHERE_CLAUSE (QueryBuilder-generated WHERE clause against alias b) + +{{ BASE_WITH_CTE }}, +reason_agg AS ( + SELECT + b.LOSSREASONNAME AS REASON, + SUM({{ METRIC_COLUMN }}) AS METRIC_VALUE, + SUM(b.MOVEIN_QTY) AS MOVEIN_QTY, + SUM(b.REJECT_TOTAL_QTY) AS REJECT_TOTAL_QTY, + SUM(b.DEFECT_QTY) AS DEFECT_QTY, + SUM(b.AFFECTED_LOT_COUNT) AS AFFECTED_LOT_COUNT + FROM base b + {{ WHERE_CLAUSE }} + GROUP BY + b.LOSSREASONNAME + HAVING SUM({{ METRIC_COLUMN }}) > 0 +) +SELECT + REASON, + METRIC_VALUE, + MOVEIN_QTY, + REJECT_TOTAL_QTY, + DEFECT_QTY, + AFFECTED_LOT_COUNT, + ROUND( + METRIC_VALUE * 100 / NULLIF(SUM(METRIC_VALUE) OVER (), 0), + 4 + ) AS PCT, + ROUND( + SUM(METRIC_VALUE) OVER ( + ORDER BY METRIC_VALUE DESC, REASON + ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW + ) * 100 / NULLIF(SUM(METRIC_VALUE) OVER (), 0), + 4 + ) AS CUM_PCT +FROM reason_agg +ORDER BY METRIC_VALUE DESC, REASON diff --git a/src/mes_dashboard/sql/reject_history/summary.sql b/src/mes_dashboard/sql/reject_history/summary.sql new file mode 100644 index 0000000..bda640f --- /dev/null +++ b/src/mes_dashboard/sql/reject_history/summary.sql @@ -0,0 +1,31 @@ +-- Reject History Summary +-- Template slots: +-- BASE_QUERY (base reject-history daily dataset SQL) +-- WHERE_CLAUSE (QueryBuilder-generated WHERE clause against alias b) + +{{ BASE_WITH_CTE }} +SELECT + NVL(SUM(b.MOVEIN_QTY), 0) AS MOVEIN_QTY, + NVL(SUM(b.REJECT_TOTAL_QTY), 0) AS REJECT_TOTAL_QTY, + NVL(SUM(b.DEFECT_QTY), 0) AS DEFECT_QTY, + CASE + WHEN NVL(SUM(b.MOVEIN_QTY), 0) = 0 THEN 0 + ELSE ROUND(NVL(SUM(b.REJECT_TOTAL_QTY), 0) * 100 / SUM(b.MOVEIN_QTY), 4) + END AS REJECT_RATE_PCT, + CASE + WHEN NVL(SUM(b.MOVEIN_QTY), 0) = 0 THEN 0 + ELSE ROUND(NVL(SUM(b.DEFECT_QTY), 0) * 100 / SUM(b.MOVEIN_QTY), 4) + END AS DEFECT_RATE_PCT, + CASE + WHEN NVL(SUM(b.REJECT_TOTAL_QTY), 0) + NVL(SUM(b.DEFECT_QTY), 0) = 0 THEN 0 + ELSE ROUND( + NVL(SUM(b.REJECT_TOTAL_QTY), 0) * 100 + / (NVL(SUM(b.REJECT_TOTAL_QTY), 0) + NVL(SUM(b.DEFECT_QTY), 0)), + 4 + ) + END AS REJECT_SHARE_PCT, + COUNT(DISTINCT b.WORKCENTER_GROUP || ':' || b.WORKCENTERNAME || ':' || b.SPECNAME || ':' || b.TXN_DAY) AS AFFECTED_EVENT_BUCKET_COUNT, + NVL(SUM(b.AFFECTED_LOT_COUNT), 0) AS AFFECTED_LOT_COUNT, + NVL(SUM(b.AFFECTED_WORKORDER_COUNT), 0) AS AFFECTED_WORKORDER_COUNT +FROM base b +{{ WHERE_CLAUSE }} diff --git a/src/mes_dashboard/sql/reject_history/trend.sql b/src/mes_dashboard/sql/reject_history/trend.sql new file mode 100644 index 0000000..eeef511 --- /dev/null +++ b/src/mes_dashboard/sql/reject_history/trend.sql @@ -0,0 +1,32 @@ +-- Reject History Trend +-- Template slots: +-- BASE_QUERY (base reject-history daily dataset SQL) +-- BUCKET_EXPR (Oracle date bucket expression, e.g. TRUNC(b.TXN_DAY)) +-- WHERE_CLAUSE (QueryBuilder-generated WHERE clause against alias b) + +{{ BASE_WITH_CTE }}, +trend_raw AS ( + SELECT + {{ BUCKET_EXPR }} AS BUCKET_DATE, + SUM(b.MOVEIN_QTY) AS MOVEIN_QTY, + SUM(b.REJECT_TOTAL_QTY) AS REJECT_TOTAL_QTY, + SUM(b.DEFECT_QTY) AS DEFECT_QTY + FROM base b + {{ WHERE_CLAUSE }} + GROUP BY {{ BUCKET_EXPR }} +) +SELECT + BUCKET_DATE, + MOVEIN_QTY, + REJECT_TOTAL_QTY, + DEFECT_QTY, + CASE + WHEN MOVEIN_QTY = 0 THEN 0 + ELSE ROUND(REJECT_TOTAL_QTY * 100 / MOVEIN_QTY, 4) + END AS REJECT_RATE_PCT, + CASE + WHEN MOVEIN_QTY = 0 THEN 0 + ELSE ROUND(DEFECT_QTY * 100 / MOVEIN_QTY, 4) + END AS DEFECT_RATE_PCT +FROM trend_raw +ORDER BY BUCKET_DATE diff --git a/tests/test_app_factory.py b/tests/test_app_factory.py index a56976c..77b8d9d 100644 --- a/tests/test_app_factory.py +++ b/tests/test_app_factory.py @@ -55,6 +55,7 @@ class AppFactoryTests(unittest.TestCase): "/wip-overview", "/wip-detail", "/hold-overview", + "/reject-history", "/excel-query", "/query-tool", "/tmtt-defect", @@ -74,6 +75,7 @@ class AppFactoryTests(unittest.TestCase): "/api/excel-query/upload", "/api/query-tool/resolve", "/api/tmtt-defect/analysis", + "/api/reject-history/summary", } missing = expected - rules self.assertFalse(missing, f"Missing routes: {sorted(missing)}") diff --git a/tests/test_portal_shell_wave_b_native_smoke.py b/tests/test_portal_shell_wave_b_native_smoke.py index 9b5142c..41cb182 100644 --- a/tests/test_portal_shell_wave_b_native_smoke.py +++ b/tests/test_portal_shell_wave_b_native_smoke.py @@ -260,3 +260,133 @@ def test_tmtt_defect_native_smoke_range_query_and_csv_export(client): export = client.get("/api/tmtt-defect/export?start_date=2026-02-01&end_date=2026-02-11") assert export.status_code == 200 assert "text/csv" in export.content_type + + +def test_reject_history_native_smoke_query_sections_and_export(client): + _login_as_admin(client) + + shell = client.get("/portal-shell/reject-history?start_date=2026-02-01&end_date=2026-02-11") + assert shell.status_code == 200 + + page = client.get("/reject-history", follow_redirects=False) + if page.status_code == 302: + assert page.status_code == 302 + assert page.location.endswith("/portal-shell/reject-history") + elif page.status_code == 200: + assert page.status_code == 200 + else: + raise AssertionError(f"unexpected status for /reject-history: {page.status_code}") + + with ( + patch( + "mes_dashboard.routes.reject_history_routes.get_filter_options", + return_value={ + "workcenter_groups": [{"name": "WB", "sequence": 1}], + "reasons": ["R1"], + "meta": {"include_excluded_scrap": False}, + }, + ), + patch( + "mes_dashboard.routes.reject_history_routes.query_summary", + return_value={ + "MOVEIN_QTY": 100, + "REJECT_TOTAL_QTY": 10, + "DEFECT_QTY": 2, + "REJECT_RATE_PCT": 10.0, + "DEFECT_RATE_PCT": 2.0, + "REJECT_SHARE_PCT": 83.3333, + "AFFECTED_LOT_COUNT": 5, + "AFFECTED_WORKORDER_COUNT": 3, + "meta": {"include_excluded_scrap": False}, + }, + ), + patch( + "mes_dashboard.routes.reject_history_routes.query_trend", + return_value={ + "items": [ + { + "bucket_date": "2026-02-01", + "MOVEIN_QTY": 100, + "REJECT_TOTAL_QTY": 10, + "DEFECT_QTY": 2, + "REJECT_RATE_PCT": 10.0, + "DEFECT_RATE_PCT": 2.0, + } + ], + "granularity": "day", + "meta": {"include_excluded_scrap": False}, + }, + ), + patch( + "mes_dashboard.routes.reject_history_routes.query_reason_pareto", + return_value={ + "items": [ + { + "reason": "R1", + "category": "CAT1", + "metric_value": 10, + "pct": 100.0, + "cumPct": 100.0, + } + ], + "metric_mode": "reject_total", + "pareto_scope": "top80", + "meta": {"include_excluded_scrap": False}, + }, + ), + patch( + "mes_dashboard.routes.reject_history_routes.query_list", + return_value={ + "items": [ + { + "TXN_DAY": "2026-02-01", + "WORKCENTER_GROUP": "WB", + "WORKCENTERNAME": "WB01", + "LOSSREASONNAME": "R1", + "REJECT_TOTAL_QTY": 10, + "DEFECT_QTY": 2, + } + ], + "pagination": {"page": 1, "perPage": 50, "total": 1, "totalPages": 1}, + "meta": {"include_excluded_scrap": False}, + }, + ), + patch( + "mes_dashboard.routes.reject_history_routes.export_csv", + return_value=iter( + [ + "TXN_DAY,REJECT_TOTAL_QTY,DEFECT_QTY\n", + "2026-02-01,10,2\n", + ] + ), + ), + ): + options = client.get("/api/reject-history/options?start_date=2026-02-01&end_date=2026-02-11") + assert options.status_code == 200 + assert options.get_json()["success"] is True + assert options.get_json()["data"]["reasons"] == ["R1"] + + summary = client.get("/api/reject-history/summary?start_date=2026-02-01&end_date=2026-02-11") + assert summary.status_code == 200 + summary_payload = summary.get_json() + assert summary_payload["success"] is True + assert summary_payload["data"]["REJECT_TOTAL_QTY"] == 10 + + trend = client.get("/api/reject-history/trend?start_date=2026-02-01&end_date=2026-02-11") + assert trend.status_code == 200 + assert trend.get_json()["success"] is True + assert trend.get_json()["data"]["items"][0]["bucket_date"] == "2026-02-01" + + pareto = client.get("/api/reject-history/reason-pareto?start_date=2026-02-01&end_date=2026-02-11") + assert pareto.status_code == 200 + assert pareto.get_json()["success"] is True + assert pareto.get_json()["data"]["items"][0]["reason"] == "R1" + + detail = client.get("/api/reject-history/list?start_date=2026-02-01&end_date=2026-02-11") + assert detail.status_code == 200 + assert detail.get_json()["success"] is True + assert detail.get_json()["data"]["pagination"]["total"] == 1 + + export = client.get("/api/reject-history/export?start_date=2026-02-01&end_date=2026-02-11") + assert export.status_code == 200 + assert "text/csv" in export.content_type diff --git a/tests/test_reject_history_routes.py b/tests/test_reject_history_routes.py new file mode 100644 index 0000000..64ed3fd --- /dev/null +++ b/tests/test_reject_history_routes.py @@ -0,0 +1,150 @@ +# -*- coding: utf-8 -*- +"""Unit tests for reject-history routes.""" + +import json +import os +import unittest +from unittest.mock import patch + +from mes_dashboard.app import create_app +import mes_dashboard.core.database as db + + +def _login_as_admin(client): + with client.session_transaction() as sess: + sess['admin'] = {'displayName': 'Admin', 'employeeNo': 'A001'} + + +class TestRejectHistoryRoutesBase(unittest.TestCase): + def setUp(self): + db._ENGINE = None + self.app = create_app('testing') + self.app.config['TESTING'] = True + self.client = self.app.test_client() + + +class TestRejectHistoryPageRoute(unittest.TestCase): + @patch.dict(os.environ, {'PORTAL_SPA_ENABLED': 'false'}) + @patch('mes_dashboard.app.os.path.exists', return_value=False) + def test_reject_history_page_fallback_contains_vite_entry(self, _mock_exists): + db._ENGINE = None + app = create_app('testing') + app.config['TESTING'] = True + client = app.test_client() + _login_as_admin(client) + + response = client.get('/reject-history', follow_redirects=False) + self.assertEqual(response.status_code, 200) + html = response.data.decode('utf-8') + self.assertIn('/static/dist/reject-history.js', html) + + +class TestRejectHistoryApiRoutes(TestRejectHistoryRoutesBase): + def test_summary_missing_dates_returns_400(self): + response = self.client.get('/api/reject-history/summary') + payload = json.loads(response.data) + + self.assertEqual(response.status_code, 400) + self.assertFalse(payload['success']) + + def test_summary_invalid_include_excluded_scrap_returns_400(self): + response = self.client.get( + '/api/reject-history/summary?start_date=2026-02-01&end_date=2026-02-07' + '&include_excluded_scrap=invalid' + ) + payload = json.loads(response.data) + + self.assertEqual(response.status_code, 400) + self.assertFalse(payload['success']) + + def test_summary_invalid_exclude_material_scrap_returns_400(self): + response = self.client.get( + '/api/reject-history/summary?start_date=2026-02-01&end_date=2026-02-07' + '&exclude_material_scrap=invalid' + ) + payload = json.loads(response.data) + + self.assertEqual(response.status_code, 400) + self.assertFalse(payload['success']) + + @patch('mes_dashboard.routes.reject_history_routes.query_summary') + def test_summary_passes_filters_and_meta(self, mock_summary): + mock_summary.return_value = { + 'MOVEIN_QTY': 100, + 'REJECT_TOTAL_QTY': 10, + 'DEFECT_QTY': 5, + 'REJECT_RATE_PCT': 10, + 'DEFECT_RATE_PCT': 5, + 'REJECT_SHARE_PCT': 66.7, + 'AFFECTED_LOT_COUNT': 8, + 'AFFECTED_WORKORDER_COUNT': 4, + 'meta': { + 'include_excluded_scrap': False, + 'exclusion_applied': True, + 'excluded_reason_count': 2, + }, + } + + response = self.client.get( + '/api/reject-history/summary?start_date=2026-02-01&end_date=2026-02-07' + '&workcenter_groups=WB&packages=PKG-A&reasons=R1&reasons=R2' + ) + payload = json.loads(response.data) + + self.assertEqual(response.status_code, 200) + self.assertTrue(payload['success']) + self.assertEqual(payload['meta']['include_excluded_scrap'], False) + _, kwargs = mock_summary.call_args + self.assertEqual(kwargs['workcenter_groups'], ['WB']) + self.assertEqual(kwargs['packages'], ['PKG-A']) + self.assertEqual(kwargs['reasons'], ['R1', 'R2']) + self.assertIs(kwargs['include_excluded_scrap'], False) + self.assertIs(kwargs['exclude_material_scrap'], True) + + @patch('mes_dashboard.routes.reject_history_routes.query_trend') + def test_trend_invalid_granularity_returns_400(self, mock_trend): + mock_trend.side_effect = ValueError('Invalid granularity. Use day, week, or month') + + response = self.client.get( + '/api/reject-history/trend?start_date=2026-02-01&end_date=2026-02-07&granularity=hour' + ) + payload = json.loads(response.data) + + self.assertEqual(response.status_code, 400) + self.assertFalse(payload['success']) + + @patch('mes_dashboard.routes.reject_history_routes.query_reason_pareto') + def test_reason_pareto_defaults_top80(self, mock_pareto): + mock_pareto.return_value = {'items': [], 'metric_mode': 'reject_total', 'pareto_scope': 'top80', 'meta': {}} + + response = self.client.get('/api/reject-history/reason-pareto?start_date=2026-02-01&end_date=2026-02-07') + + self.assertEqual(response.status_code, 200) + _, kwargs = mock_pareto.call_args + self.assertEqual(kwargs['pareto_scope'], 'top80') + self.assertEqual(kwargs['metric_mode'], 'reject_total') + + @patch('mes_dashboard.routes.reject_history_routes.query_list') + @patch('mes_dashboard.core.rate_limit.check_and_record', return_value=(True, 6)) + def test_list_rate_limited_returns_429(self, _mock_limit, mock_list): + response = self.client.get('/api/reject-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'), '6') + mock_list.assert_not_called() + + @patch('mes_dashboard.routes.reject_history_routes.export_csv') + def test_export_returns_csv_response(self, mock_export): + mock_export.return_value = iter(['A,B\n', '1,2\n']) + + response = self.client.get('/api/reject-history/export?start_date=2026-02-01&end_date=2026-02-07') + + self.assertEqual(response.status_code, 200) + self.assertIn('attachment; filename=reject_history_2026-02-01_to_2026-02-07.csv', response.headers.get('Content-Disposition', '')) + self.assertIn('text/csv', response.headers.get('Content-Type', '')) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_reject_history_service.py b/tests/test_reject_history_service.py new file mode 100644 index 0000000..bd3f34e --- /dev/null +++ b/tests/test_reject_history_service.py @@ -0,0 +1,360 @@ +# -*- coding: utf-8 -*- +"""Unit tests for reject_history_service.""" + +from __future__ import annotations + +import pandas as pd +import pytest + +from mes_dashboard.services import reject_history_service as svc + + +def test_query_summary_returns_metrics_and_policy_meta(monkeypatch): + monkeypatch.setattr(svc, "get_excluded_reasons", lambda force_refresh=False: {"358"}) + + captured = {} + + def _fake_read_sql_df(_sql, params=None): + captured["params"] = dict(params or {}) + return pd.DataFrame( + [ + { + "MOVEIN_QTY": 1000, + "REJECT_TOTAL_QTY": 25, + "DEFECT_QTY": 10, + "REJECT_RATE_PCT": 2.5, + "DEFECT_RATE_PCT": 1.0, + "REJECT_SHARE_PCT": 71.4286, + "AFFECTED_LOT_COUNT": 12, + "AFFECTED_WORKORDER_COUNT": 7, + } + ] + ) + + monkeypatch.setattr(svc, "read_sql_df", _fake_read_sql_df) + + result = svc.query_summary( + start_date="2026-02-01", + end_date="2026-02-07", + include_excluded_scrap=False, + ) + + assert result["MOVEIN_QTY"] == 1000 + assert result["REJECT_TOTAL_QTY"] == 25 + assert result["DEFECT_QTY"] == 10 + assert result["AFFECTED_LOT_COUNT"] == 12 + assert result["meta"]["include_excluded_scrap"] is False + assert result["meta"]["exclusion_applied"] is True + assert result["meta"]["excluded_reason_count"] == 1 + + assert captured["params"]["start_date"] == "2026-02-01" + assert captured["params"]["end_date"] == "2026-02-07" + assert "358" in captured["params"].values() + + +def test_query_summary_include_override_skips_exclusion_filter(monkeypatch): + monkeypatch.setattr(svc, "get_excluded_reasons", lambda force_refresh=False: {"358", "REASON_X"}) + + captured = {} + + def _fake_read_sql_df(_sql, params=None): + captured["params"] = dict(params or {}) + return pd.DataFrame( + [ + { + "MOVEIN_QTY": 1000, + "REJECT_TOTAL_QTY": 25, + "DEFECT_QTY": 10, + "REJECT_RATE_PCT": 2.5, + "DEFECT_RATE_PCT": 1.0, + "REJECT_SHARE_PCT": 71.4286, + "AFFECTED_LOT_COUNT": 12, + "AFFECTED_WORKORDER_COUNT": 7, + } + ] + ) + + monkeypatch.setattr(svc, "read_sql_df", _fake_read_sql_df) + result = svc.query_summary( + start_date="2026-02-01", + end_date="2026-02-07", + include_excluded_scrap=True, + ) + + assert result["meta"]["include_excluded_scrap"] is True + assert result["meta"]["exclusion_applied"] is False + assert result["meta"]["excluded_reason_count"] == 0 + assert "358" not in captured["params"].values() + assert "REASON_X" not in captured["params"].values() + + +def test_build_where_clause_applies_reason_prefix_policy_by_default(monkeypatch): + monkeypatch.setattr(svc, "get_excluded_reasons", lambda force_refresh=False: set()) + where_clause, _params, meta = svc._build_where_clause(include_excluded_scrap=False) + + assert "REGEXP_LIKE(UPPER(NVL(TRIM(b.LOSSREASONNAME), '')), '^[0-9]{3}_')" in where_clause + assert "NOT REGEXP_LIKE(UPPER(NVL(TRIM(b.LOSSREASONNAME), '')), '^(XXX|ZZZ)_')" in where_clause + assert meta["reason_name_prefix_policy_applied"] is True + assert meta["exclusion_applied"] is True + + +def test_build_where_clause_include_override_skips_reason_prefix_policy(monkeypatch): + monkeypatch.setattr(svc, "get_excluded_reasons", lambda force_refresh=False: {"358"}) + where_clause, params, meta = svc._build_where_clause( + include_excluded_scrap=True, + packages=["PKG-A"], + ) + + assert "REGEXP_LIKE(UPPER(NVL(TRIM(b.LOSSREASONNAME), '')), '^[0-9]{3}_')" not in where_clause + assert "NOT REGEXP_LIKE(UPPER(NVL(TRIM(b.LOSSREASONNAME), '')), '^(XXX|ZZZ)_')" not in where_clause + assert meta["reason_name_prefix_policy_applied"] is False + assert meta["exclusion_applied"] is False + assert meta["package_filter_count"] == 1 + assert "358" not in params.values() + + +def test_get_filter_options_includes_packages(monkeypatch): + monkeypatch.setattr(svc, "get_excluded_reasons", lambda force_refresh=False: set()) + monkeypatch.setattr( + svc, + "get_workcenter_groups", + lambda: [ + {"name": "WB", "sequence": 1}, + {"name": "FA", "sequence": 2}, + ], + ) + + def _fake_read_sql_df(sql, _params=None): + if "AS REASON" in sql: + return pd.DataFrame([{"REASON": "R1"}, {"REASON": "R2"}]) + if "AS PACKAGE" in sql: + return pd.DataFrame([{"PACKAGE": "PKG-A"}, {"PACKAGE": "PKG-B"}]) + return pd.DataFrame() + + monkeypatch.setattr(svc, "read_sql_df", _fake_read_sql_df) + + result = svc.get_filter_options( + start_date="2026-02-01", + end_date="2026-02-07", + include_excluded_scrap=False, + ) + + assert result["reasons"] == ["R1", "R2"] + assert result["packages"] == ["PKG-A", "PKG-B"] + assert result["workcenter_groups"][0]["name"] == "WB" + + +def test_get_filter_options_appends_material_reason_option(monkeypatch): + monkeypatch.setattr(svc, "get_excluded_reasons", lambda force_refresh=False: set()) + monkeypatch.setattr(svc, "get_workcenter_groups", lambda: []) + + def _fake_read_sql_df(sql, _params=None): + if "AS REASON" in sql: + return pd.DataFrame([{"REASON": "001_TEST"}]) + if "AS PACKAGE" in sql: + return pd.DataFrame([{"PACKAGE": "PKG-A"}]) + if "AS HAS_MATERIAL" in sql: + return pd.DataFrame([{"HAS_MATERIAL": 1}]) + return pd.DataFrame() + + monkeypatch.setattr(svc, "read_sql_df", _fake_read_sql_df) + + result = svc.get_filter_options(start_date="2026-02-01", end_date="2026-02-07") + assert svc.MATERIAL_REASON_OPTION in result["reasons"] + + +def test_build_where_clause_with_material_reason_adds_objecttype_condition(monkeypatch): + monkeypatch.setattr(svc, "get_excluded_reasons", lambda force_refresh=False: set()) + where_clause, _params, meta = svc._build_where_clause(reasons=[svc.MATERIAL_REASON_OPTION]) + + assert "UPPER(NVL(TRIM(b.SCRAP_OBJECTTYPE), '-')) = 'MATERIAL'" in where_clause + assert meta["material_reason_selected"] is True + + +def test_build_where_clause_exclude_material_scrap_adds_not_material_condition(monkeypatch): + monkeypatch.setattr(svc, "get_excluded_reasons", lambda force_refresh=False: set()) + where_clause, _params, meta = svc._build_where_clause(exclude_material_scrap=True) + + assert "UPPER(NVL(TRIM(b.SCRAP_OBJECTTYPE), '-')) <> 'MATERIAL'" in where_clause + assert meta["exclude_material_scrap"] is True + assert meta["material_exclusion_applied"] is True + + +def test_sql_template_replacement_does_not_introduce_fake_bind_placeholders(): + sql = svc._prepare_sql( + "summary", + where_clause="WHERE 1=1", + bucket_expr="TRUNC(b.TXN_DAY)", + metric_column="b.REJECT_TOTAL_QTY", + ) + + assert ":BASE" not in sql + assert ":WHERE" not in sql + assert ":BUCKET" not in sql + assert ":METRIC" not in sql + + +def test_base_with_cte_sql_flattens_nested_with(monkeypatch): + monkeypatch.setattr( + svc, + "_load_sql", + lambda name: ( + "-- comment line\n" + "WITH c1 AS (SELECT 1 AS X FROM DUAL),\n" + "c2 AS (SELECT X FROM c1)\n" + "SELECT X FROM c2" + ) + if name == "performance_daily" + else "", + ) + + rendered = svc._base_with_cte_sql("base") + assert rendered.startswith("WITH c1 AS") + assert "base AS (\nSELECT X FROM c2\n)" in rendered + assert "WITH base AS (\nWITH c1" not in rendered + + +def test_query_trend_invalid_granularity_raises(): + with pytest.raises(ValueError, match="Invalid granularity"): + svc.query_trend(start_date="2026-02-01", end_date="2026-02-07", granularity="hour") + + +def test_query_reason_pareto_top80_scope(monkeypatch): + monkeypatch.setattr(svc, "get_excluded_reasons", lambda force_refresh=False: set()) + + monkeypatch.setattr( + svc, + "read_sql_df", + lambda _sql, _params=None: pd.DataFrame( + [ + {"REASON": "R1", "CATEGORY": "C1", "METRIC_VALUE": 50, "MOVEIN_QTY": 100, "REJECT_TOTAL_QTY": 50, "DEFECT_QTY": 0, "AFFECTED_LOT_COUNT": 10, "PCT": 50, "CUM_PCT": 50}, + {"REASON": "R2", "CATEGORY": "C1", "METRIC_VALUE": 29, "MOVEIN_QTY": 100, "REJECT_TOTAL_QTY": 29, "DEFECT_QTY": 0, "AFFECTED_LOT_COUNT": 8, "PCT": 29, "CUM_PCT": 79}, + {"REASON": "R3", "CATEGORY": "C2", "METRIC_VALUE": 13, "MOVEIN_QTY": 100, "REJECT_TOTAL_QTY": 13, "DEFECT_QTY": 0, "AFFECTED_LOT_COUNT": 6, "PCT": 13, "CUM_PCT": 92}, + {"REASON": "R4", "CATEGORY": "C3", "METRIC_VALUE": 8, "MOVEIN_QTY": 100, "REJECT_TOTAL_QTY": 8, "DEFECT_QTY": 0, "AFFECTED_LOT_COUNT": 5, "PCT": 8, "CUM_PCT": 100}, + ] + ), + ) + + top80 = svc.query_reason_pareto( + start_date="2026-02-01", + end_date="2026-02-07", + metric_mode="reject_total", + pareto_scope="top80", + ) + assert len(top80["items"]) == 2 + assert top80["items"][-1]["reason"] == "R2" + assert "category" not in top80["items"][0] + + all_items = svc.query_reason_pareto( + start_date="2026-02-01", + end_date="2026-02-07", + metric_mode="reject_total", + pareto_scope="all", + ) + assert len(all_items["items"]) == 4 + + +def test_query_list_pagination_and_caps(monkeypatch): + monkeypatch.setattr(svc, "get_excluded_reasons", lambda force_refresh=False: set()) + + captured = {} + + def _fake_read_sql_df(_sql, params=None): + captured["params"] = dict(params or {}) + return pd.DataFrame( + [ + { + "TXN_DAY": "2026-02-03", + "TXN_MONTH": "2026-02", + "WORKCENTER_GROUP": "WB", + "WORKCENTERNAME": "WB01", + "SPECNAME": "S1", + "PRODUCTLINENAME": "P1", + "PJ_TYPE": "TYPE1", + "LOSSREASONNAME": "R1", + "LOSSREASON_CODE": "001", + "REJECTCATEGORYNAME": "CAT", + "MOVEIN_QTY": 100, + "REJECT_QTY": 3, + "STANDBY_QTY": 1, + "QTYTOPROCESS_QTY": 1, + "INPROCESS_QTY": 1, + "PROCESSED_QTY": 1, + "REJECT_TOTAL_QTY": 7, + "DEFECT_QTY": 2, + "REJECT_RATE_PCT": 7, + "DEFECT_RATE_PCT": 2, + "REJECT_SHARE_PCT": 77.777, + "AFFECTED_LOT_COUNT": 3, + "AFFECTED_WORKORDER_COUNT": 2, + "TOTAL_COUNT": 12, + } + ] + ) + + monkeypatch.setattr(svc, "read_sql_df", _fake_read_sql_df) + + result = svc.query_list( + start_date="2026-02-01", + end_date="2026-02-07", + page=2, + per_page=500, + packages=["PKG1"], + ) + + assert result["pagination"]["page"] == 2 + assert result["pagination"]["perPage"] == 200 + assert result["pagination"]["total"] == 12 + assert result["pagination"]["totalPages"] == 1 + assert captured["params"]["offset"] == 200 + assert captured["params"]["limit"] == 200 + assert "PKG1" in captured["params"].values() + + +def test_export_csv_contains_semantic_headers(monkeypatch): + monkeypatch.setattr(svc, "get_excluded_reasons", lambda force_refresh=False: set()) + monkeypatch.setattr( + svc, + "read_sql_df", + lambda _sql, _params=None: pd.DataFrame( + [ + { + "TXN_DAY": "2026-02-03", + "TXN_MONTH": "2026-02", + "WORKCENTER_GROUP": "WB", + "WORKCENTERNAME": "WB01", + "SPECNAME": "S1", + "PRODUCTLINENAME": "P1", + "PJ_TYPE": "TYPE1", + "LOSSREASONNAME": "R1", + "LOSSREASON_CODE": "001", + "REJECTCATEGORYNAME": "CAT", + "MOVEIN_QTY": 100, + "REJECT_QTY": 3, + "STANDBY_QTY": 1, + "QTYTOPROCESS_QTY": 1, + "INPROCESS_QTY": 1, + "PROCESSED_QTY": 1, + "REJECT_TOTAL_QTY": 7, + "DEFECT_QTY": 2, + "REJECT_RATE_PCT": 7, + "DEFECT_RATE_PCT": 2, + "REJECT_SHARE_PCT": 77.777, + "AFFECTED_LOT_COUNT": 3, + "AFFECTED_WORKORDER_COUNT": 2, + } + ] + ), + ) + + chunks = list( + svc.export_csv( + start_date="2026-02-01", + end_date="2026-02-07", + ) + ) + payload = "".join(chunks) + + assert "REJECT_TOTAL_QTY" in payload + assert "DEFECT_QTY" in payload + assert "2026-02-03" in payload diff --git a/tests/test_reject_history_shell_coverage.py b/tests/test_reject_history_shell_coverage.py new file mode 100644 index 0000000..65686f1 --- /dev/null +++ b/tests/test_reject_history_shell_coverage.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +"""Governance coverage tests for reject-history shell integration.""" + +from __future__ import annotations + +import json +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +ROUTE_CONTRACTS_FILE = ROOT / 'frontend' / 'src' / 'portal-shell' / 'routeContracts.js' +NATIVE_REGISTRY_FILE = ROOT / 'frontend' / 'src' / 'portal-shell' / 'nativeModuleRegistry.js' +PAGE_STATUS_FILE = ROOT / 'data' / 'page_status.json' + + +def test_reject_history_route_contract_entry_exists(): + text = ROUTE_CONTRACTS_FILE.read_text(encoding='utf-8') + + assert "'/reject-history'" in text + assert "routeId: 'reject-history'" in text + assert "title: '報廢歷史查詢'" in text + + +def test_reject_history_native_loader_entry_exists(): + text = NATIVE_REGISTRY_FILE.read_text(encoding='utf-8') + + assert "'/reject-history'" in text + assert "import('../reject-history/App.vue')" in text + + +def test_reject_history_page_status_entry_exists(): + payload = json.loads(PAGE_STATUS_FILE.read_text(encoding='utf-8')) + pages = payload.get('pages', []) + + entry = next((item for item in pages if item.get('route') == '/reject-history'), None) + assert entry is not None + assert entry.get('drawer_id') + assert isinstance(entry.get('order'), int) diff --git a/tests/test_scrap_reason_exclusion_cache.py b/tests/test_scrap_reason_exclusion_cache.py new file mode 100644 index 0000000..96c8e4f --- /dev/null +++ b/tests/test_scrap_reason_exclusion_cache.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8 -*- +"""Tests for scrap_reason_exclusion_cache service.""" + +from __future__ import annotations + +import json +from unittest.mock import MagicMock + +import pandas as pd + +from mes_dashboard.services import scrap_reason_exclusion_cache as cache + + +def _reset_cache_state(): + with cache._CACHE_LOCK: + cache._CACHE["reasons"] = set() + cache._CACHE["updated_at"] = None + cache._CACHE["loaded"] = False + cache._CACHE["source"] = None + + +def test_refresh_cache_loads_enabled_reason_codes(monkeypatch): + _reset_cache_state() + + monkeypatch.setattr(cache, "try_acquire_lock", lambda *_args, **_kwargs: True) + monkeypatch.setattr(cache, "release_lock", lambda *_args, **_kwargs: None) + monkeypatch.setattr(cache, "get_redis_client", lambda: None) + monkeypatch.setattr( + cache, + "read_sql_df", + lambda _sql: pd.DataFrame({"REASON_NAME": ["358", " 160 ", "bonus_adjust"]}), + ) + + assert cache.refresh_cache(force=True) is True + assert cache.get_excluded_reasons() == {"358", "160", "BONUS_ADJUST"} + + +def test_refresh_cache_falls_back_to_redis_when_oracle_fails(monkeypatch): + _reset_cache_state() + + redis_client = MagicMock() + redis_client.get.side_effect = [json.dumps(["A01", "b02"]), "2026-02-13T00:00:00"] + + monkeypatch.setattr(cache, "try_acquire_lock", lambda *_args, **_kwargs: True) + monkeypatch.setattr(cache, "release_lock", lambda *_args, **_kwargs: None) + monkeypatch.setattr(cache, "get_redis_client", lambda: redis_client) + + def _raise(_sql): + raise RuntimeError("oracle unavailable") + + monkeypatch.setattr(cache, "read_sql_df", _raise) + + assert cache.refresh_cache(force=True) is True + assert cache.get_excluded_reasons() == {"A01", "B02"} + + +def test_get_excluded_reasons_uses_redis_for_lazy_bootstrap(monkeypatch): + _reset_cache_state() + + redis_client = MagicMock() + redis_client.get.side_effect = [json.dumps(["X1", "x2"]), "2026-02-13T12:00:00"] + monkeypatch.setattr(cache, "get_redis_client", lambda: redis_client) + monkeypatch.setattr(cache, "refresh_cache", lambda force=False: True) + + reasons = cache.get_excluded_reasons(force_refresh=False) + + assert reasons == {"X1", "X2"} diff --git a/tests/test_template_integration.py b/tests/test_template_integration.py index 8d2a595..d035105 100644 --- a/tests/test_template_integration.py +++ b/tests/test_template_integration.py @@ -373,6 +373,7 @@ class TestViteModuleIntegration(unittest.TestCase): ('/tables', 'tables.js'), ('/resource', 'resource-status.js'), ('/resource-history', 'resource-history.js'), + ('/reject-history', 'reject-history.js'), ('/job-query', 'job-query.js'), ('/excel-query', 'excel-query.js'), ('/query-tool', 'query-tool.js'), @@ -396,13 +397,18 @@ class TestViteModuleIntegration(unittest.TestCase): response = self.client.get(endpoint, follow_redirects=False) if endpoint in canonical_routes: - self.assertEqual(response.status_code, 302) - self.assertTrue(response.location.endswith(canonical_routes[endpoint])) - follow = self.client.get(response.location) - self.assertEqual(follow.status_code, 200) - html = follow.data.decode('utf-8') - self.assertIn('/static/dist/portal-shell.js', html) - self.assertIn('type="module"', html) + if response.status_code == 302: + self.assertTrue(response.location.endswith(canonical_routes[endpoint])) + follow = self.client.get(response.location) + self.assertEqual(follow.status_code, 200) + html = follow.data.decode('utf-8') + self.assertIn('/static/dist/portal-shell.js', html) + self.assertIn('type="module"', html) + else: + self.assertEqual(response.status_code, 200) + html = response.data.decode('utf-8') + self.assertIn(f'/static/dist/{asset}', html) + self.assertIn('type="module"', html) else: self.assertEqual(response.status_code, 200) html = response.data.decode('utf-8')