From 777751311c74767d80e67602138f6561f2e80c3d Mon Sep 17 00:00:00 2001 From: egg Date: Tue, 3 Mar 2026 17:32:41 +0800 Subject: [PATCH] feat: add material trace page for bidirectional LOT/material query MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement full-stack material trace feature enabling forward (LOT/工單 → 原物料) and reverse (原物料 → LOT) queries with wildcard support, safeguards (memory guard, IN-clause batching, Oracle slow-query channel), CSV export, and portal-shell integration. Co-Authored-By: Claude Opus 4.6 --- data/page_status.json | 7 + .../route_contracts.json | 12 + frontend/src/material-trace/App.vue | 438 +++++++++++++++++ frontend/src/material-trace/index.html | 12 + frontend/src/material-trace/main.js | 7 + frontend/src/material-trace/style.css | 444 ++++++++++++++++++ .../src/portal-shell/nativeModuleRegistry.js | 4 + frontend/src/portal-shell/routeContracts.js | 12 + frontend/vite.config.js | 3 +- .../.openspec.yaml | 2 + .../design.md | 78 +++ .../proposal.md | 35 ++ .../specs/material-trace-api/spec.md | 120 +++++ .../specs/material-trace-page/spec.md | 120 +++++ .../tasks.md | 63 +++ openspec/specs/material-trace-api/spec.md | 120 +++++ openspec/specs/material-trace-page/spec.md | 120 +++++ src/mes_dashboard/app.py | 21 + src/mes_dashboard/routes/__init__.py | 3 + .../routes/material_trace_routes.py | 185 ++++++++ .../services/material_trace_service.py | 366 +++++++++++++++ .../sql/material_trace/forward_by_lot.sql | 29 ++ .../material_trace/forward_by_workorder.sql | 29 ++ .../material_trace/resolve_container_ids.sql | 12 + .../reverse_by_material_lot.sql | 32 ++ tests/test_material_trace_routes.py | 276 +++++++++++ tests/test_material_trace_service.py | 350 ++++++++++++++ 27 files changed, 2899 insertions(+), 1 deletion(-) create mode 100644 frontend/src/material-trace/App.vue create mode 100644 frontend/src/material-trace/index.html create mode 100644 frontend/src/material-trace/main.js create mode 100644 frontend/src/material-trace/style.css create mode 100644 openspec/changes/archive/2026-03-03-add-material-trace-page/.openspec.yaml create mode 100644 openspec/changes/archive/2026-03-03-add-material-trace-page/design.md create mode 100644 openspec/changes/archive/2026-03-03-add-material-trace-page/proposal.md create mode 100644 openspec/changes/archive/2026-03-03-add-material-trace-page/specs/material-trace-api/spec.md create mode 100644 openspec/changes/archive/2026-03-03-add-material-trace-page/specs/material-trace-page/spec.md create mode 100644 openspec/changes/archive/2026-03-03-add-material-trace-page/tasks.md create mode 100644 openspec/specs/material-trace-api/spec.md create mode 100644 openspec/specs/material-trace-page/spec.md create mode 100644 src/mes_dashboard/routes/material_trace_routes.py create mode 100644 src/mes_dashboard/services/material_trace_service.py create mode 100644 src/mes_dashboard/sql/material_trace/forward_by_lot.sql create mode 100644 src/mes_dashboard/sql/material_trace/forward_by_workorder.sql create mode 100644 src/mes_dashboard/sql/material_trace/resolve_container_ids.sql create mode 100644 src/mes_dashboard/sql/material_trace/reverse_by_material_lot.sql create mode 100644 tests/test_material_trace_routes.py create mode 100644 tests/test_material_trace_service.py diff --git a/data/page_status.json b/data/page_status.json index 5fdd111..0aa8e93 100644 --- a/data/page_status.json +++ b/data/page_status.json @@ -99,6 +99,13 @@ "drawer_id": "drawer", "order": 3 }, + { + "route": "/material-trace", + "name": "原物料追溯查詢", + "status": "released", + "drawer_id": "drawer", + "order": 4 + }, { "route": "/admin/pages", "name": "頁面管理", diff --git a/docs/migration/full-modernization-architecture-blueprint/route_contracts.json b/docs/migration/full-modernization-architecture-blueprint/route_contracts.json index 55821ec..5585c9c 100644 --- a/docs/migration/full-modernization-architecture-blueprint/route_contracts.json +++ b/docs/migration/full-modernization-architecture-blueprint/route_contracts.json @@ -191,6 +191,18 @@ "canonical_shell_path": "/portal-shell/mid-section-defect", "rollback_strategy": "fallback_to_legacy_route", "compatibility_policy": "redirect_to_shell_when_spa_enabled" + }, + { + "route": "/material-trace", + "route_id": "material-trace", + "title": "Material Trace", + "scope": "in-scope", + "render_mode": "native", + "owner": "frontend-mes-reporting", + "visibility_policy": "released_or_admin", + "canonical_shell_path": "/portal-shell/material-trace", + "rollback_strategy": "fallback_to_legacy_route", + "compatibility_policy": "redirect_to_shell_when_spa_enabled" } ] } diff --git a/frontend/src/material-trace/App.vue b/frontend/src/material-trace/App.vue new file mode 100644 index 0000000..186da6b --- /dev/null +++ b/frontend/src/material-trace/App.vue @@ -0,0 +1,438 @@ + + + diff --git a/frontend/src/material-trace/index.html b/frontend/src/material-trace/index.html new file mode 100644 index 0000000..8074ca2 --- /dev/null +++ b/frontend/src/material-trace/index.html @@ -0,0 +1,12 @@ + + + + + + 原物料追溯查詢 + + +
+ + + diff --git a/frontend/src/material-trace/main.js b/frontend/src/material-trace/main.js new file mode 100644 index 0000000..badc9a1 --- /dev/null +++ b/frontend/src/material-trace/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/material-trace/style.css b/frontend/src/material-trace/style.css new file mode 100644 index 0000000..507b438 --- /dev/null +++ b/frontend/src/material-trace/style.css @@ -0,0 +1,444 @@ +.material-trace-header { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); +} + +.mode-tab-row { + display: flex; + gap: 0; + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; + width: fit-content; +} + +.mode-tab { + padding: 7px 16px; + border: none; + background: #f8fafc; + font-size: 13px; + font-weight: 600; + color: #475569; + cursor: pointer; + transition: background 0.15s, color 0.15s; +} + +.mode-tab:not(:last-child) { + border-right: 1px solid var(--border); +} + +.mode-tab.active { + background: #667eea; + color: #fff; +} + +.mode-tab:hover:not(.active) { + background: #eef2f7; +} + +.input-type-row { + display: flex; + align-items: center; + gap: 8px; +} + +.input-type-select { + width: auto; + min-width: 120px; + max-width: 180px; + padding: 6px 10px; + border: 1px solid var(--border); + border-radius: 8px; + font-size: 13px; + background: #fff; +} + +.filter-textarea { + resize: vertical; + font-family: monospace; + line-height: 1.5; +} + +.input-count { + font-size: 12px; + color: #64748b; + margin-top: 4px; +} + +.input-count.over-limit { + color: #dc2626; + font-weight: 600; +} + +.card { + background: var(--card-bg); + border-radius: 10px; + box-shadow: var(--shadow); + overflow: visible; + margin-bottom: 14px; +} + +.card-header { + padding: 14px 18px; + border-bottom: 1px solid var(--border); + background: #f8fafc; + border-radius: 10px 10px 0 0; +} + +.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; +} + +.warning-banner { + margin-bottom: 14px; + padding: 10px 12px; + border-radius: 6px; + background: #fffbeb; + color: #b45309; + font-size: 13px; +} + +.filter-panel { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px; + align-items: end; +} + +.filter-group { + display: flex; + flex-direction: column; + gap: 8px; +} + +.filter-group-full { + grid-column: 1 / -1; +} + +.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-toolbar { + grid-column: 1 / -1; + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.filter-actions { + display: flex; + gap: 10px; + flex-shrink: 0; +} + +.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; +} + +.detail-table tbody tr:hover { + background: #f8fbff; +} + +.detail-table-wrap { + position: relative; + overflow: auto; + max-height: 70vh; +} + +.detail-table-wrap.is-loading table { + opacity: 0.4; + pointer-events: none; + transition: opacity 0.15s ease; +} + +.table-loading-overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + z-index: 2; +} + +.table-spinner { + display: block; + width: 28px; + height: 28px; + border: 3px solid #d1d5db; + border-top-color: #667eea; + border-radius: 50%; + animation: spin 0.6s linear infinite; +} + +.btn-spinner { + display: inline-block; + width: 12px; + height: 12px; + border: 2px solid rgba(255, 255, 255, 0.4); + border-top-color: #fff; + border-radius: 50%; + animation: spin 0.6s linear infinite; + margin-right: 6px; + vertical-align: middle; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.pagination-bar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 0; + font-size: 13px; + color: #475569; +} + +.pagination-info { + display: flex; + align-items: center; + gap: 8px; +} + +.pagination-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.pagination-actions button { + padding: 5px 12px; + border: 1px solid var(--border); + border-radius: 6px; + background: #fff; + color: #334155; + font-size: 13px; + cursor: pointer; +} + +.pagination-actions button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.pagination-actions button:hover:not(:disabled) { + background: #f1f5f9; +} + +.btn.btn-export { + background: #667eea; + color: #fff; +} + +.btn.btn-export:hover { + background: #5568d3; + color: #fff; +} + +.btn.btn-export:disabled { + opacity: 0.7; + cursor: not-allowed; +} + +.empty-message { + text-align: center; + padding: 40px 20px; + color: #94a3b8; + font-size: 14px; +} + +/* Multi-select (copied from reject-history for standalone use) */ + +.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: #667eea; +} + +.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: 768px) { + .filter-panel { + grid-template-columns: 1fr; + } + + .filter-group-full { + grid-column: span 1; + } + + .filter-toolbar { + flex-direction: column; + align-items: flex-start; + } +} diff --git a/frontend/src/portal-shell/nativeModuleRegistry.js b/frontend/src/portal-shell/nativeModuleRegistry.js index 57ef514..13d1f83 100644 --- a/frontend/src/portal-shell/nativeModuleRegistry.js +++ b/frontend/src/portal-shell/nativeModuleRegistry.js @@ -70,6 +70,10 @@ const NATIVE_MODULE_LOADERS = Object.freeze({ () => import('../mid-section-defect/App.vue'), [() => import('../mid-section-defect/style.css')], ), + '/material-trace': createNativeLoader( + () => import('../material-trace/App.vue'), + [() => import('../wip-shared/styles.css'), () => import('../material-trace/style.css')], + ), '/admin/performance': createNativeLoader( () => import('../admin-performance/App.vue'), [() => import('../admin-performance/style.css')], diff --git a/frontend/src/portal-shell/routeContracts.js b/frontend/src/portal-shell/routeContracts.js index 3e37740..02203e0 100644 --- a/frontend/src/portal-shell/routeContracts.js +++ b/frontend/src/portal-shell/routeContracts.js @@ -13,6 +13,7 @@ const IN_SCOPE_REPORT_ROUTES = Object.freeze([ '/excel-query', '/query-tool', '/mid-section-defect', + '/material-trace', ]); const IN_SCOPE_ADMIN_ROUTES = Object.freeze([ @@ -230,6 +231,17 @@ const ROUTE_CONTRACTS = Object.freeze({ scope: 'in-scope', compatibilityPolicy: 'redirect_to_shell_when_spa_enabled', }), + '/material-trace': buildContract({ + route: '/material-trace', + routeId: 'material-trace', + 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', + }), }); const REQUIRED_FIELDS = Object.freeze([ diff --git a/frontend/vite.config.js b/frontend/vite.config.js index bd33437..39ba097 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -28,7 +28,8 @@ export default defineConfig(({ mode }) => ({ 'query-tool': resolve(__dirname, 'src/query-tool/main.js'), 'qc-gate': resolve(__dirname, 'src/qc-gate/index.html'), 'mid-section-defect': resolve(__dirname, 'src/mid-section-defect/index.html'), - 'admin-performance': resolve(__dirname, 'src/admin-performance/index.html') + 'admin-performance': resolve(__dirname, 'src/admin-performance/index.html'), + 'material-trace': resolve(__dirname, 'src/material-trace/index.html') }, output: { entryFileNames: '[name].js', diff --git a/openspec/changes/archive/2026-03-03-add-material-trace-page/.openspec.yaml b/openspec/changes/archive/2026-03-03-add-material-trace-page/.openspec.yaml new file mode 100644 index 0000000..85cf50d --- /dev/null +++ b/openspec/changes/archive/2026-03-03-add-material-trace-page/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-03 diff --git a/openspec/changes/archive/2026-03-03-add-material-trace-page/design.md b/openspec/changes/archive/2026-03-03-add-material-trace-page/design.md new file mode 100644 index 0000000..6056083 --- /dev/null +++ b/openspec/changes/archive/2026-03-03-add-material-trace-page/design.md @@ -0,0 +1,78 @@ +## Context + +工程師需要查詢 LOT/工單對應的原物料消耗記錄,以及反向從原物料批號追溯使用該批料的所有 LOT。目前原物料資訊只能在 Query Tool 的 LotDetail "原物料" tab 逐筆查看(透過 `/api/trace/events?domains=["materials"]`),不支援批量輸入或反向查詢。 + +資料來源 `DWH.DW_MES_LOTMATERIALSHISTORY` 有 1800 萬筆記錄,已建立四個索引: +- IDX1: `CONTAINERID`(正向 LOT 查詢) +- IDX2: `PJ_WORKORDER`(正向工單查詢) +- IDX3: `MATERIALPARTNAME`(料號,本次不使用) +- IDX4: `MATERIALLOTNAME`(反向原物料批號查詢) + +站群組(WORKCENTER_GROUP)對應由 `filter_cache.get_workcenter_mapping()` 提供,從 `DW_MES_SPEC_WORKCENTER_V` 載入,每小時刷新。 + +## Goals / Non-Goals + +**Goals:** + +- 提供獨立頁面,支援正向(LOT ID / 工單 → 原物料)和反向(原物料批號 → LOT)雙向查詢 +- 正向查詢支援 LOT ID 和工單兩種輸入模式切換 +- 支援多筆輸入(換行/逗號分隔) +- 結果含站群組篩選、分頁、CSV 匯出 +- 使用既有 Oracle 索引,查詢效率可控 + +**Non-Goals:** + +- 不支援 MATERIALPARTNAME(料號)反向查詢(資料量風險過高,同一料號可能數萬筆) +- 不需日期範圍篩選(以 LOT/工單/原物料批號為查詢條件即可) +- 不做 Redis 快取或 BatchQueryEngine 分片(查詢範圍由輸入筆數控制,非時間範圍) +- 不做 BOM 對照或原物料品質統計 + +## Decisions + +### D1: 使用 `read_sql_df`(pooled connection)而非 `read_sql_df_slow` + +**決定**: 查詢使用 pooled connection(`read_sql_df`),不走 slow query path。 + +**理由**: 此查詢依賴索引命中,預期回應時間 < 5s。不像 reject-history 的全表掃描需要 dedicated connection。正向查詢最多幾千筆結果,反向查詢設結果上限 10,000 筆。 + +**替代方案**: 使用 `read_sql_df_slow`。 +**為何不採用**: 佔用 slow query semaphore 會排擠需要長時間執行的查詢(reject-history、resource-history)。 + +### D2: 正向查詢先解析 LOT ID → CONTAINERID + +**決定**: LOT ID 輸入模式需要先將 CONTAINERNAME 轉換為 CONTAINERID(16-char hex),因為 `LOTMATERIALSHISTORY` 的索引是 CONTAINERID。使用 `DW_MES_CONTAINER` 做 batch lookup。工單模式直接查 `PJ_WORKORDER` 索引,不需轉換。 + +**理由**: 使用者輸入的是可讀的 LOT 名稱(如 GA25060001-A01),但資料表索引是 CONTAINERID。直接 JOIN 會讓 optimizer 可能選擇低效計畫。先 batch resolve 再用 IN clause 更可預測。 + +**替代方案**: SQL 內直接 JOIN CONTAINER 表。 +**為何不採用**: 對於多筆 LOT 輸入,兩步驟(resolve + query)的執行計畫更穩定,且 resolve 結果可重用於顯示。 + +### D3: 站群組篩選在後端 enrichment 而非 SQL WHERE + +**決定**: SQL 查詢不加 WORKCENTERNAME 過濾。查詢結果回來後,後端用 `get_workcenter_mapping()` 對每列添加 `WORKCENTER_GROUP` 欄位,前端可做篩選。若使用者選了站群組篩選,後端先 resolve 站群組 → WORKCENTERNAME 清單,再在 SQL 加 `AND WORKCENTERNAME IN (...)` 過濾。 + +**理由**: 如果不篩選,使用者能看到所有站點的資料(含站群組欄位)。如果篩選了,SQL 層就縮減結果集,減少傳輸和分頁壓力。 + +### D4: 反向查詢結果數上限 10,000 筆 + +**決定**: 反向查詢(原物料批號 → LOT)加入 `FETCH FIRST 10001 ROWS ONLY` 上限。若回傳超過 10,000 筆,前端顯示警告「結果超過上限,請縮小查詢範圍」。 + +**理由**: 一批常用原物料可能被上千個 LOT 使用。無上限的反向查詢可能回傳數萬筆,壓垮前端和 Oracle 連線。10,000 筆足以覆蓋絕大多數場景。 + +### D5: 前端頁面結構沿用 Vite multi-page 模式 + +**決定**: 新增 `frontend/material-trace.html` + `frontend/src/material-trace/App.vue` 作為獨立 Vite entry point。沿用 reject-history 的單檔 App.vue + 子元件模式。 + +**理由**: 專案的所有查詢頁面(reject-history、hold-history、resource-history)都是獨立 Vite entry。統一架構。 + +### D6: 輸入筆數上限 + +**決定**: 正向查詢(LOT ID / 工單)輸入上限 200 筆,反向查詢(原物料批號)輸入上限 50 筆。 + +**理由**: 正向查詢每筆 LOT 平均產生 10-50 筆原物料記錄,200 筆 LOT 最多 10,000 筆結果。反向查詢每批原物料可能對應 100-1000 個 LOT,50 批已有碰上 10,000 筆上限的風險。 + +## Risks / Trade-offs + +- **[低] CONTAINERID resolve 多一次 round-trip** — LOT ID 模式需先查 `DW_MES_CONTAINER` 轉換。→ Container 表有 CONTAINERNAME 索引,batch IN query 很快(< 1s)。 +- **[低] 站群組 mapping 可能未涵蓋所有 WORKCENTERNAME** — `DW_MES_SPEC_WORKCENTER_V` 可能缺少新站點。→ 未映射的站點在結果中站群組欄位顯示空值,不影響查詢結果。 +- **[中] 反向查詢結果截斷** — 10,000 筆上限可能截斷大量使用的原物料批號結果。→ 前端明確顯示截斷警告,引導使用者縮小範圍。 diff --git a/openspec/changes/archive/2026-03-03-add-material-trace-page/proposal.md b/openspec/changes/archive/2026-03-03-add-material-trace-page/proposal.md new file mode 100644 index 0000000..342990d --- /dev/null +++ b/openspec/changes/archive/2026-03-03-add-material-trace-page/proposal.md @@ -0,0 +1,35 @@ +## Why + +生產追溯過程中,工程師需要查詢「某個 LOT/工單在哪個站群組用了什麼原物料」以及「某批原物料被哪些 LOT 使用」。目前原物料消耗資訊散落在 Query Tool 的 LotDetail "原物料" tab 中,只能逐筆 LOT 查看,無法批量查詢或反向追溯。缺少專屬頁面讓原物料異常時的影響範圍評估非常耗時。 + +## What Changes + +- 新增「原物料追溯查詢」獨立頁面,提供雙向查詢能力: + - **正向查詢**:輸入 LOT ID 或工單號碼(多筆),查詢對應的原物料消耗記錄,可依站群組篩選 + - **反向查詢**:輸入原物料批號 MATERIALLOTNAME(多筆),查詢該批原物料被哪些 LOT 使用 +- 結果表格含分頁、站群組篩選、CSV 匯出 +- 後端新增 `/api/material-trace/query` 和 `/api/material-trace/export` API 端點 +- 查詢資料來源:`DWH.DW_MES_LOTMATERIALSHISTORY`(1800 萬筆),利用既有索引(CONTAINERID, PJ_WORKORDER, MATERIALLOTNAME) +- 站群組對應透過 `filter_cache.get_workcenter_mapping()` 解析(與設備歷史績效共用同一份 mapping) + +## Capabilities + +### New Capabilities + +- `material-trace-page`: 原物料追溯查詢頁面 — 前端 UI、查詢模式切換、結果表格、分頁、CSV 匯出 +- `material-trace-api`: 原物料追溯 API — 正向/反向查詢端點、輸入驗證、結果分頁、匯出端點、rate limiting + +### Modified Capabilities + +(無既有 spec 需修改) + +## Impact + +- **新增後端服務** — `src/mes_dashboard/services/material_trace_service.py`:正向/反向查詢邏輯、站群組 enrichment +- **新增後端路由** — `src/mes_dashboard/routes/material_trace_routes.py`:API 端點註冊 +- **新增 SQL** — `src/mes_dashboard/sql/material_trace/`:3 個查詢檔(forward_by_lot、forward_by_workorder、reverse_by_material_lot) +- **新增前端頁面** — `frontend/src/material-trace/`:App.vue + 子元件(FilterPanel、ResultTable) +- **新增前端入口** — `frontend/material-trace.html` + Vite entry +- **共用依賴** — `filter_cache.get_workcenter_mapping()` 提供站群組對應、`parseMultiLineInput()` 處理多筆輸入 +- **資料庫** — 查詢 `DWH.DW_MES_LOTMATERIALSHISTORY`,使用既有索引,無 schema 變更 +- **Sidebar** — 需在導覽列新增頁面入口 diff --git a/openspec/changes/archive/2026-03-03-add-material-trace-page/specs/material-trace-api/spec.md b/openspec/changes/archive/2026-03-03-add-material-trace-page/specs/material-trace-api/spec.md new file mode 100644 index 0000000..5093e48 --- /dev/null +++ b/openspec/changes/archive/2026-03-03-add-material-trace-page/specs/material-trace-api/spec.md @@ -0,0 +1,120 @@ +## ADDED Requirements + +### Requirement: Material Trace API SHALL provide forward query endpoint +The API SHALL accept LOT IDs or work order numbers and return corresponding material consumption records from `DW_MES_LOTMATERIALSHISTORY`. + +#### Scenario: Forward query by LOT ID +- **WHEN** `POST /api/material-trace/query` is called with `mode: "lot"` and `values: ["GA25060001-A01", "GA25060502"]` +- **THEN** the API SHALL resolve LOT names to CONTAINERIDs via `DW_MES_CONTAINER` +- **THEN** the API SHALL return material consumption records matching those CONTAINERIDs +- **THEN** each record SHALL include CONTAINERID, CONTAINERNAME, PJ_WORKORDER, WORKCENTERNAME, WORKCENTER_GROUP, MATERIALPARTNAME, MATERIALLOTNAME, VENDORLOTNUMBER, QTYREQUIRED, QTYCONSUMED, EQUIPMENTNAME, TXNDATE, PRIMARY_CATEGORY, SECONDARY_CATEGORY + +#### Scenario: Forward query by work order +- **WHEN** `POST /api/material-trace/query` is called with `mode: "workorder"` and `values: ["WO-2025-001", "WO-2025-002"]` +- **THEN** the API SHALL query `DW_MES_LOTMATERIALSHISTORY` using `PJ_WORKORDER` index directly +- **THEN** the response format SHALL be identical to LOT ID mode + +#### Scenario: Forward query with workcenter group filter +- **WHEN** `POST /api/material-trace/query` includes `workcenter_groups: ["焊接_DB"]` +- **THEN** the API SHALL resolve group names to WORKCENTERNAME list via `filter_cache.get_workcenter_mapping()` +- **THEN** the SQL query SHALL include `AND WORKCENTERNAME IN (...)` filter +- **THEN** results SHALL only contain records from workcenters belonging to the selected groups + +#### Scenario: Forward query input limit +- **WHEN** `POST /api/material-trace/query` with `mode: "lot"` or `mode: "workorder"` contains more than 200 values +- **THEN** the API SHALL return HTTP 400 with error message indicating the 200-value limit + +### Requirement: Material Trace API SHALL provide reverse query endpoint +The API SHALL accept material lot names and return LOTs that consumed those materials. + +#### Scenario: Reverse query by material lot name +- **WHEN** `POST /api/material-trace/query` is called with `mode: "material_lot"` and `values: ["WIRE-LOT-20250101-A"]` +- **THEN** the API SHALL query `DW_MES_LOTMATERIALSHISTORY` using `MATERIALLOTNAME` index +- **THEN** each record SHALL include the same fields as forward query results + +#### Scenario: Reverse query with workcenter group filter +- **WHEN** reverse query includes `workcenter_groups` parameter +- **THEN** the same workcenter group filtering logic as forward query SHALL apply + +#### Scenario: Reverse query input limit +- **WHEN** `POST /api/material-trace/query` with `mode: "material_lot"` contains more than 50 values +- **THEN** the API SHALL return HTTP 400 with error message indicating the 50-value limit + +#### Scenario: Reverse query result limit +- **WHEN** reverse query results exceed 10,000 rows +- **THEN** the API SHALL return exactly 10,000 rows +- **THEN** the response `meta` SHALL include `truncated: true` and `max_rows: 10000` + +### Requirement: Material Trace API SHALL validate query parameters +The API SHALL validate input parameters before executing database queries. + +#### Scenario: Missing required fields +- **WHEN** `POST /api/material-trace/query` is called without `mode` or `values` +- **THEN** the API SHALL return HTTP 400 with descriptive validation error + +#### Scenario: Invalid mode +- **WHEN** `mode` is not one of `lot`, `workorder`, `material_lot` +- **THEN** the API SHALL return HTTP 400 + +#### Scenario: Empty values +- **WHEN** `values` is an empty array or all values are blank after trimming +- **THEN** the API SHALL return HTTP 400 with error message "請輸入至少一筆查詢條件" + +#### Scenario: Unresolvable LOT IDs +- **WHEN** some LOT names cannot be resolved to CONTAINERIDs +- **THEN** the API SHALL proceed with the resolved subset +- **THEN** the response `meta` SHALL include `unresolved` array listing unresolvable LOT names + +### Requirement: Material Trace API SHALL support paginated results +The API SHALL support server-side pagination for query results. + +#### Scenario: Pagination parameters +- **WHEN** `POST /api/material-trace/query` includes `page` and `per_page` +- **THEN** results SHALL be paginated accordingly +- **THEN** response SHALL include `pagination: { page, per_page, total, total_pages }` + +#### Scenario: Default pagination +- **WHEN** `page` or `per_page` is not provided +- **THEN** `page` SHALL default to 1 +- **THEN** `per_page` SHALL default to 50 + +#### Scenario: Per-page cap +- **WHEN** `per_page` exceeds 200 +- **THEN** `per_page` SHALL be capped at 200 + +### Requirement: Material Trace API SHALL provide CSV export endpoint +The API SHALL provide CSV export using the same query parameters as the query endpoint. + +#### Scenario: Export request +- **WHEN** `POST /api/material-trace/export` is called with the same parameters as query +- **THEN** the response SHALL be a CSV file with UTF-8 BOM encoding +- **THEN** CSV headers SHALL be in Chinese +- **THEN** all matching records SHALL be included (no pagination, subject to result limits) + +#### Scenario: Export result limit +- **WHEN** export results exceed 50,000 rows +- **THEN** the export SHALL be truncated at 50,000 rows +- **THEN** a warning header SHALL indicate truncation + +### Requirement: Material Trace API SHALL enrich results with workcenter group +The API SHALL add WORKCENTER_GROUP to each result row based on `filter_cache.get_workcenter_mapping()`. + +#### Scenario: Workcenter group enrichment +- **WHEN** query results are returned +- **THEN** each row SHALL include a `WORKCENTER_GROUP` field +- **THEN** the value SHALL be resolved from `filter_cache.get_workcenter_mapping()` using the row's `WORKCENTERNAME` + +#### Scenario: Unknown workcenter +- **WHEN** a row's WORKCENTERNAME has no mapping in the workcenter cache +- **THEN** `WORKCENTER_GROUP` SHALL be empty string + +### Requirement: Material Trace API SHALL apply rate limiting +The API SHALL rate-limit query and export endpoints to protect Oracle resources. + +#### Scenario: Query rate limit +- **WHEN** `/api/material-trace/query` receives excessive requests +- **THEN** requests beyond 30 per 60 seconds SHALL be rejected with HTTP 429 + +#### Scenario: Export rate limit +- **WHEN** `/api/material-trace/export` receives excessive requests +- **THEN** requests beyond 10 per 60 seconds SHALL be rejected with HTTP 429 diff --git a/openspec/changes/archive/2026-03-03-add-material-trace-page/specs/material-trace-page/spec.md b/openspec/changes/archive/2026-03-03-add-material-trace-page/specs/material-trace-page/spec.md new file mode 100644 index 0000000..92c83a1 --- /dev/null +++ b/openspec/changes/archive/2026-03-03-add-material-trace-page/specs/material-trace-page/spec.md @@ -0,0 +1,120 @@ +## ADDED Requirements + +### Requirement: Material Trace page SHALL provide bidirectional query mode switching +The page SHALL provide two query directions with explicit tab switching. + +#### Scenario: Forward query mode (default) +- **WHEN** the page loads +- **THEN** "正向查詢:LOT/工單 → 原物料" tab SHALL be active by default +- **THEN** the input area SHALL show input type selector (LOT ID / 工單) and a multi-line text input + +#### Scenario: Reverse query mode +- **WHEN** user clicks "反向查詢:原物料 → LOT" tab +- **THEN** the input area SHALL switch to material lot name multi-line input +- **THEN** query results and pagination SHALL be cleared + +#### Scenario: Forward input type switching +- **WHEN** forward mode is active +- **THEN** user SHALL be able to switch between "LOT ID" and "工單" input types +- **THEN** switching input type SHALL clear the input field and results + +### Requirement: Material Trace page SHALL accept multi-line input +The page SHALL accept multiple values separated by newlines or commas. + +#### Scenario: Multi-line input parsing +- **WHEN** user enters values separated by newlines, commas, or mixed delimiters +- **THEN** the system SHALL parse and deduplicate values using the same logic as `parseMultiLineInput()` + +#### Scenario: Input count display +- **WHEN** user enters values +- **THEN** the input area SHALL display the parsed count (e.g., "已輸入 5 筆") + +#### Scenario: Forward input limit feedback +- **WHEN** user enters more than 200 values in forward mode +- **THEN** the page SHALL display an error message "正向查詢上限 200 筆" +- **THEN** the query SHALL NOT be sent + +#### Scenario: Reverse input limit feedback +- **WHEN** user enters more than 50 values in reverse mode +- **THEN** the page SHALL display an error message "反向查詢上限 50 筆" +- **THEN** the query SHALL NOT be sent + +### Requirement: Material Trace page SHALL provide workcenter group filter +The page SHALL allow filtering results by workcenter group. + +#### Scenario: Workcenter group options +- **WHEN** the page loads +- **THEN** workcenter group filter SHALL be populated from `filter_cache.get_workcenter_groups()` +- **THEN** the filter SHALL support multi-select +- **THEN** default SHALL be "全部站點" (no filter) + +#### Scenario: Filter applied to query +- **WHEN** user selects workcenter groups and clicks "查詢" +- **THEN** the selected groups SHALL be sent as `workcenter_groups` parameter to the API +- **THEN** results SHALL only contain records from workcenters in the selected groups + +### Requirement: Material Trace page SHALL display query results in a paginated table +The page SHALL display results in a sortable, paginated detail table. + +#### Scenario: Result table columns +- **WHEN** query results are loaded +- **THEN** the table SHALL display: LOT ID (CONTAINERNAME), 工單 (PJ_WORKORDER), 站群組 (WORKCENTER_GROUP), 站點 (WORKCENTERNAME), 料號 (MATERIALPARTNAME), 物料批號 (MATERIALLOTNAME), 供應商批號 (VENDORLOTNUMBER), 應領量 (QTYREQUIRED), 實際消耗 (QTYCONSUMED), 機台 (EQUIPMENTNAME), 交易日期 (TXNDATE), 主分類 (PRIMARY_CATEGORY), 副分類 (SECONDARY_CATEGORY) + +#### Scenario: Pagination controls +- **WHEN** results exceed per-page size +- **THEN** pagination controls SHALL display "上一頁" / "下一頁" buttons and page info in Chinese +- **THEN** default per-page size SHALL be 50 + +#### Scenario: Empty results +- **WHEN** query returns no matching records +- **THEN** the table area SHALL display "查無資料" message + +#### Scenario: Unresolved LOT IDs warning +- **WHEN** the API response contains `meta.unresolved` array +- **THEN** a warning banner SHALL display listing the unresolvable LOT names + +#### Scenario: Result truncation warning +- **WHEN** the API response contains `meta.truncated: true` +- **THEN** an amber warning banner SHALL display "查詢結果超過 10,000 筆上限,請縮小查詢範圍" + +### Requirement: Material Trace page SHALL support CSV export +The page SHALL allow exporting current query results to CSV. + +#### Scenario: Export button +- **WHEN** query results are loaded +- **THEN** an "匯出 CSV" button SHALL be visible +- **WHEN** user clicks "匯出 CSV" +- **THEN** the export request SHALL use the same query parameters as the current query + +#### Scenario: Export disabled without results +- **WHEN** no query has been executed or results are empty +- **THEN** the "匯出 CSV" button SHALL be disabled + +### Requirement: Material Trace page SHALL provide loading and error states +The page SHALL provide clear feedback during loading and error conditions. + +#### Scenario: Loading state +- **WHEN** a query is in progress +- **THEN** a loading indicator SHALL be visible +- **THEN** the query button SHALL be disabled + +#### Scenario: API error +- **WHEN** the API returns an error +- **THEN** a red error banner SHALL display the error message + +#### Scenario: Error cleared on new query +- **WHEN** user initiates a new query +- **THEN** previous error and warning banners SHALL be cleared + +### Requirement: Material Trace page SHALL use Chinese labels +The page SHALL display all UI text in Traditional Chinese consistent with the rest of the application. + +#### Scenario: Page title +- **WHEN** the page is rendered +- **THEN** the page title SHALL be "原物料追溯查詢" + +#### Scenario: Button labels +- **WHEN** the page is rendered +- **THEN** the query button SHALL display "查詢" +- **THEN** the export button SHALL display "匯出 CSV" +- **THEN** the clear button SHALL display "清除" diff --git a/openspec/changes/archive/2026-03-03-add-material-trace-page/tasks.md b/openspec/changes/archive/2026-03-03-add-material-trace-page/tasks.md new file mode 100644 index 0000000..79b7753 --- /dev/null +++ b/openspec/changes/archive/2026-03-03-add-material-trace-page/tasks.md @@ -0,0 +1,63 @@ +## 1. SQL 查詢檔 + +- [x] 1.1 建立 `src/mes_dashboard/sql/material_trace/` 目錄 +- [x] 1.2 新增 `forward_by_lot.sql`:以 CONTAINERID IN (:ids) 查詢 `DW_MES_LOTMATERIALSHISTORY`,LEFT JOIN `DW_MES_CONTAINER` 取 CONTAINERNAME,含可選 WORKCENTERNAME IN 篩選 +- [x] 1.3 新增 `forward_by_workorder.sql`:以 PJ_WORKORDER IN (:ids) 查詢,結構與 forward_by_lot 相同 +- [x] 1.4 新增 `reverse_by_material_lot.sql`:以 MATERIALLOTNAME IN (:ids) 查詢,LEFT JOIN `DW_MES_CONTAINER` 取 CONTAINERNAME,含 FETCH FIRST 10001 ROWS ONLY 上限,含可選 WORKCENTERNAME IN 篩選 +- [x] 1.5 新增 `resolve_container_ids.sql`:批次將 CONTAINERNAME 轉換為 CONTAINERID + +## 2. 後端 Service + +- [x] 2.1 新增 `src/mes_dashboard/services/material_trace_service.py`,包含 `forward_query(mode, values, workcenter_groups, page, per_page)` 函式 +- [x] 2.2 在 `forward_query` 中實作 LOT ID 模式:呼叫 `resolve_container_ids.sql` 將 CONTAINERNAME 批次轉換為 CONTAINERID,記錄未解析的名稱到 `meta.unresolved` +- [x] 2.3 在 `forward_query` 中實作工單模式:直接以 PJ_WORKORDER 查詢 +- [x] 2.4 實作 `reverse_query(values, workcenter_groups, page, per_page)` 函式,以 MATERIALLOTNAME 查詢,檢查結果是否超過 10,000 筆並設定 `meta.truncated` +- [x] 2.5 實作共用 `_enrich_workcenter_group(df)` 函式:使用 `filter_cache.get_workcenter_mapping()` 對 DataFrame 添加 WORKCENTER_GROUP 欄位 +- [x] 2.6 實作共用 `_apply_workcenter_group_filter(workcenter_groups)` 函式:透過 `filter_cache.get_workcenter_mapping()` 將站群組名稱解析為 WORKCENTERNAME 清單,供 SQL WHERE 使用 +- [x] 2.7 實作 `export_csv(mode, values, workcenter_groups)` 函式,結果上限 50,000 筆,回傳 UTF-8 BOM CSV + +## 3. 後端 Route + +- [x] 3.1 新增 `src/mes_dashboard/routes/material_trace_routes.py`,建立 `material_trace_bp` Blueprint,prefix `/api/material-trace` +- [x] 3.2 實作 `POST /query` 端點:驗證 mode(lot/workorder/material_lot)、values 非空、筆數上限(正向 200 / 反向 50);根據 mode 呼叫 `forward_query` 或 `reverse_query`;回傳分頁結果 +- [x] 3.3 實作 `POST /export` 端點:與 query 相同參數驗證,呼叫 `export_csv`,回傳 CSV response +- [x] 3.4 實作 `GET /filter-options` 端點:回傳 `filter_cache.get_workcenter_groups()` 供前端站群組下拉選單使用 +- [x] 3.5 加入 rate limiting:query 30/60s,export 10/60s +- [x] 3.6 在 `routes/__init__.py` 註冊 `material_trace_bp` + +## 4. 前端頁面基礎 + +- [x] 4.1 新增 `frontend/material-trace.html` Vite entry point +- [x] 4.2 新增 `frontend/src/material-trace/main.js` 初始化 Vue app +- [x] 4.3 新增 `frontend/src/material-trace/App.vue` 主元件:包含 queryMode(forward/reverse)、forwardInputType(lot/workorder)、inputText、workcenterGroups、results、pagination、loading、error 等 reactive state +- [x] 4.4 新增 `frontend/src/material-trace/style.css`,沿用 reject-history 的表格/banner 樣式基礎 +- [x] 4.5 在 `vite.config.js` 加入 `material-trace` entry +- [x] 4.6 在 Flask 後端 `templates/` 新增頁面路由(或 Jinja template),確認頁面可存取 + +## 5. 前端元件 + +- [x] 5.1 實作查詢模式切換 tab(正向查詢 / 反向查詢),切換時清空輸入和結果 +- [x] 5.2 實作正向模式的輸入類型選擇(LOT ID / 工單),切換時清空輸入 +- [x] 5.3 實作多筆輸入 textarea,使用 `parseMultiLineInput()` 解析,顯示已輸入筆數 +- [x] 5.4 實作前端輸入筆數驗證(正向 200 筆 / 反向 50 筆),超過時顯示 error banner 並阻止查詢 +- [x] 5.5 實作站群組多選篩選下拉(options 從 `/api/material-trace/filter-options` 載入) +- [x] 5.6 實作 `executePrimaryQuery()` 函式:呼叫 `/api/material-trace/query` API,處理結果、分頁、error、unresolved、truncated 警告 +- [x] 5.7 實作結果表格,含 13 個欄位(CONTAINERNAME、PJ_WORKORDER、WORKCENTER_GROUP、WORKCENTERNAME、MATERIALPARTNAME、MATERIALLOTNAME、VENDORLOTNUMBER、QTYREQUIRED、QTYCONSUMED、EQUIPMENTNAME、TXNDATE、PRIMARY_CATEGORY、SECONDARY_CATEGORY) +- [x] 5.8 實作分頁控制(上一頁/下一頁/頁碼顯示),server-side 分頁 +- [x] 5.9 實作匯出 CSV 按鈕,呼叫 `/api/material-trace/export`,無結果時 disabled +- [x] 5.10 實作 loading overlay、error banner、warning banner(unresolved LOT / 結果截斷) + +## 6. 導覽整合 + +- [x] 6.1 在 sidebar/drawer 導覽列新增「原物料追溯查詢」頁面入口 + +## 7. 測試 + +- [x] 7.1 新增 `tests/test_material_trace_service.py`:測試正向 LOT 模式查詢(mock Oracle 回傳),驗證 CONTAINERID resolve + 結果 enrichment +- [x] 7.2 測試正向工單模式查詢,驗證 PJ_WORKORDER 直接查詢 +- [x] 7.3 測試反向查詢,驗證結果上限 10,000 筆截斷邏輯 +- [x] 7.4 測試站群組篩選:mock `get_workcenter_mapping()` 回傳 mapping,驗證 WORKCENTERNAME IN 過濾 +- [x] 7.5 測試未解析 LOT ID 的 `meta.unresolved` 回傳 +- [x] 7.6 新增 `tests/test_material_trace_routes.py`:測試輸入驗證(mode 無效、values 空、超過筆數上限)回傳 HTTP 400 +- [x] 7.7 測試 query 端點回傳正確分頁結構 +- [x] 7.8 測試 export 端點回傳 CSV content-type 和 UTF-8 BOM diff --git a/openspec/specs/material-trace-api/spec.md b/openspec/specs/material-trace-api/spec.md new file mode 100644 index 0000000..5093e48 --- /dev/null +++ b/openspec/specs/material-trace-api/spec.md @@ -0,0 +1,120 @@ +## ADDED Requirements + +### Requirement: Material Trace API SHALL provide forward query endpoint +The API SHALL accept LOT IDs or work order numbers and return corresponding material consumption records from `DW_MES_LOTMATERIALSHISTORY`. + +#### Scenario: Forward query by LOT ID +- **WHEN** `POST /api/material-trace/query` is called with `mode: "lot"` and `values: ["GA25060001-A01", "GA25060502"]` +- **THEN** the API SHALL resolve LOT names to CONTAINERIDs via `DW_MES_CONTAINER` +- **THEN** the API SHALL return material consumption records matching those CONTAINERIDs +- **THEN** each record SHALL include CONTAINERID, CONTAINERNAME, PJ_WORKORDER, WORKCENTERNAME, WORKCENTER_GROUP, MATERIALPARTNAME, MATERIALLOTNAME, VENDORLOTNUMBER, QTYREQUIRED, QTYCONSUMED, EQUIPMENTNAME, TXNDATE, PRIMARY_CATEGORY, SECONDARY_CATEGORY + +#### Scenario: Forward query by work order +- **WHEN** `POST /api/material-trace/query` is called with `mode: "workorder"` and `values: ["WO-2025-001", "WO-2025-002"]` +- **THEN** the API SHALL query `DW_MES_LOTMATERIALSHISTORY` using `PJ_WORKORDER` index directly +- **THEN** the response format SHALL be identical to LOT ID mode + +#### Scenario: Forward query with workcenter group filter +- **WHEN** `POST /api/material-trace/query` includes `workcenter_groups: ["焊接_DB"]` +- **THEN** the API SHALL resolve group names to WORKCENTERNAME list via `filter_cache.get_workcenter_mapping()` +- **THEN** the SQL query SHALL include `AND WORKCENTERNAME IN (...)` filter +- **THEN** results SHALL only contain records from workcenters belonging to the selected groups + +#### Scenario: Forward query input limit +- **WHEN** `POST /api/material-trace/query` with `mode: "lot"` or `mode: "workorder"` contains more than 200 values +- **THEN** the API SHALL return HTTP 400 with error message indicating the 200-value limit + +### Requirement: Material Trace API SHALL provide reverse query endpoint +The API SHALL accept material lot names and return LOTs that consumed those materials. + +#### Scenario: Reverse query by material lot name +- **WHEN** `POST /api/material-trace/query` is called with `mode: "material_lot"` and `values: ["WIRE-LOT-20250101-A"]` +- **THEN** the API SHALL query `DW_MES_LOTMATERIALSHISTORY` using `MATERIALLOTNAME` index +- **THEN** each record SHALL include the same fields as forward query results + +#### Scenario: Reverse query with workcenter group filter +- **WHEN** reverse query includes `workcenter_groups` parameter +- **THEN** the same workcenter group filtering logic as forward query SHALL apply + +#### Scenario: Reverse query input limit +- **WHEN** `POST /api/material-trace/query` with `mode: "material_lot"` contains more than 50 values +- **THEN** the API SHALL return HTTP 400 with error message indicating the 50-value limit + +#### Scenario: Reverse query result limit +- **WHEN** reverse query results exceed 10,000 rows +- **THEN** the API SHALL return exactly 10,000 rows +- **THEN** the response `meta` SHALL include `truncated: true` and `max_rows: 10000` + +### Requirement: Material Trace API SHALL validate query parameters +The API SHALL validate input parameters before executing database queries. + +#### Scenario: Missing required fields +- **WHEN** `POST /api/material-trace/query` is called without `mode` or `values` +- **THEN** the API SHALL return HTTP 400 with descriptive validation error + +#### Scenario: Invalid mode +- **WHEN** `mode` is not one of `lot`, `workorder`, `material_lot` +- **THEN** the API SHALL return HTTP 400 + +#### Scenario: Empty values +- **WHEN** `values` is an empty array or all values are blank after trimming +- **THEN** the API SHALL return HTTP 400 with error message "請輸入至少一筆查詢條件" + +#### Scenario: Unresolvable LOT IDs +- **WHEN** some LOT names cannot be resolved to CONTAINERIDs +- **THEN** the API SHALL proceed with the resolved subset +- **THEN** the response `meta` SHALL include `unresolved` array listing unresolvable LOT names + +### Requirement: Material Trace API SHALL support paginated results +The API SHALL support server-side pagination for query results. + +#### Scenario: Pagination parameters +- **WHEN** `POST /api/material-trace/query` includes `page` and `per_page` +- **THEN** results SHALL be paginated accordingly +- **THEN** response SHALL include `pagination: { page, per_page, total, total_pages }` + +#### Scenario: Default pagination +- **WHEN** `page` or `per_page` is not provided +- **THEN** `page` SHALL default to 1 +- **THEN** `per_page` SHALL default to 50 + +#### Scenario: Per-page cap +- **WHEN** `per_page` exceeds 200 +- **THEN** `per_page` SHALL be capped at 200 + +### Requirement: Material Trace API SHALL provide CSV export endpoint +The API SHALL provide CSV export using the same query parameters as the query endpoint. + +#### Scenario: Export request +- **WHEN** `POST /api/material-trace/export` is called with the same parameters as query +- **THEN** the response SHALL be a CSV file with UTF-8 BOM encoding +- **THEN** CSV headers SHALL be in Chinese +- **THEN** all matching records SHALL be included (no pagination, subject to result limits) + +#### Scenario: Export result limit +- **WHEN** export results exceed 50,000 rows +- **THEN** the export SHALL be truncated at 50,000 rows +- **THEN** a warning header SHALL indicate truncation + +### Requirement: Material Trace API SHALL enrich results with workcenter group +The API SHALL add WORKCENTER_GROUP to each result row based on `filter_cache.get_workcenter_mapping()`. + +#### Scenario: Workcenter group enrichment +- **WHEN** query results are returned +- **THEN** each row SHALL include a `WORKCENTER_GROUP` field +- **THEN** the value SHALL be resolved from `filter_cache.get_workcenter_mapping()` using the row's `WORKCENTERNAME` + +#### Scenario: Unknown workcenter +- **WHEN** a row's WORKCENTERNAME has no mapping in the workcenter cache +- **THEN** `WORKCENTER_GROUP` SHALL be empty string + +### Requirement: Material Trace API SHALL apply rate limiting +The API SHALL rate-limit query and export endpoints to protect Oracle resources. + +#### Scenario: Query rate limit +- **WHEN** `/api/material-trace/query` receives excessive requests +- **THEN** requests beyond 30 per 60 seconds SHALL be rejected with HTTP 429 + +#### Scenario: Export rate limit +- **WHEN** `/api/material-trace/export` receives excessive requests +- **THEN** requests beyond 10 per 60 seconds SHALL be rejected with HTTP 429 diff --git a/openspec/specs/material-trace-page/spec.md b/openspec/specs/material-trace-page/spec.md new file mode 100644 index 0000000..92c83a1 --- /dev/null +++ b/openspec/specs/material-trace-page/spec.md @@ -0,0 +1,120 @@ +## ADDED Requirements + +### Requirement: Material Trace page SHALL provide bidirectional query mode switching +The page SHALL provide two query directions with explicit tab switching. + +#### Scenario: Forward query mode (default) +- **WHEN** the page loads +- **THEN** "正向查詢:LOT/工單 → 原物料" tab SHALL be active by default +- **THEN** the input area SHALL show input type selector (LOT ID / 工單) and a multi-line text input + +#### Scenario: Reverse query mode +- **WHEN** user clicks "反向查詢:原物料 → LOT" tab +- **THEN** the input area SHALL switch to material lot name multi-line input +- **THEN** query results and pagination SHALL be cleared + +#### Scenario: Forward input type switching +- **WHEN** forward mode is active +- **THEN** user SHALL be able to switch between "LOT ID" and "工單" input types +- **THEN** switching input type SHALL clear the input field and results + +### Requirement: Material Trace page SHALL accept multi-line input +The page SHALL accept multiple values separated by newlines or commas. + +#### Scenario: Multi-line input parsing +- **WHEN** user enters values separated by newlines, commas, or mixed delimiters +- **THEN** the system SHALL parse and deduplicate values using the same logic as `parseMultiLineInput()` + +#### Scenario: Input count display +- **WHEN** user enters values +- **THEN** the input area SHALL display the parsed count (e.g., "已輸入 5 筆") + +#### Scenario: Forward input limit feedback +- **WHEN** user enters more than 200 values in forward mode +- **THEN** the page SHALL display an error message "正向查詢上限 200 筆" +- **THEN** the query SHALL NOT be sent + +#### Scenario: Reverse input limit feedback +- **WHEN** user enters more than 50 values in reverse mode +- **THEN** the page SHALL display an error message "反向查詢上限 50 筆" +- **THEN** the query SHALL NOT be sent + +### Requirement: Material Trace page SHALL provide workcenter group filter +The page SHALL allow filtering results by workcenter group. + +#### Scenario: Workcenter group options +- **WHEN** the page loads +- **THEN** workcenter group filter SHALL be populated from `filter_cache.get_workcenter_groups()` +- **THEN** the filter SHALL support multi-select +- **THEN** default SHALL be "全部站點" (no filter) + +#### Scenario: Filter applied to query +- **WHEN** user selects workcenter groups and clicks "查詢" +- **THEN** the selected groups SHALL be sent as `workcenter_groups` parameter to the API +- **THEN** results SHALL only contain records from workcenters in the selected groups + +### Requirement: Material Trace page SHALL display query results in a paginated table +The page SHALL display results in a sortable, paginated detail table. + +#### Scenario: Result table columns +- **WHEN** query results are loaded +- **THEN** the table SHALL display: LOT ID (CONTAINERNAME), 工單 (PJ_WORKORDER), 站群組 (WORKCENTER_GROUP), 站點 (WORKCENTERNAME), 料號 (MATERIALPARTNAME), 物料批號 (MATERIALLOTNAME), 供應商批號 (VENDORLOTNUMBER), 應領量 (QTYREQUIRED), 實際消耗 (QTYCONSUMED), 機台 (EQUIPMENTNAME), 交易日期 (TXNDATE), 主分類 (PRIMARY_CATEGORY), 副分類 (SECONDARY_CATEGORY) + +#### Scenario: Pagination controls +- **WHEN** results exceed per-page size +- **THEN** pagination controls SHALL display "上一頁" / "下一頁" buttons and page info in Chinese +- **THEN** default per-page size SHALL be 50 + +#### Scenario: Empty results +- **WHEN** query returns no matching records +- **THEN** the table area SHALL display "查無資料" message + +#### Scenario: Unresolved LOT IDs warning +- **WHEN** the API response contains `meta.unresolved` array +- **THEN** a warning banner SHALL display listing the unresolvable LOT names + +#### Scenario: Result truncation warning +- **WHEN** the API response contains `meta.truncated: true` +- **THEN** an amber warning banner SHALL display "查詢結果超過 10,000 筆上限,請縮小查詢範圍" + +### Requirement: Material Trace page SHALL support CSV export +The page SHALL allow exporting current query results to CSV. + +#### Scenario: Export button +- **WHEN** query results are loaded +- **THEN** an "匯出 CSV" button SHALL be visible +- **WHEN** user clicks "匯出 CSV" +- **THEN** the export request SHALL use the same query parameters as the current query + +#### Scenario: Export disabled without results +- **WHEN** no query has been executed or results are empty +- **THEN** the "匯出 CSV" button SHALL be disabled + +### Requirement: Material Trace page SHALL provide loading and error states +The page SHALL provide clear feedback during loading and error conditions. + +#### Scenario: Loading state +- **WHEN** a query is in progress +- **THEN** a loading indicator SHALL be visible +- **THEN** the query button SHALL be disabled + +#### Scenario: API error +- **WHEN** the API returns an error +- **THEN** a red error banner SHALL display the error message + +#### Scenario: Error cleared on new query +- **WHEN** user initiates a new query +- **THEN** previous error and warning banners SHALL be cleared + +### Requirement: Material Trace page SHALL use Chinese labels +The page SHALL display all UI text in Traditional Chinese consistent with the rest of the application. + +#### Scenario: Page title +- **WHEN** the page is rendered +- **THEN** the page title SHALL be "原物料追溯查詢" + +#### Scenario: Button labels +- **WHEN** the page is rendered +- **THEN** the query button SHALL display "查詢" +- **THEN** the export button SHALL display "匯出 CSV" +- **THEN** the clear button SHALL display "清除" diff --git a/src/mes_dashboard/app.py b/src/mes_dashboard/app.py index 1a19406..404c1ca 100644 --- a/src/mes_dashboard/app.py +++ b/src/mes_dashboard/app.py @@ -931,6 +931,27 @@ def create_app(config_name: str | None = None) -> Flask: 200, )) + @app.route('/material-trace') + def material_trace_page(): + """Material trace query page served as pure Vite HTML output.""" + canonical_redirect = maybe_redirect_to_canonical_shell('/material-trace') + 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, "material-trace.html") + if os.path.exists(dist_html): + return send_from_directory(dist_dir, 'material-trace.html') + + return missing_in_scope_asset_response('/material-trace', ( + "" + "" + "原物料追溯查詢" + "" + "
", + 200, + )) + # ======================================================== # Table Query APIs (for table_data_viewer) # ======================================================== diff --git a/src/mes_dashboard/routes/__init__.py b/src/mes_dashboard/routes/__init__.py index d43f307..b534c9b 100644 --- a/src/mes_dashboard/routes/__init__.py +++ b/src/mes_dashboard/routes/__init__.py @@ -20,6 +20,7 @@ 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 +from .material_trace_routes import material_trace_bp def register_routes(app) -> None: @@ -38,6 +39,7 @@ def register_routes(app) -> None: app.register_blueprint(mid_section_defect_bp) app.register_blueprint(trace_bp) app.register_blueprint(reject_history_bp) + app.register_blueprint(material_trace_bp) __all__ = [ 'wip_bp', @@ -56,5 +58,6 @@ __all__ = [ 'mid_section_defect_bp', 'trace_bp', 'reject_history_bp', + 'material_trace_bp', 'register_routes', ] diff --git a/src/mes_dashboard/routes/material_trace_routes.py b/src/mes_dashboard/routes/material_trace_routes.py new file mode 100644 index 0000000..da1f159 --- /dev/null +++ b/src/mes_dashboard/routes/material_trace_routes.py @@ -0,0 +1,185 @@ +# -*- coding: utf-8 -*- +"""Material trace API routes.""" + +from __future__ import annotations + +import logging + +from flask import Blueprint, Response, jsonify + +from mes_dashboard.core.rate_limit import configured_rate_limit +from mes_dashboard.core.request_validation import parse_json_payload +from mes_dashboard.services.container_resolution_policy import ( + validate_resolution_request, +) +from mes_dashboard.services.filter_cache import get_workcenter_groups +from mes_dashboard.services.material_trace_service import ( + export_csv, + forward_query, + reverse_query, +) + +logger = logging.getLogger("mes_dashboard.material_trace") + +material_trace_bp = Blueprint("material_trace", __name__) + +# ============================================================ +# Constants +# ============================================================ + +_VALID_MODES = {"lot", "workorder", "material_lot"} +_FORWARD_MODES = {"lot", "workorder"} +_FORWARD_INPUT_LIMIT = 200 +_REVERSE_INPUT_LIMIT = 50 +_MAX_PER_PAGE = 200 + +# ============================================================ +# Rate Limiting +# ============================================================ + +_QUERY_RATE_LIMIT = configured_rate_limit( + bucket="material-trace-query", + max_attempts_env="MATERIAL_TRACE_QUERY_RATE_LIMIT_MAX_REQUESTS", + window_seconds_env="MATERIAL_TRACE_QUERY_RATE_LIMIT_WINDOW_SECONDS", + default_max_attempts=30, + default_window_seconds=60, +) + +_EXPORT_RATE_LIMIT = configured_rate_limit( + bucket="material-trace-export", + max_attempts_env="MATERIAL_TRACE_EXPORT_RATE_LIMIT_MAX_REQUESTS", + window_seconds_env="MATERIAL_TRACE_EXPORT_RATE_LIMIT_WINDOW_SECONDS", + default_max_attempts=10, + default_window_seconds=60, +) + +# ============================================================ +# Helpers +# ============================================================ + + +def _validate_query_params(body: dict) -> tuple[str | None, str, list[str], list[str] | None, int, int]: + """Validate and extract query parameters. + + Returns: + (error_message, mode, values, workcenter_groups, page, per_page) + """ + mode = str(body.get("mode", "")).strip() + if mode not in _VALID_MODES: + return f"無效的查詢模式,可用值: {', '.join(sorted(_VALID_MODES))}", "", [], None, 1, 50 + + raw_values = body.get("values") + if not isinstance(raw_values, list): + return "values 必須為陣列", mode, [], None, 1, 50 + + values = [str(v).strip() for v in raw_values if str(v).strip()] + if not values: + return "請輸入至少一筆查詢條件", mode, [], None, 1, 50 + + # Input count limits + if mode in _FORWARD_MODES and len(values) > _FORWARD_INPUT_LIMIT: + return f"正向查詢上限 {_FORWARD_INPUT_LIMIT} 筆", mode, values, None, 1, 50 + if mode == "material_lot" and len(values) > _REVERSE_INPUT_LIMIT: + return f"反向查詢上限 {_REVERSE_INPUT_LIMIT} 筆", mode, values, None, 1, 50 + + # Wildcard prefix safety (reuse container_resolution_policy guardrails) + _INPUT_TYPE_LABELS = {"lot": "LOT ID", "workorder": "工單", "material_lot": "原物料批號"} + wildcard_error = validate_resolution_request(_INPUT_TYPE_LABELS.get(mode, mode), values) + if wildcard_error: + return wildcard_error, mode, values, None, 1, 50 + + # Optional workcenter groups + raw_groups = body.get("workcenter_groups") + workcenter_groups = None + if isinstance(raw_groups, list) and raw_groups: + workcenter_groups = [str(g).strip() for g in raw_groups if str(g).strip()] + if not workcenter_groups: + workcenter_groups = None + + page = max(1, int(body.get("page", 1) or 1)) + per_page = min(max(1, int(body.get("per_page", 50) or 50)), _MAX_PER_PAGE) + + return None, mode, values, workcenter_groups, page, per_page + + +# ============================================================ +# Routes +# ============================================================ + + +@material_trace_bp.route("/api/material-trace/query", methods=["POST"]) +@_QUERY_RATE_LIMIT +def api_material_trace_query(): + """Execute material trace query (forward or reverse).""" + body, payload_error = parse_json_payload(require_non_empty_object=True) + if payload_error is not None: + return jsonify({"success": False, "error": payload_error.message}), payload_error.status_code + + error, mode, values, workcenter_groups, page, per_page = _validate_query_params(body) + if error: + return jsonify({"success": False, "error": error}), 400 + + try: + if mode in _FORWARD_MODES: + result = forward_query(mode, values, workcenter_groups, page, per_page) + else: + result = reverse_query(values, workcenter_groups, page, per_page) + + return jsonify({"success": True, **result}) + + except MemoryError as exc: + logger.warning("Material trace query memory guard: %s", exc) + return jsonify({"success": False, "error": str(exc)}), 400 + except Exception: + logger.exception("Material trace query failed: mode=%s", mode) + return jsonify({"success": False, "error": "查詢失敗,請稍後再試"}), 500 + + +@material_trace_bp.route("/api/material-trace/export", methods=["POST"]) +@_EXPORT_RATE_LIMIT +def api_material_trace_export(): + """Export material trace query results as CSV.""" + body, payload_error = parse_json_payload(require_non_empty_object=True) + if payload_error is not None: + return jsonify({"success": False, "error": payload_error.message}), payload_error.status_code + + error, mode, values, workcenter_groups, _page, _per_page = _validate_query_params(body) + if error: + return jsonify({"success": False, "error": error}), 400 + + try: + csv_bytes, meta = export_csv(mode, values, workcenter_groups) + + response = Response( + csv_bytes, + mimetype="text/csv; charset=utf-8", + headers={ + "Content-Disposition": "attachment; filename=material_trace.csv", + }, + ) + if meta.get("truncated"): + response.headers["X-Truncated"] = "true" + response.headers["X-Max-Rows"] = str(meta.get("export_max_rows", "")) + return response + + except MemoryError as exc: + logger.warning("Material trace export memory guard: %s", exc) + return jsonify({"success": False, "error": str(exc)}), 400 + except Exception: + logger.exception("Material trace export failed: mode=%s", mode) + return jsonify({"success": False, "error": "匯出失敗,請稍後再試"}), 500 + + +@material_trace_bp.route("/api/material-trace/filter-options", methods=["GET"]) +def api_material_trace_filter_options(): + """Return workcenter group options for filter dropdown.""" + groups = get_workcenter_groups() + if groups is None: + return jsonify({"success": False, "error": "站群組資料載入中"}), 503 + + return jsonify({ + "success": True, + "data": { + "workcenter_groups": [g["name"] for g in groups], + }, + }) diff --git a/src/mes_dashboard/services/material_trace_service.py b/src/mes_dashboard/services/material_trace_service.py new file mode 100644 index 0000000..9dd16c1 --- /dev/null +++ b/src/mes_dashboard/services/material_trace_service.py @@ -0,0 +1,366 @@ +# -*- coding: utf-8 -*- +"""Material trace service — bidirectional LOT/material query.""" + +from __future__ import annotations + +import io +import logging +import os +from typing import Any, Dict, List, Optional + +import pandas as pd + +from mes_dashboard.core.database import read_sql_df, read_sql_df_slow +from mes_dashboard.services.container_resolution_policy import ( + validate_resolution_request, +) +from mes_dashboard.services.filter_cache import ( + get_workcenter_mapping, + get_workcenters_for_groups, +) +from mes_dashboard.sql import QueryBuilder, SQLLoader + +logger = logging.getLogger("mes_dashboard.material_trace") + +_REVERSE_MAX_ROWS = 10_000 +_EXPORT_MAX_ROWS = 50_000 + +# Safeguard: max DataFrame memory (MB) before aborting — same pattern as batch_query_engine +_MAX_RESULT_MB = int(os.getenv("MATERIAL_TRACE_MAX_RESULT_MB", "256")) + +# Safeguard: IN-clause batch size — Oracle has practical limits on large IN lists +_IN_BATCH_SIZE = 1000 + +_CSV_COLUMNS = { + "CONTAINERNAME": "LOT ID", + "PJ_WORKORDER": "工單", + "WORKCENTER_GROUP": "站群組", + "WORKCENTERNAME": "站點", + "MATERIALPARTNAME": "料號", + "MATERIALLOTNAME": "物料批號", + "VENDORLOTNUMBER": "供應商批號", + "QTYREQUIRED": "應領量", + "QTYCONSUMED": "實際消耗", + "EQUIPMENTNAME": "機台", + "TXNDATE": "交易日期", + "PRIMARY_CATEGORY": "主分類", + "SECONDARY_CATEGORY": "副分類", +} + + +# ============================================================ +# Wildcard helpers (same pattern as query_tool_service) +# ============================================================ + + +def _normalize_wildcard_token(value: str) -> str: + """Normalize user wildcard syntax: * → %.""" + return str(value or "").replace("*", "%") + + +def _is_pattern_token(value: str) -> bool: + token = _normalize_wildcard_token(value) + return "%" in token or "_" in token + + +def _add_exact_or_pattern_condition( + builder: QueryBuilder, + column: str, + values: List[str], +) -> None: + """Add IN + LIKE mixed condition supporting exact and wildcard tokens. + + Replicates the proven pattern from query_tool_service. + """ + if not values: + return + + col_expr = f"NVL({column}, '')" + conditions: List[str] = [] + + exact_tokens = [v for v in values if not _is_pattern_token(v)] + pattern_tokens = [v for v in values if _is_pattern_token(v)] + + if exact_tokens: + placeholders: List[str] = [] + for token in exact_tokens: + param = builder._next_param() + placeholders.append(f":{param}") + builder.params[param] = token + conditions.append(f"{col_expr} IN ({', '.join(placeholders)})") + + for token in pattern_tokens: + param = builder._next_param() + builder.params[param] = _normalize_wildcard_token(token) + conditions.append(f"{col_expr} LIKE :{param} ESCAPE '\\'") + + if conditions: + builder.add_condition(f"({' OR '.join(conditions)})") + + +# ============================================================ +# Shared helpers +# ============================================================ + + +def _enrich_workcenter_group(df: pd.DataFrame) -> pd.DataFrame: + """Add WORKCENTER_GROUP column based on filter_cache mapping.""" + df = df.copy() + mapping = get_workcenter_mapping() + if mapping and "WORKCENTERNAME" in df.columns: + df["WORKCENTER_GROUP"] = df["WORKCENTERNAME"].map( + lambda wc: (mapping.get(wc) or {}).get("group", "") + ) + else: + df["WORKCENTER_GROUP"] = "" + return df + + +def _resolve_workcenter_names(workcenter_groups: Optional[List[str]]) -> Optional[List[str]]: + """Resolve group names to a flat list of WORKCENTERNAME values.""" + if not workcenter_groups: + return None + names = get_workcenters_for_groups(workcenter_groups) + return names or None + + +def _resolve_container_ids( + lot_names: List[str], +) -> tuple[List[str], Dict[str, str], List[str]]: + """Batch-resolve CONTAINERNAME → CONTAINERID (supports wildcards). + + Returns: + (container_ids, name_to_id_map, unresolved_names) + Note: wildcard tokens are never reported as "unresolved". + """ + builder = QueryBuilder(base_sql=SQLLoader.load("material_trace/resolve_container_ids")) + _add_exact_or_pattern_condition(builder, "c.CONTAINERNAME", lot_names) + sql, params = builder.build() + + df = read_sql_df(sql, params) + if df is None or df.empty: + # Only exact tokens can be "unresolved" + exact_unresolved = [n for n in lot_names if not _is_pattern_token(n)] + return [], {}, exact_unresolved + + name_to_id: Dict[str, str] = {} + for _, row in df.iterrows(): + name_to_id[str(row["CONTAINERNAME"])] = str(row["CONTAINERID"]) + + resolved_ids = list(name_to_id.values()) + # Only report unresolved for exact tokens (wildcards can match 0 rows legitimately) + unresolved = [n for n in lot_names if not _is_pattern_token(n) and n not in name_to_id] + return resolved_ids, name_to_id, unresolved + + +def _check_memory_guard(df: pd.DataFrame) -> None: + """Raise if DataFrame exceeds memory threshold.""" + mem_mb = df.memory_usage(deep=True).sum() / (1024 * 1024) + if mem_mb > _MAX_RESULT_MB: + raise MemoryError( + f"查詢結果佔用 {mem_mb:.0f} MB,超過 {_MAX_RESULT_MB} MB 上限,請縮小查詢範圍" + ) + + +def _execute_batched_query( + sql_name: str, + column: str, + values: List[str], + wc_names: Optional[List[str]] = None, + *, + allow_patterns: bool = True, +) -> pd.DataFrame: + """Execute query in batches, using slow-query channel. + + When allow_patterns=True, values containing * or % are sent as LIKE clauses. + When allow_patterns=False (e.g. resolved CONTAINERIDs), all values are treated + as exact IN matches regardless of content. + """ + base_sql = SQLLoader.load(sql_name) + chunks: list[pd.DataFrame] = [] + + if allow_patterns: + exact_tokens = [v for v in values if not _is_pattern_token(v)] + pattern_tokens = [v for v in values if _is_pattern_token(v)] + else: + exact_tokens = list(values) + pattern_tokens = [] + + # Batch exact tokens; include pattern tokens once in the first batch + for i in range(0, max(len(exact_tokens), 1), _IN_BATCH_SIZE): + batch = exact_tokens[i : i + _IN_BATCH_SIZE] + combined = batch + (pattern_tokens if i == 0 else []) + if not combined: + continue + + builder = QueryBuilder(base_sql=base_sql) + _add_exact_or_pattern_condition(builder, column, combined) + if wc_names: + builder.add_in_condition("m.WORKCENTERNAME", wc_names) + + sql, params = builder.build() + df = read_sql_df_slow(sql, params) + if df is not None and not df.empty: + chunks.append(df) + + if not chunks: + return pd.DataFrame() + + result = pd.concat(chunks, ignore_index=True) if len(chunks) > 1 else chunks[0] + # Deduplicate — wildcards across batches may produce overlapping rows + if len(chunks) > 1 and "CONTAINERID" in result.columns: + result = result.drop_duplicates(subset=["CONTAINERID", "MATERIALLOTNAME", "WORKCENTERNAME", "TXNDATE"], ignore_index=True) + _check_memory_guard(result) + return result + + +def _paginate(df: pd.DataFrame, page: int, per_page: int) -> Dict[str, Any]: + """Apply pagination to a DataFrame and return paginated dict.""" + total = len(df) + total_pages = max(1, (total + per_page - 1) // per_page) + page = min(page, total_pages) + start = (page - 1) * per_page + end = start + per_page + page_df = df.iloc[start:end] + + # Replace NaN/NaT with None so JSON serialization produces null (not NaN). + # Must convert to object dtype first — float64 columns coerce None back to NaN. + page_df = page_df.astype(object).where(page_df.notna(), None) + + return { + "rows": page_df.to_dict("records"), + "pagination": { + "page": page, + "per_page": per_page, + "total": total, + "total_pages": total_pages, + }, + } + + +# ============================================================ +# Forward query (LOT ID / Work Order → Materials) +# ============================================================ + + +def forward_query( + mode: str, + values: List[str], + workcenter_groups: Optional[List[str]] = None, + page: int = 1, + per_page: int = 50, +) -> Dict[str, Any]: + """Execute forward material trace query.""" + meta: Dict[str, Any] = {} + wc_names = _resolve_workcenter_names(workcenter_groups) + + if mode == "lot": + container_ids, _name_map, unresolved = _resolve_container_ids(values) + if unresolved: + meta["unresolved"] = unresolved + if not container_ids: + return {"rows": [], "pagination": {"page": 1, "per_page": per_page, "total": 0, "total_pages": 0}, "meta": meta} + + df = _execute_batched_query("material_trace/forward_by_lot", "m.CONTAINERID", container_ids, wc_names, allow_patterns=False) + + else: # workorder + df = _execute_batched_query("material_trace/forward_by_workorder", "m.PJ_WORKORDER", values, wc_names) + + if df.empty: + return {"rows": [], "pagination": {"page": 1, "per_page": per_page, "total": 0, "total_pages": 0}, "meta": meta} + + df = _enrich_workcenter_group(df) + result = _paginate(df, page, per_page) + result["meta"] = meta + return result + + +# ============================================================ +# Reverse query (Material Lot → LOTs) +# ============================================================ + + +def reverse_query( + values: List[str], + workcenter_groups: Optional[List[str]] = None, + page: int = 1, + per_page: int = 50, +) -> Dict[str, Any]: + """Execute reverse material trace query.""" + meta: Dict[str, Any] = {} + wc_names = _resolve_workcenter_names(workcenter_groups) + + df = _execute_batched_query("material_trace/reverse_by_material_lot", "m.MATERIALLOTNAME", values, wc_names) + + if df.empty: + return {"rows": [], "pagination": {"page": 1, "per_page": per_page, "total": 0, "total_pages": 0}, "meta": meta} + + # Check truncation (SQL fetches 10001 rows to detect overflow) + if len(df) > _REVERSE_MAX_ROWS: + df = df.iloc[:_REVERSE_MAX_ROWS] + meta["truncated"] = True + meta["max_rows"] = _REVERSE_MAX_ROWS + + df = _enrich_workcenter_group(df) + result = _paginate(df, page, per_page) + result["meta"] = meta + return result + + +# ============================================================ +# CSV Export +# ============================================================ + + +def export_csv( + mode: str, + values: List[str], + workcenter_groups: Optional[List[str]] = None, +) -> tuple[bytes, Dict[str, Any]]: + """Export query results as UTF-8 BOM CSV.""" + meta: Dict[str, Any] = {} + wc_names = _resolve_workcenter_names(workcenter_groups) + + if mode == "lot": + container_ids, _name_map, unresolved = _resolve_container_ids(values) + if unresolved: + meta["unresolved"] = unresolved + if not container_ids: + return _empty_csv(), meta + + df = _execute_batched_query("material_trace/forward_by_lot", "m.CONTAINERID", container_ids, wc_names, allow_patterns=False) + + elif mode == "workorder": + df = _execute_batched_query("material_trace/forward_by_workorder", "m.PJ_WORKORDER", values, wc_names) + + else: # material_lot + df = _execute_batched_query("material_trace/reverse_by_material_lot", "m.MATERIALLOTNAME", values, wc_names) + + if df.empty: + return _empty_csv(), meta + + # Truncate if over export limit + if len(df) > _EXPORT_MAX_ROWS: + df = df.iloc[:_EXPORT_MAX_ROWS] + meta["truncated"] = True + meta["export_max_rows"] = _EXPORT_MAX_ROWS + + df = _enrich_workcenter_group(df) + + # Select and rename columns for CSV + available_cols = [c for c in _CSV_COLUMNS if c in df.columns] + export_df = df[available_cols].rename(columns=_CSV_COLUMNS) + + buf = io.BytesIO() + buf.write(b"\xef\xbb\xbf") # UTF-8 BOM + buf.write(export_df.fillna("").to_csv(index=False).encode("utf-8")) + return buf.getvalue(), meta + + +def _empty_csv() -> bytes: + """Return an empty CSV with headers only.""" + buf = io.BytesIO() + buf.write(b"\xef\xbb\xbf") + headers = ",".join(_CSV_COLUMNS.values()) + "\n" + buf.write(headers.encode("utf-8")) + return buf.getvalue() diff --git a/src/mes_dashboard/sql/material_trace/forward_by_lot.sql b/src/mes_dashboard/sql/material_trace/forward_by_lot.sql new file mode 100644 index 0000000..cf83fac --- /dev/null +++ b/src/mes_dashboard/sql/material_trace/forward_by_lot.sql @@ -0,0 +1,29 @@ +-- Forward Material Trace by LOT (CONTAINERID) +-- Retrieves material consumption records for given CONTAINERIDs +-- +-- Parameters: +-- Bind variables generated by QueryBuilder.add_in_condition() +-- for CONTAINERID IN (:p0, :p1, ...) +-- +-- Template slots: +-- {{ WHERE_CLAUSE }} - Dynamic WHERE with CONTAINERID IN + optional WORKCENTERNAME IN + +SELECT + m.CONTAINERID, + c.CONTAINERNAME, + m.PJ_WORKORDER, + m.WORKCENTERNAME, + m.MATERIALPARTNAME, + m.MATERIALLOTNAME, + m.VENDORLOTNUMBER, + m.QTYREQUIRED, + m.QTYCONSUMED, + m.EQUIPMENTNAME, + m.TXNDATE, + m.PRIMARY_CATEGORY, + m.SECONDARY_CATEGORY +FROM DWH.DW_MES_LOTMATERIALSHISTORY m +LEFT JOIN DWH.DW_MES_CONTAINER c + ON c.CONTAINERID = m.CONTAINERID +{{ WHERE_CLAUSE }} +ORDER BY m.TXNDATE DESC diff --git a/src/mes_dashboard/sql/material_trace/forward_by_workorder.sql b/src/mes_dashboard/sql/material_trace/forward_by_workorder.sql new file mode 100644 index 0000000..a6ac2ed --- /dev/null +++ b/src/mes_dashboard/sql/material_trace/forward_by_workorder.sql @@ -0,0 +1,29 @@ +-- Forward Material Trace by Work Order (PJ_WORKORDER) +-- Retrieves material consumption records for given work orders +-- +-- Parameters: +-- Bind variables generated by QueryBuilder.add_in_condition() +-- for PJ_WORKORDER IN (:p0, :p1, ...) +-- +-- Template slots: +-- {{ WHERE_CLAUSE }} - Dynamic WHERE with PJ_WORKORDER IN + optional WORKCENTERNAME IN + +SELECT + m.CONTAINERID, + c.CONTAINERNAME, + m.PJ_WORKORDER, + m.WORKCENTERNAME, + m.MATERIALPARTNAME, + m.MATERIALLOTNAME, + m.VENDORLOTNUMBER, + m.QTYREQUIRED, + m.QTYCONSUMED, + m.EQUIPMENTNAME, + m.TXNDATE, + m.PRIMARY_CATEGORY, + m.SECONDARY_CATEGORY +FROM DWH.DW_MES_LOTMATERIALSHISTORY m +LEFT JOIN DWH.DW_MES_CONTAINER c + ON c.CONTAINERID = m.CONTAINERID +{{ WHERE_CLAUSE }} +ORDER BY m.TXNDATE DESC diff --git a/src/mes_dashboard/sql/material_trace/resolve_container_ids.sql b/src/mes_dashboard/sql/material_trace/resolve_container_ids.sql new file mode 100644 index 0000000..c9cea8c --- /dev/null +++ b/src/mes_dashboard/sql/material_trace/resolve_container_ids.sql @@ -0,0 +1,12 @@ +-- Resolve CONTAINERNAME to CONTAINERID +-- Batch lookup for LOT ID -> CONTAINERID conversion +-- +-- Parameters: +-- Bind variables generated by QueryBuilder.add_in_condition() +-- for CONTAINERNAME IN (:p0, :p1, ...) + +SELECT + c.CONTAINERID, + c.CONTAINERNAME +FROM DWH.DW_MES_CONTAINER c +{{ WHERE_CLAUSE }} diff --git a/src/mes_dashboard/sql/material_trace/reverse_by_material_lot.sql b/src/mes_dashboard/sql/material_trace/reverse_by_material_lot.sql new file mode 100644 index 0000000..66ee76f --- /dev/null +++ b/src/mes_dashboard/sql/material_trace/reverse_by_material_lot.sql @@ -0,0 +1,32 @@ +-- Reverse Material Trace by Material Lot Name (MATERIALLOTNAME) +-- Retrieves LOTs that consumed given material lot names +-- +-- Parameters: +-- Bind variables generated by QueryBuilder.add_in_condition() +-- for MATERIALLOTNAME IN (:p0, :p1, ...) +-- +-- Template slots: +-- {{ WHERE_CLAUSE }} - Dynamic WHERE with MATERIALLOTNAME IN + optional WORKCENTERNAME IN +-- +-- Note: FETCH FIRST 10001 ROWS ONLY to detect truncation (> 10000) + +SELECT + m.CONTAINERID, + c.CONTAINERNAME, + m.PJ_WORKORDER, + m.WORKCENTERNAME, + m.MATERIALPARTNAME, + m.MATERIALLOTNAME, + m.VENDORLOTNUMBER, + m.QTYREQUIRED, + m.QTYCONSUMED, + m.EQUIPMENTNAME, + m.TXNDATE, + m.PRIMARY_CATEGORY, + m.SECONDARY_CATEGORY +FROM DWH.DW_MES_LOTMATERIALSHISTORY m +LEFT JOIN DWH.DW_MES_CONTAINER c + ON c.CONTAINERID = m.CONTAINERID +{{ WHERE_CLAUSE }} +ORDER BY m.TXNDATE DESC +FETCH FIRST 10001 ROWS ONLY diff --git a/tests/test_material_trace_routes.py b/tests/test_material_trace_routes.py new file mode 100644 index 0000000..bfe4a08 --- /dev/null +++ b/tests/test_material_trace_routes.py @@ -0,0 +1,276 @@ +# -*- coding: utf-8 -*- +"""Integration tests for Material Trace API routes. + +Tests input validation, pagination structure, and CSV export. +""" + +import json +import pytest +from unittest.mock import patch, MagicMock + +from mes_dashboard import create_app +from mes_dashboard.core.cache import NoOpCache +from mes_dashboard.core.rate_limit import reset_rate_limits_for_tests + + +@pytest.fixture +def app(): + """Create test Flask application.""" + app = create_app() + app.config["TESTING"] = True + app.extensions["cache"] = NoOpCache() + return app + + +@pytest.fixture +def client(app): + """Create test client.""" + return app.test_client() + + +@pytest.fixture(autouse=True) +def _reset_rate_limits(): + reset_rate_limits_for_tests() + yield + reset_rate_limits_for_tests() + + +# ============================================================ +# 7.6 Input validation → HTTP 400 +# ============================================================ + + +class TestQueryValidation: + def test_missing_mode_returns_400(self, client): + response = client.post( + "/api/material-trace/query", + data=json.dumps({"values": ["LOT-A"]}), + content_type="application/json", + ) + assert response.status_code == 400 + payload = response.get_json() + assert payload["success"] is False + assert "無效的查詢模式" in payload["error"] + + def test_invalid_mode_returns_400(self, client): + response = client.post( + "/api/material-trace/query", + data=json.dumps({"mode": "invalid", "values": ["LOT-A"]}), + content_type="application/json", + ) + assert response.status_code == 400 + assert "無效的查詢模式" in response.get_json()["error"] + + def test_empty_values_returns_400(self, client): + response = client.post( + "/api/material-trace/query", + data=json.dumps({"mode": "lot", "values": []}), + content_type="application/json", + ) + assert response.status_code == 400 + assert "請輸入至少一筆" in response.get_json()["error"] + + def test_blank_values_returns_400(self, client): + response = client.post( + "/api/material-trace/query", + data=json.dumps({"mode": "lot", "values": ["", " "]}), + content_type="application/json", + ) + assert response.status_code == 400 + assert "請輸入至少一筆" in response.get_json()["error"] + + def test_forward_over_200_returns_400(self, client): + values = [f"LOT-{i}" for i in range(201)] + response = client.post( + "/api/material-trace/query", + data=json.dumps({"mode": "lot", "values": values}), + content_type="application/json", + ) + assert response.status_code == 400 + assert "正向查詢上限 200 筆" in response.get_json()["error"] + + def test_workorder_over_200_returns_400(self, client): + values = [f"WO-{i}" for i in range(201)] + response = client.post( + "/api/material-trace/query", + data=json.dumps({"mode": "workorder", "values": values}), + content_type="application/json", + ) + assert response.status_code == 400 + assert "正向查詢上限 200 筆" in response.get_json()["error"] + + def test_reverse_over_50_returns_400(self, client): + values = [f"MLOT-{i}" for i in range(51)] + response = client.post( + "/api/material-trace/query", + data=json.dumps({"mode": "material_lot", "values": values}), + content_type="application/json", + ) + assert response.status_code == 400 + assert "反向查詢上限 50 筆" in response.get_json()["error"] + + def test_non_json_returns_415(self, client): + response = client.post( + "/api/material-trace/query", + data="plain text", + content_type="text/plain", + ) + assert response.status_code == 415 + + def test_empty_body_returns_400(self, client): + response = client.post( + "/api/material-trace/query", + data=json.dumps({}), + content_type="application/json", + ) + # Empty object triggers require_non_empty_object + assert response.status_code in (400, 415) + + +# ============================================================ +# 7.7 Query endpoint — correct pagination structure +# ============================================================ + + +class TestQueryPagination: + @patch("mes_dashboard.routes.material_trace_routes.forward_query") + def test_query_returns_pagination_structure(self, mock_fwd, client): + mock_fwd.return_value = { + "rows": [{"CONTAINERNAME": "LOT-1", "PJ_WORKORDER": "WO-1"}], + "pagination": { + "page": 1, + "per_page": 50, + "total": 100, + "total_pages": 2, + }, + "meta": {}, + } + + response = client.post( + "/api/material-trace/query", + data=json.dumps({"mode": "workorder", "values": ["WO-001"]}), + content_type="application/json", + ) + + assert response.status_code == 200 + payload = response.get_json() + assert payload["success"] is True + assert "pagination" in payload + pag = payload["pagination"] + assert pag["page"] == 1 + assert pag["per_page"] == 50 + assert pag["total"] == 100 + assert pag["total_pages"] == 2 + assert len(payload["rows"]) == 1 + + @patch("mes_dashboard.routes.material_trace_routes.forward_query") + def test_query_passes_page_param(self, mock_fwd, client): + mock_fwd.return_value = { + "rows": [], + "pagination": {"page": 3, "per_page": 50, "total": 200, "total_pages": 4}, + "meta": {}, + } + + response = client.post( + "/api/material-trace/query", + data=json.dumps({"mode": "workorder", "values": ["WO-001"], "page": 3}), + content_type="application/json", + ) + + assert response.status_code == 200 + mock_fwd.assert_called_once() + call_kwargs = mock_fwd.call_args + # page should be 3 + assert call_kwargs[0][3] == 3 or call_kwargs.kwargs.get("page") == 3 + + @patch("mes_dashboard.routes.material_trace_routes.reverse_query") + def test_reverse_mode_dispatches_correctly(self, mock_rev, client): + mock_rev.return_value = { + "rows": [], + "pagination": {"page": 1, "per_page": 50, "total": 0, "total_pages": 0}, + "meta": {}, + } + + response = client.post( + "/api/material-trace/query", + data=json.dumps({"mode": "material_lot", "values": ["MLOT-A"]}), + content_type="application/json", + ) + + assert response.status_code == 200 + mock_rev.assert_called_once() + + +# ============================================================ +# 7.8 Export endpoint — CSV content-type and UTF-8 BOM +# ============================================================ + + +class TestExportEndpoint: + @patch("mes_dashboard.routes.material_trace_routes.export_csv") + def test_export_returns_csv_content_type(self, mock_export, client): + csv_content = b"\xef\xbb\xbfLOT ID,\xe5\xb7\xa5\xe5\x96\xae\n" + mock_export.return_value = (csv_content, {}) + + response = client.post( + "/api/material-trace/export", + data=json.dumps({"mode": "workorder", "values": ["WO-001"]}), + content_type="application/json", + ) + + assert response.status_code == 200 + assert "text/csv" in response.content_type + # Check UTF-8 BOM + assert response.data[:3] == b"\xef\xbb\xbf" + + @patch("mes_dashboard.routes.material_trace_routes.export_csv") + def test_export_truncated_sets_header(self, mock_export, client): + csv_content = b"\xef\xbb\xbfheader\nrow\n" + mock_export.return_value = (csv_content, {"truncated": True, "export_max_rows": 50000}) + + response = client.post( + "/api/material-trace/export", + data=json.dumps({"mode": "workorder", "values": ["WO-001"]}), + content_type="application/json", + ) + + assert response.status_code == 200 + assert response.headers.get("X-Truncated") == "true" + + def test_export_validation_same_as_query(self, client): + """Export should reject invalid mode same as query.""" + response = client.post( + "/api/material-trace/export", + data=json.dumps({"mode": "invalid", "values": ["X"]}), + content_type="application/json", + ) + assert response.status_code == 400 + + +# ============================================================ +# Filter options endpoint +# ============================================================ + + +class TestFilterOptions: + @patch("mes_dashboard.routes.material_trace_routes.get_workcenter_groups") + def test_filter_options_returns_groups(self, mock_groups, client): + mock_groups.return_value = [ + {"name": "焊接_DB", "sequence": 1}, + {"name": "焊線_WB", "sequence": 2}, + ] + + response = client.get("/api/material-trace/filter-options") + + assert response.status_code == 200 + payload = response.get_json() + assert payload["success"] is True + assert payload["data"]["workcenter_groups"] == ["焊接_DB", "焊線_WB"] + + @patch("mes_dashboard.routes.material_trace_routes.get_workcenter_groups") + def test_filter_options_unavailable_returns_503(self, mock_groups, client): + mock_groups.return_value = None + + response = client.get("/api/material-trace/filter-options") + + assert response.status_code == 503 diff --git a/tests/test_material_trace_service.py b/tests/test_material_trace_service.py new file mode 100644 index 0000000..1ddd0c7 --- /dev/null +++ b/tests/test_material_trace_service.py @@ -0,0 +1,350 @@ +# -*- coding: utf-8 -*- +"""Unit tests for material_trace_service. + +Tests cover forward/reverse query logic, CONTAINERID resolution, +workcenter group enrichment/filtering, truncation, CSV export, +safeguards (memory guard, batched queries), and wildcard support. +""" + +import pytest +from unittest.mock import patch, MagicMock, call + +import pandas as pd + +from mes_dashboard.services.material_trace_service import ( + _add_exact_or_pattern_condition, + _enrich_workcenter_group, + _is_pattern_token, + _resolve_container_ids, + _resolve_workcenter_names, + _check_memory_guard, + _IN_BATCH_SIZE, + export_csv, + forward_query, + reverse_query, +) +from mes_dashboard.sql import QueryBuilder + + +# ============================================================ +# Fixtures +# ============================================================ + +MOCK_WORKCENTER_MAPPING = { + "WC_DB_1": {"group": "焊接_DB", "sequence": 1}, + "WC_DB_2": {"group": "焊接_DB", "sequence": 1}, + "WC_WB_1": {"group": "焊線_WB", "sequence": 2}, + "WC_MOLD_1": {"group": "封膠_Mold", "sequence": 3}, +} + + +def _make_material_df(n=3, workcenter="WC_DB_1"): + """Create a sample material consumption DataFrame.""" + return pd.DataFrame( + { + "CONTAINERID": [f"CID{i:016d}" for i in range(n)], + "CONTAINERNAME": [f"LOT-{i:04d}" for i in range(n)], + "PJ_WORKORDER": [f"WO-{i}" for i in range(n)], + "WORKCENTERNAME": [workcenter] * n, + "MATERIALPARTNAME": [f"MAT-{i}" for i in range(n)], + "MATERIALLOTNAME": [f"MLOT-{i}" for i in range(n)], + "VENDORLOTNUMBER": [f"VL-{i}" for i in range(n)], + "QTYREQUIRED": [10.0] * n, + "QTYCONSUMED": [9.5] * n, + "EQUIPMENTNAME": [f"EQ-{i}" for i in range(n)], + "TXNDATE": ["2025-06-01"] * n, + "PRIMARY_CATEGORY": ["CAT_A"] * n, + "SECONDARY_CATEGORY": ["SUB_1"] * n, + } + ) + + +def _make_resolve_df(lot_names): + """Create a DataFrame simulating DW_MES_CONTAINER resolve result.""" + rows = [] + for name in lot_names: + rows.append({"CONTAINERID": f"CID_{name}", "CONTAINERNAME": name}) + return pd.DataFrame(rows) + + +# ============================================================ +# 7.1 Forward LOT mode — resolve + enrichment +# ============================================================ + + +class TestForwardLotQuery: + @patch("mes_dashboard.services.material_trace_service.read_sql_df_slow") + @patch("mes_dashboard.services.material_trace_service.read_sql_df") + @patch("mes_dashboard.services.material_trace_service.get_workcenter_mapping") + def test_forward_lot_resolves_and_enriches(self, mock_mapping, mock_sql, mock_sql_slow): + mock_mapping.return_value = MOCK_WORKCENTER_MAPPING + mock_sql.return_value = _make_resolve_df(["LOT-A", "LOT-B"]) + mock_sql_slow.return_value = _make_material_df(5) + + result = forward_query("lot", ["LOT-A", "LOT-B"], page=1, per_page=50) + + assert result["pagination"]["total"] == 5 + assert len(result["rows"]) == 5 + assert result["rows"][0]["WORKCENTER_GROUP"] == "焊接_DB" + assert result["meta"] == {} + assert mock_sql.call_count == 1 + assert mock_sql_slow.call_count == 1 + + @patch("mes_dashboard.services.material_trace_service.read_sql_df_slow") + @patch("mes_dashboard.services.material_trace_service.read_sql_df") + @patch("mes_dashboard.services.material_trace_service.get_workcenter_mapping") + def test_forward_lot_all_unresolved_returns_empty(self, mock_mapping, mock_sql, mock_sql_slow): + mock_mapping.return_value = MOCK_WORKCENTER_MAPPING + mock_sql.return_value = pd.DataFrame() + + result = forward_query("lot", ["UNKNOWN-LOT"], page=1, per_page=50) + + assert result["rows"] == [] + assert result["pagination"]["total"] == 0 + assert result["meta"]["unresolved"] == ["UNKNOWN-LOT"] + mock_sql_slow.assert_not_called() + + +# ============================================================ +# 7.2 Forward work order mode +# ============================================================ + + +class TestForwardWorkorderQuery: + @patch("mes_dashboard.services.material_trace_service.read_sql_df_slow") + @patch("mes_dashboard.services.material_trace_service.get_workcenter_mapping") + def test_forward_workorder_queries_directly(self, mock_mapping, mock_sql_slow): + mock_mapping.return_value = MOCK_WORKCENTER_MAPPING + mock_sql_slow.return_value = _make_material_df(3) + + result = forward_query("workorder", ["WO-2025-001"], page=1, per_page=50) + + assert result["pagination"]["total"] == 3 + assert len(result["rows"]) == 3 + assert mock_sql_slow.call_count == 1 + + +# ============================================================ +# 7.3 Reverse query — truncation logic +# ============================================================ + + +class TestReverseQuery: + @patch("mes_dashboard.services.material_trace_service.read_sql_df_slow") + @patch("mes_dashboard.services.material_trace_service.get_workcenter_mapping") + def test_reverse_truncation_at_10000(self, mock_mapping, mock_sql_slow): + mock_mapping.return_value = MOCK_WORKCENTER_MAPPING + mock_sql_slow.return_value = _make_material_df(10001) + + result = reverse_query(["MLOT-A"], page=1, per_page=50) + + assert result["meta"]["truncated"] is True + assert result["meta"]["max_rows"] == 10000 + assert result["pagination"]["total"] == 10000 + + @patch("mes_dashboard.services.material_trace_service.read_sql_df_slow") + @patch("mes_dashboard.services.material_trace_service.get_workcenter_mapping") + def test_reverse_no_truncation_under_limit(self, mock_mapping, mock_sql_slow): + mock_mapping.return_value = MOCK_WORKCENTER_MAPPING + mock_sql_slow.return_value = _make_material_df(500) + + result = reverse_query(["MLOT-A"], page=1, per_page=50) + + assert "truncated" not in result["meta"] + assert result["pagination"]["total"] == 500 + + +# ============================================================ +# 7.4 Workcenter group filtering +# ============================================================ + + +class TestWorkcenterGroupFilter: + @patch("mes_dashboard.services.material_trace_service.read_sql_df_slow") + @patch("mes_dashboard.services.material_trace_service.get_workcenters_for_groups") + @patch("mes_dashboard.services.material_trace_service.get_workcenter_mapping") + def test_workcenter_group_resolves_to_names(self, mock_mapping, mock_for_groups, mock_sql_slow): + mock_mapping.return_value = MOCK_WORKCENTER_MAPPING + mock_for_groups.return_value = ["WC_DB_1", "WC_DB_2"] + mock_sql_slow.return_value = _make_material_df(3) + + result = forward_query( + "workorder", ["WO-001"], workcenter_groups=["焊接_DB"], page=1, per_page=50 + ) + + mock_for_groups.assert_called_once_with(["焊接_DB"]) + sql_call = mock_sql_slow.call_args + sql_text = sql_call[0][0] + assert "WORKCENTERNAME IN" in sql_text + assert result["pagination"]["total"] == 3 + + +# ============================================================ +# 7.5 Unresolved LOT IDs +# ============================================================ + + +class TestUnresolvedLots: + @patch("mes_dashboard.services.material_trace_service.read_sql_df_slow") + @patch("mes_dashboard.services.material_trace_service.read_sql_df") + @patch("mes_dashboard.services.material_trace_service.get_workcenter_mapping") + def test_partial_resolve_reports_unresolved(self, mock_mapping, mock_sql, mock_sql_slow): + mock_mapping.return_value = MOCK_WORKCENTER_MAPPING + resolve_df = pd.DataFrame( + [{"CONTAINERID": "CID_LOT_A", "CONTAINERNAME": "LOT-A"}] + ) + mock_sql.return_value = resolve_df + mock_sql_slow.return_value = _make_material_df(2) + + result = forward_query("lot", ["LOT-A", "LOT-B"], page=1, per_page=50) + + assert result["meta"]["unresolved"] == ["LOT-B"] + assert result["pagination"]["total"] == 2 + + +# ============================================================ +# Enrichment helper +# ============================================================ + + +class TestEnrichWorkcenterGroup: + def test_enrich_maps_correctly(self): + df = pd.DataFrame({"WORKCENTERNAME": ["WC_DB_1", "WC_WB_1", "UNKNOWN"]}) + with patch( + "mes_dashboard.services.material_trace_service.get_workcenter_mapping" + ) as mock: + mock.return_value = MOCK_WORKCENTER_MAPPING + result = _enrich_workcenter_group(df) + + assert list(result["WORKCENTER_GROUP"]) == ["焊接_DB", "焊線_WB", ""] + + +# ============================================================ +# CSV export +# ============================================================ + + +class TestExportCsv: + @patch("mes_dashboard.services.material_trace_service.read_sql_df_slow") + @patch("mes_dashboard.services.material_trace_service.get_workcenter_mapping") + def test_export_returns_utf8_bom_csv(self, mock_mapping, mock_sql_slow): + mock_mapping.return_value = MOCK_WORKCENTER_MAPPING + mock_sql_slow.return_value = _make_material_df(3) + + csv_bytes, meta = export_csv("workorder", ["WO-001"]) + + assert csv_bytes[:3] == b"\xef\xbb\xbf" + csv_text = csv_bytes.decode("utf-8-sig") + assert "LOT ID" in csv_text + assert "料號" in csv_text + lines = csv_text.strip().split("\n") + assert len(lines) == 4 + + +# ============================================================ +# Safeguards: memory guard +# ============================================================ + + +class TestMemoryGuard: + def test_memory_guard_raises_on_large_df(self): + with patch("mes_dashboard.services.material_trace_service._MAX_RESULT_MB", 0): + df = _make_material_df(10) + with pytest.raises(MemoryError, match="超過.*上限"): + _check_memory_guard(df) + + def test_memory_guard_passes_small_df(self): + df = _make_material_df(5) + _check_memory_guard(df) + + +# ============================================================ +# Safeguards: IN-clause batching +# ============================================================ + + +class TestInClauseBatching: + @patch("mes_dashboard.services.material_trace_service.read_sql_df_slow") + @patch("mes_dashboard.services.material_trace_service.get_workcenter_mapping") + def test_large_input_is_batched(self, mock_mapping, mock_sql_slow): + mock_mapping.return_value = MOCK_WORKCENTER_MAPPING + mock_sql_slow.return_value = _make_material_df(3) + + values = [f"WO-{i}" for i in range(1500)] + result = forward_query("workorder", values, page=1, per_page=50) + + assert mock_sql_slow.call_count == 2 + assert result["pagination"]["total"] > 0 + + +# ============================================================ +# Wildcard support +# ============================================================ + + +class TestWildcardHelpers: + def test_is_pattern_token_with_star(self): + assert _is_pattern_token("GA250605*") is True + + def test_is_pattern_token_with_percent(self): + assert _is_pattern_token("GA250605%") is True + + def test_is_pattern_token_exact(self): + assert _is_pattern_token("GA25060001-A01") is False + + def test_add_exact_or_pattern_mixed(self): + """Mixed exact + wildcard values produce IN + LIKE conditions.""" + builder = QueryBuilder(base_sql="SELECT 1 FROM t {{ WHERE_CLAUSE }}") + _add_exact_or_pattern_condition(builder, "col", ["EXACT-1", "WILD*"]) + sql, params = builder.build() + assert "IN" in sql + assert "LIKE" in sql + # Wildcard normalized: * → % + like_params = [v for v in params.values() if "%" in str(v)] + assert len(like_params) == 1 + assert like_params[0] == "WILD%" + + +class TestWildcardResolve: + @patch("mes_dashboard.services.material_trace_service.read_sql_df") + def test_wildcard_resolve_generates_like(self, mock_sql): + """Wildcard LOT names produce LIKE clause in resolve SQL.""" + mock_sql.return_value = _make_resolve_df(["LOT-A001"]) + + _resolve_container_ids(["LOT-A*"]) + + sql_text = mock_sql.call_args[0][0] + assert "LIKE" in sql_text + + @patch("mes_dashboard.services.material_trace_service.read_sql_df") + def test_wildcard_not_reported_as_unresolved(self, mock_sql): + """Wildcard tokens that match 0 rows should NOT appear in unresolved.""" + mock_sql.return_value = pd.DataFrame() + + _, _, unresolved = _resolve_container_ids(["WILD*"]) + + # Wildcard tokens are not counted as unresolved + assert unresolved == [] + + @patch("mes_dashboard.services.material_trace_service.read_sql_df") + def test_exact_unresolved_still_reported(self, mock_sql): + """Exact tokens that don't resolve ARE reported as unresolved.""" + mock_sql.return_value = pd.DataFrame() + + _, _, unresolved = _resolve_container_ids(["EXACT-MISSING"]) + + assert unresolved == ["EXACT-MISSING"] + + +class TestWildcardWorkorder: + @patch("mes_dashboard.services.material_trace_service.read_sql_df_slow") + @patch("mes_dashboard.services.material_trace_service.get_workcenter_mapping") + def test_workorder_wildcard_generates_like(self, mock_mapping, mock_sql_slow): + """Wildcard work orders produce LIKE clause in query SQL.""" + mock_mapping.return_value = MOCK_WORKCENTER_MAPPING + mock_sql_slow.return_value = _make_material_df(3) + + forward_query("workorder", ["WO-2025*"], page=1, per_page=50) + + sql_text = mock_sql_slow.call_args[0][0] + assert "LIKE" in sql_text