diff --git a/frontend/src/reject-history/style.css b/frontend/src/reject-history/style.css
index 6615004..7fe31ec 100644
--- a/frontend/src/reject-history/style.css
+++ b/frontend/src/reject-history/style.css
@@ -41,6 +41,19 @@
line-height: 1.5;
}
+.container-label-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.container-type-select {
+ width: auto;
+ min-width: 120px;
+ max-width: 180px;
+}
+
.supplementary-panel {
border-top: 1px solid var(--border);
padding: 16px 18px;
@@ -119,6 +132,15 @@
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: repeat(4, minmax(0, 1fr));
diff --git a/openspec/changes/archive/2026-03-03-fix-silent-data-loss-reject-history/.openspec.yaml b/openspec/changes/archive/2026-03-03-fix-silent-data-loss-reject-history/.openspec.yaml
new file mode 100644
index 0000000..85cf50d
--- /dev/null
+++ b/openspec/changes/archive/2026-03-03-fix-silent-data-loss-reject-history/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-03-03
diff --git a/openspec/changes/archive/2026-03-03-fix-silent-data-loss-reject-history/design.md b/openspec/changes/archive/2026-03-03-fix-silent-data-loss-reject-history/design.md
new file mode 100644
index 0000000..f534af3
--- /dev/null
+++ b/openspec/changes/archive/2026-03-03-fix-silent-data-loss-reject-history/design.md
@@ -0,0 +1,80 @@
+## Context
+
+報廢歷史查詢使用 `BatchQueryEngine` 將長日期範圍拆成 10 天 chunks 平行查詢 Oracle。每個 chunk 有記憶體上限(256 MB)和 timeout(300s)防護。當 chunk 失敗時,`has_partial_failure` 旗標寫入 Redis HSET(key: `batch:reject:{hash}:meta`),但此資訊**在三個斷點被丟失**:
+
+1. `reject_dataset_cache.py` 的 `execute_primary_query()` 未讀取 batch progress metadata
+2. API route 直接 `jsonify({"success": True, **result})`,在 partial chunk failure 路徑下仍回 HTTP 200 + `success: true`,不區分完整與不完整結果
+3. 前端 `App.vue` 沒有任何 partial failure 處理邏輯
+
+另一個問題:`redis_clear_batch()` 在 `execute_primary_query()` 的清理階段會刪除 metadata key,所以讀取必須在清理之前。
+
+前端的 730 天日期上限驗證只在後端 `_validate_range()` 做,前端缺乏即時回饋。
+
+## Goals / Non-Goals
+
+**Goals:**
+
+- 將 `has_partial_failure` 從 Redis metadata 傳遞到 API response `meta` 欄位
+- 追蹤失敗 chunk 的時間範圍,讓前端可顯示具體的缺漏區間
+- 前端顯示 amber warning banner,告知使用者資料可能不完整
+- 前端加入日期範圍即時驗證,避免無效 API 請求
+- 對 transient error(Oracle timeout、連線失敗)加入單次重試,減少不必要的 partial failure
+- 持久化 partial failure 旗標到獨立 Redis key,讓 cache-hit 路徑也能還原警告狀態
+
+**Non-Goals:**
+
+- 不改變現有 chunk 分片策略或記憶體上限數值
+- 不實作前端的自動重查/重試機制
+- 不修改 `EVENT_FETCHER_ALLOW_PARTIAL_RESULTS` 的行為(預設已是安全的 false)
+- 不加入 progress bar / 即時進度追蹤 UI
+
+## Decisions
+
+### D1: 在 `redis_clear_batch` 之前讀取 metadata
+
+**決定**: 在 `execute_primary_query()` 中,`merge_chunks()` 之後、`redis_clear_batch()` 之前,呼叫 `get_batch_progress("reject", engine_hash)` 讀取 partial failure 狀態。
+
+**理由**: `redis_clear_batch` 會刪除包含 metadata 的 key,之後就讀不到了。此時 chunk 資料已合併完成,是最後可讀取 metadata 的時機點。
+
+### D2: 用獨立 Redis key 持久化 partial failure flag,TTL 對齊實際資料層
+
+**決定**: 在 `_store_query_result()` 之後,將 partial failure 資訊存到 `reject_dataset:{query_id}:partial_failure` Redis HSET。**TTL 必須與資料實際存活的層一致**:若資料 spill 到 parquet spool(`_REJECT_ENGINE_SPOOL_TTL_SECONDS = 21600s`),partial failure flag 的 TTL 也要用 21600s;若資料存在 L1/L2(`_CACHE_TTL = 900s`),flag TTL 用 900s。實作方式:`_store_partial_failure_flag()` 接受 `ttl` 參數,由呼叫端根據 `should_spill` 判斷傳入 `_REJECT_ENGINE_SPOOL_TTL_SECONDS` 或 `_CACHE_TTL`。Cache-hit 路徑透過 `_load_partial_failure_flag(query_id)` 還原。
+
+**替代方案 A**: 將 flag 嵌入 DataFrame 的 attrs 或另外 pickle。
+**為何不採用**: DataFrame attrs 在 parquet 序列化時會丟失;pickle 增加反序列化風險。
+
+**替代方案 B**: 固定 TTL=900s。
+**為何不採用**: 大查詢 spill 到 parquet spool(21600s TTL),資料還能讀 6 小時,但 partial failure flag 15 分鐘就過期,造成「資料讀得到但警告消失」。
+
+### D3: 在 `_update_progress` 中追蹤 failed_ranges(僅 time-range chunk)
+
+**決定**: 擴充 `_update_progress()` 接受 `failed_ranges: Optional[List[Dict]]` 參數,以 JSON 字串存入 Redis HSET。Sequential 和 parallel path 均從失敗的 chunk descriptor 提取 `chunk_start` / `chunk_end`。**僅當 chunk descriptor 包含 `chunk_start`/`chunk_end` 時才記錄**(即 `decompose_by_time_range` 產生的 time-range chunk)。
+
+**container-id 分塊的情境**: reject 的 container 模式使用 `decompose_by_ids()`,chunk 結構為 `{"ids": [...]}` 不含日期範圍。此時 `failed_ranges` 為空 list,前端透過 `failed_chunk_count > 0` 顯示 generic 警告訊息(「N 個查詢批次的資料擷取失敗」),不含日期區間。
+
+**理由**: chunk descriptor 的結構由 decompose 函式決定,engine 層不應假設所有 chunk 都有時間範圍。
+
+### D4: Memory guard 失敗不重試
+
+**決定**: `_execute_single_chunk()` 加入 `max_retries=1`,但只對 `_is_retryable_error()` 回傳 true 的 exception 重試。Memory guard(記憶體超限)和 Redis store 失敗直接 return False,不重試。
+
+**理由**: Memory guard 代表該時段資料量確實過大,重試結果相同;Oracle timeout 和連線錯誤則可能是暫態問題。
+
+### D5: 前端 warning banner 使用既有 amber 色系
+
+**決定**: 新增 `.warning-banner` CSS class,使用 `background: #fffbeb; color: #b45309`,與既有 `.resolution-warn` 的 amber 色系一致。放在 `.error-banner` 之後。
+
+**替代方案**: 使用 toast/notification 元件。
+**為何不採用**: 此專案無 toast 系統,amber banner 與 red error-banner 模式統一。
+
+### D6: 前端日期驗證函式放在共用 filters module
+
+**決定**: 在 `frontend/src/core/reject-history-filters.js` 新增 `validateDateRange()`,複用 `resource-history/App.vue:231-248` 的驗證模式。
+
+**理由**: reject-history-filters.js 已是此頁面的 filter 工具模組,validateDateRange 屬於 filter 驗證邏輯。
+
+## Risks / Trade-offs
+
+- **[中] 重試邏輯影響所有 execute_plan 呼叫端** — `_execute_single_chunk()` 是 shared function,被 reject / hold / resource / job / msd 五個服務共用。重試邏輯為加法行為(新增 retry loop 包在既有 try/except 外),成功路徑不變。→ 需要對其他 4 個服務執行 smoke test(既有測試通過即可)。若需更保守,可加入 `max_retries` 參數讓呼叫端控制(預設 1),但目前判斷統一重試對所有服務都是正面效果。
+- **[低] 重試增加 Oracle 負擔** — 單次重試最多增加 1 倍的失敗查詢量。→ 透過 `_is_retryable_error()` 嚴格過濾,只重試 transient error,且 parallel path 最多 3 worker,影響可控。
+- **[低] failed_ranges JSON 大小** — 理論上 73 chunks(730/10)全部失敗會產生 73 筆 range,JSON < 5 KB。→ 遠低於 Redis HSET 欄位限制。
diff --git a/openspec/changes/archive/2026-03-03-fix-silent-data-loss-reject-history/proposal.md b/openspec/changes/archive/2026-03-03-fix-silent-data-loss-reject-history/proposal.md
new file mode 100644
index 0000000..ed079e2
--- /dev/null
+++ b/openspec/changes/archive/2026-03-03-fix-silent-data-loss-reject-history/proposal.md
@@ -0,0 +1,34 @@
+## Why
+
+報廢歷史查詢的防爆機制(時間分片 + 記憶體上限 256 MB + Oracle timeout 300s)在 chunk 失敗時會丟棄該 chunk 的資料,`has_partial_failure` 旗標僅寫入 Redis metadata,**從未傳遞到 API response 或前端**。使用者查到不完整資料卻毫不知情,影響決策正確性。此外,730 天日期上限僅在後端驗證,前端無即時提示,導致不必要的等待。
+
+## What Changes
+
+- 後端 `reject_dataset_cache` 在 `execute_plan()` 後讀取 batch progress metadata,將 `has_partial_failure`、失敗 chunk 數量及失敗時間範圍注入 API response `meta` 欄位
+- 後端 `batch_query_engine` 追蹤失敗 chunk 的時間區間描述,寫入 Redis metadata 的 `failed_ranges` 欄位
+- 後端 `_execute_single_chunk()` 對 transient error(Oracle timeout / 連線錯誤)加入單次重試,memory guard 失敗不重試
+- 前端新增 amber warning banner,當 `meta.has_partial_failure` 為 true 時顯示不完整資料警告及失敗的日期區間
+- 前端新增日期範圍即時驗證(730 天上限),在 API 發送前攔截無效範圍
+
+## Capabilities
+
+### New Capabilities
+
+- `batch-query-resilience`: 批次查詢引擎的失敗範圍追蹤、partial failure metadata 傳遞、及 transient error 單次重試機制
+
+### Modified Capabilities
+
+- `reject-history-api`: API response `meta` 新增 `has_partial_failure`、`failed_chunk_count`、`failed_ranges` 欄位,讓前端得知查詢結果完整性
+- `reject-history-page`: 新增 amber warning banner 顯示 partial failure 警告;新增前端日期範圍即時驗證(730 天上限)
+
+## Impact
+
+- **後端服務 — batch_query_engine.py(共用模組,影響所有使用 execute_plan 的服務)**:
+ - 追蹤 failed_ranges + 重試邏輯修改的是 `_execute_single_chunk()`,此函式被 **reject / hold / resource / job / msd** 五個 dataset cache 服務共用
+ - 重試邏輯為加法行為(新增 retry loop),不改變既有成功路徑,對其他服務向後相容
+ - `failed_ranges` 追蹤僅在 chunk descriptor 含 `chunk_start`/`chunk_end` 時才記錄,container-id 分塊(僅 reject container 模式使用)不受影響
+ - 需對 hold / resource / job / msd 執行回歸 smoke test
+- **後端服務 — reject_dataset_cache.py**: 讀取 metadata + 注入 response + 持久化 partial failure flag
+- **前端**: `App.vue`(warning banner + 日期驗證)、`reject-history-filters.js`(validateDateRange 函式)、`style.css`(.warning-banner 樣式)
+- **API 契約**: response `meta` 新增可選欄位(向後相容,現有前端不受影響)
+- **測試**: `test_batch_query_engine.py`、`test_reject_dataset_cache.py` 需新增對應測試案例;hold / resource / job / msd 需回歸驗證
diff --git a/openspec/changes/archive/2026-03-03-fix-silent-data-loss-reject-history/specs/batch-query-resilience/spec.md b/openspec/changes/archive/2026-03-03-fix-silent-data-loss-reject-history/specs/batch-query-resilience/spec.md
new file mode 100644
index 0000000..42fe558
--- /dev/null
+++ b/openspec/changes/archive/2026-03-03-fix-silent-data-loss-reject-history/specs/batch-query-resilience/spec.md
@@ -0,0 +1,82 @@
+## ADDED Requirements
+
+### Requirement: BatchQueryEngine SHALL track failed chunk time ranges in progress metadata
+The engine SHALL record the time ranges of failed chunks in Redis progress metadata so consumers can report which date intervals have missing data.
+
+#### Scenario: Failed chunk range recorded in sequential path
+- **WHEN** a chunk with `chunk_start` and `chunk_end` keys fails during sequential execution
+- **THEN** `_update_progress()` SHALL store a `failed_ranges` field in the Redis HSET metadata
+- **THEN** `failed_ranges` SHALL be a JSON array of objects, each with `start` and `end` string keys
+- **THEN** the array SHALL contain one entry per failed chunk
+
+#### Scenario: Failed chunk range recorded in parallel path
+- **WHEN** a chunk with `chunk_start` and `chunk_end` keys fails during parallel execution
+- **THEN** the failed chunk's time range SHALL be appended to `failed_ranges` in the same format as the sequential path
+
+#### Scenario: No failed ranges when all chunks succeed
+- **WHEN** all chunks complete successfully
+- **THEN** the `failed_ranges` field SHALL NOT be present in Redis metadata
+
+#### Scenario: ID-batch chunks produce no failed_ranges entries
+- **WHEN** a chunk created by `decompose_by_ids()` (containing only an `ids` key, no `chunk_start`/`chunk_end`) fails
+- **THEN** no entry SHALL be appended to `failed_ranges` for that chunk
+- **THEN** `has_partial_failure` SHALL still be set to `True`
+- **THEN** `failed` count SHALL still be incremented
+
+#### Scenario: get_batch_progress returns failed_ranges
+- **WHEN** `get_batch_progress()` is called after execution with failed chunks
+- **THEN** the returned dict SHALL include `failed_ranges` as a JSON string parseable to a list of `{start, end}` objects
+
+### Requirement: BatchQueryEngine SHALL retry transient chunk failures once
+The engine SHALL retry chunk execution once for transient errors (Oracle timeout, connection errors) but SHALL NOT retry deterministic failures (memory guard, Redis store).
+
+#### Scenario: Oracle timeout retried once
+- **WHEN** `_execute_single_chunk()` raises an exception matching Oracle timeout patterns (`DPY-4024`, `ORA-01013`)
+- **THEN** the chunk SHALL be retried exactly once
+- **WHEN** the retry succeeds
+- **THEN** the chunk SHALL be marked as successful
+
+#### Scenario: Connection error retried once
+- **WHEN** `_execute_single_chunk()` raises `TimeoutError`, `ConnectionError`, or `OSError`
+- **THEN** the chunk SHALL be retried exactly once
+
+#### Scenario: Retry exhausted marks chunk as failed
+- **WHEN** a chunk fails on both the initial attempt and the retry
+- **THEN** the chunk SHALL be marked as failed
+- **THEN** `has_partial_failure` SHALL be set to `True`
+
+#### Scenario: Memory guard failure NOT retried
+- **WHEN** a chunk's DataFrame exceeds `BATCH_CHUNK_MAX_MEMORY_MB`
+- **THEN** the chunk SHALL return `False` immediately without retry
+- **THEN** the query function SHALL have been called exactly once for that chunk
+
+#### Scenario: Redis store failure NOT retried
+- **WHEN** `redis_store_chunk()` returns `False`
+- **THEN** the chunk SHALL return `False` immediately without retry
+
+### Requirement: reject_dataset_cache SHALL propagate partial failure metadata to API response
+The cache service SHALL read batch execution metadata and include partial failure information in the API response `meta` field.
+
+#### Scenario: Partial failure metadata included in response
+- **WHEN** `execute_primary_query()` uses the batch engine path and `get_batch_progress()` returns `has_partial_failure=True`
+- **THEN** the response `meta` dict SHALL include `has_partial_failure: true`
+- **THEN** the response `meta` dict SHALL include `failed_chunk_count` as an integer
+- **THEN** if `failed_ranges` is present, the response `meta` dict SHALL include `failed_ranges` as a list of `{start, end}` objects
+
+#### Scenario: Metadata read before redis_clear_batch
+- **WHEN** `execute_primary_query()` calls `get_batch_progress()`
+- **THEN** the call SHALL occur after `merge_chunks()` and before `redis_clear_batch()`
+
+#### Scenario: No partial failure on successful query
+- **WHEN** all chunks complete successfully
+- **THEN** the response `meta` dict SHALL NOT include `has_partial_failure`
+
+#### Scenario: Cache-hit path restores partial failure flag
+- **WHEN** a cached DataFrame is returned (cache hit) and a partial failure flag was stored during the original query
+- **THEN** the response `meta` dict SHALL include the same `has_partial_failure`, `failed_chunk_count`, and `failed_ranges` as the original response
+
+#### Scenario: Partial failure flag TTL matches data storage layer
+- **WHEN** partial failure is detected and the query result is spilled to parquet spool
+- **THEN** the partial failure flag SHALL be stored with TTL equal to `_REJECT_ENGINE_SPOOL_TTL_SECONDS` (default 21600 seconds)
+- **WHEN** partial failure is detected and the query result is stored in L1/L2 Redis cache
+- **THEN** the partial failure flag SHALL be stored with TTL equal to `_CACHE_TTL` (default 900 seconds)
diff --git a/openspec/changes/archive/2026-03-03-fix-silent-data-loss-reject-history/specs/reject-history-api/spec.md b/openspec/changes/archive/2026-03-03-fix-silent-data-loss-reject-history/specs/reject-history-api/spec.md
new file mode 100644
index 0000000..f7234c5
--- /dev/null
+++ b/openspec/changes/archive/2026-03-03-fix-silent-data-loss-reject-history/specs/reject-history-api/spec.md
@@ -0,0 +1,36 @@
+## MODIFIED Requirements
+
+### Requirement: Reject History API SHALL validate required query parameters
+The API SHALL validate date parameters and basic paging bounds before executing database work.
+
+#### Scenario: Missing required dates
+- **WHEN** a reject-history endpoint requiring date range is called without `start_date` or `end_date`
+- **THEN** the API SHALL return HTTP 400 with a descriptive validation error
+
+#### Scenario: Invalid date order
+- **WHEN** `end_date` is earlier than `start_date`
+- **THEN** the API SHALL return HTTP 400 and SHALL NOT run SQL queries
+
+#### Scenario: Date range exceeds maximum
+- **WHEN** the date range between `start_date` and `end_date` exceeds 730 days
+- **THEN** the API SHALL return HTTP 400 with error message "日期範圍不可超過 730 天"
+
+## ADDED Requirements
+
+### Requirement: Reject History API primary query response SHALL include partial failure metadata
+The primary query endpoint SHALL include batch execution completeness information in the response `meta` field when chunks fail during batch query execution.
+
+#### Scenario: Partial failure metadata in response
+- **WHEN** `POST /api/reject-history/query` completes with some chunks failing
+- **THEN** the response SHALL include `meta.has_partial_failure: true`
+- **THEN** the response SHALL include `meta.failed_chunk_count` as a positive integer
+- **THEN** the response SHALL include `meta.failed_ranges` as an array of `{start, end}` date strings (if available)
+- **THEN** the HTTP status SHALL still be 200 (data is partially available)
+
+#### Scenario: No partial failure metadata on full success
+- **WHEN** `POST /api/reject-history/query` completes with all chunks succeeding
+- **THEN** the response `meta` SHALL NOT include `has_partial_failure`, `failed_chunk_count`, or `failed_ranges`
+
+#### Scenario: Partial failure metadata preserved on cache hit
+- **WHEN** `POST /api/reject-history/query` returns cached data that originally had partial failures
+- **THEN** the response SHALL include the same `meta.has_partial_failure`, `meta.failed_chunk_count`, and `meta.failed_ranges` as the original response
diff --git a/openspec/changes/archive/2026-03-03-fix-silent-data-loss-reject-history/specs/reject-history-page/spec.md b/openspec/changes/archive/2026-03-03-fix-silent-data-loss-reject-history/specs/reject-history-page/spec.md
new file mode 100644
index 0000000..b023fff
--- /dev/null
+++ b/openspec/changes/archive/2026-03-03-fix-silent-data-loss-reject-history/specs/reject-history-page/spec.md
@@ -0,0 +1,58 @@
+## ADDED Requirements
+
+### Requirement: Reject History page SHALL display partial failure warning banner
+The page SHALL display an amber warning banner when the query result contains partial failures, informing users that displayed data may be incomplete.
+
+#### Scenario: Warning banner displayed on partial failure
+- **WHEN** the primary query response includes `meta.has_partial_failure: true`
+- **THEN** an amber warning banner SHALL be displayed below the error banner position
+- **THEN** the warning message SHALL be in Traditional Chinese
+
+#### Scenario: Warning banner shows failed date ranges
+- **WHEN** `meta.failed_ranges` contains date range objects
+- **THEN** the warning banner SHALL display the specific failed date ranges (e.g., "以下日期區間的資料擷取失敗:2025-01-01 ~ 2025-01-10")
+
+#### Scenario: Warning banner shows generic message without ranges (container mode or missing range data)
+- **WHEN** `meta.has_partial_failure` is true but `meta.failed_ranges` is empty or absent (e.g., container-id batch query)
+- **THEN** the warning banner SHALL display a generic message with the failed chunk count (e.g., "3 個查詢批次的資料擷取失敗")
+
+#### Scenario: Warning banner cleared on new query
+- **WHEN** user initiates a new primary query
+- **THEN** the warning banner SHALL be cleared before the new query executes
+- **THEN** if the new query also has partial failures, the warning SHALL update with new failure information
+
+#### Scenario: Warning banner coexists with error banner
+- **WHEN** both an error message and a partial failure warning exist
+- **THEN** the error banner SHALL appear first, followed by the warning banner
+
+#### Scenario: Warning banner visual style
+- **WHEN** the warning banner is rendered
+- **THEN** it SHALL use amber/orange color scheme (background `#fffbeb`, text `#b45309`)
+- **THEN** the style SHALL be consistent with the existing `.resolution-warn` color pattern
+
+### Requirement: Reject History page SHALL validate date range before query submission
+The page SHALL validate the date range on the client side before sending the API request, providing immediate feedback for invalid ranges.
+
+#### Scenario: Date range exceeds 730-day limit
+- **WHEN** user selects a date range exceeding 730 days and clicks "查詢"
+- **THEN** the page SHALL display an error message "查詢範圍不可超過 730 天(約兩年)"
+- **THEN** the API request SHALL NOT be sent
+
+#### Scenario: Missing start or end date
+- **WHEN** user clicks "查詢" without setting both start_date and end_date (in date_range mode)
+- **THEN** the page SHALL display an error message "請先設定開始與結束日期"
+- **THEN** the API request SHALL NOT be sent
+
+#### Scenario: End date before start date
+- **WHEN** user selects an end_date earlier than start_date
+- **THEN** the page SHALL display an error message "結束日期必須大於起始日期"
+- **THEN** the API request SHALL NOT be sent
+
+#### Scenario: Valid date range proceeds normally
+- **WHEN** user selects a valid date range within 730 days and clicks "查詢"
+- **THEN** no validation error SHALL be shown
+- **THEN** the API request SHALL proceed normally
+
+#### Scenario: Container mode skips date validation
+- **WHEN** query mode is "container" (not "date_range")
+- **THEN** date range validation SHALL be skipped
diff --git a/openspec/changes/archive/2026-03-03-fix-silent-data-loss-reject-history/tasks.md b/openspec/changes/archive/2026-03-03-fix-silent-data-loss-reject-history/tasks.md
new file mode 100644
index 0000000..d69a373
--- /dev/null
+++ b/openspec/changes/archive/2026-03-03-fix-silent-data-loss-reject-history/tasks.md
@@ -0,0 +1,46 @@
+## 1. 前端日期範圍即時驗證
+
+- [x] 1.1 在 `frontend/src/core/reject-history-filters.js` 末尾新增 `validateDateRange(startDate, endDate)` 函式(MAX_QUERY_DAYS=730),回傳空字串表示通過、非空字串為錯誤訊息
+- [x] 1.2 在 `frontend/src/reject-history/App.vue` import `validateDateRange`,在 `executePrimaryQuery()` 的 API 呼叫前(`errorMessage.value = ''` 重置之後)加入 date_range 模式的驗證邏輯,驗證失敗時設定 `errorMessage` 並 return
+
+## 2. 後端追蹤失敗 chunk 時間範圍
+
+- [x] 2.1 在 `batch_query_engine.py` 的 `_update_progress()` 簽名加入 `failed_ranges: Optional[List] = None` 參數,在 mapping dict 中條件性加入 `json.dumps(failed_ranges)` 欄位
+- [x] 2.2 在 `execute_plan()` 的 sequential path(`for idx, chunk in enumerate(chunks)` 迴圈區段)新增 `failed_range_list = []`,chunk 失敗時從 chunk descriptor 條件性提取 `chunk_start`/`chunk_end` append 到 list(僅 time-range chunk 才有),傳入每次 `_update_progress()` 呼叫
+- [x] 2.3 在 `_execute_parallel()` 修改 `futures` dict 為 `futures[future] = (idx, chunk)` 以保留 chunk descriptor,新增 `failed_range_list`,失敗時條件性 append range,返回值改為 4-tuple `(completed, failed, has_partial_failure, failed_range_list)`;同步更新 `execute_plan()` 中呼叫 `_execute_parallel()` 的解構為 4-tuple
+
+## 3. 後端 chunk 失敗單次重試
+
+- [x] 3.1 在 `batch_query_engine.py` 新增 `_RETRYABLE_PATTERNS` 常數和 `_is_retryable_error(exc)` 函式,辨識 Oracle timeout / 連線錯誤
+- [x] 3.2 修改 `_execute_single_chunk()` 加入 `max_retries: int = 1` 參數,將 try/except 包在 retry loop 中:memory guard 和 Redis store 失敗直接 return False 不重試;exception 中若 `_is_retryable_error()` 為 True 則 log warning 並 continue
+
+## 4. 後端傳遞 partial failure 到 API response
+
+- [x] 4.1 在 `reject_dataset_cache.py` 的 `execute_primary_query()` 內 batch_query_engine local import 區塊加入 `get_batch_progress`
+- [x] 4.2 在 `execute_primary_query()` 的 `merge_chunks()` 呼叫之後、`redis_clear_batch()` 呼叫之前,呼叫 `get_batch_progress("reject", engine_hash)` 讀取 `has_partial_failure`、`failed`、`failed_ranges`
+- [x] 4.3 在 `redis_clear_batch()` 之後、`_apply_policy_filters()` 之前,將 partial failure 資訊條件性注入 `meta` dict(`has_partial_failure`、`failed_chunk_count`、`failed_ranges`)
+- [x] 4.4 新增 `_store_partial_failure_flag(query_id, failed_count, failed_ranges, ttl)` 和 `_load_partial_failure_flag(query_id)` 兩個 helper,使用 Redis HSET 存取 `reject_dataset:{query_id}:partial_failure`;`ttl` 由呼叫端傳入
+- [x] 4.5 在 `_store_query_result()` 呼叫之後呼叫 `_store_partial_failure_flag()`,TTL 根據 `_store_query_result()` 內的 `should_spill` 判斷:spill 到 spool 時用 `_REJECT_ENGINE_SPOOL_TTL_SECONDS`(21600s),否則用 `_CACHE_TTL`(900s);在 `_get_cached_df()` cache-hit 路徑呼叫 `_load_partial_failure_flag()` 並 `meta.update()`
+
+## 5. 前端 partial failure 警告 banner
+
+- [x] 5.1 在 `frontend/src/reject-history/App.vue` 新增 `partialFailureWarning` ref,在 `executePrimaryQuery()` 開頭重置,在讀取 result 後根據 `result.meta.has_partial_failure` 設定警告訊息(含 failed_ranges 的日期區間文字;無 ranges 時用 failed_chunk_count 的 generic 訊息)
+- [x] 5.2 在 App.vue template 的 error-banner `
` 之後加入 `
{{ partialFailureWarning }}
`
+- [x] 5.3 在 `frontend/src/reject-history/style.css` 的 `.error-banner` 規則之後加入 `.warning-banner` 樣式(background: #fffbeb, color: #b45309)
+
+## 6. 測試
+
+- [x] 6.1 在 `tests/test_batch_query_engine.py` 新增 `test_transient_failure_retried_once`:mock query_fn 第一次 raise TimeoutError、第二次成功,assert chunk 最終成功且 query_fn 被呼叫 2 次
+- [x] 6.2 在 `tests/test_batch_query_engine.py` 新增 `test_memory_guard_not_retried`:mock query_fn 回傳超大 DataFrame,assert query_fn 僅被呼叫 1 次
+- [x] 6.3 在 `tests/test_batch_query_engine.py` 新增 `test_failed_ranges_tracked`:3 chunks 其中 1 個失敗,assert Redis metadata 含 `failed_ranges` JSON
+- [x] 6.4 在 `tests/test_reject_dataset_cache.py` 新增 `test_partial_failure_in_response_meta`:mock `get_batch_progress` 回傳 `has_partial_failure=True`,assert response `meta` 包含旗標和 `failed_ranges`
+- [x] 6.5 在 `tests/test_reject_dataset_cache.py` 新增 `test_cache_hit_restores_partial_failure`:先寫入 partial failure flag,cache hit 時 assert meta 有旗標
+- [x] 6.6 在 `tests/test_reject_dataset_cache.py` 新增 `test_partial_failure_ttl_matches_spool`:當 should_spill=True 時 assert flag TTL 為 `_REJECT_ENGINE_SPOOL_TTL_SECONDS`,否則為 `_CACHE_TTL`
+- [x] 6.7 在 `tests/test_batch_query_engine.py` 新增 `test_id_batch_chunk_no_failed_ranges`:container-id 分塊 chunk 失敗時 assert `failed_ranges` 為空 list 但 `has_partial_failure=True`
+
+## 7. 跨服務回歸驗證
+
+- [x] 7.1 執行 `pytest tests/test_batch_query_engine.py tests/test_reject_dataset_cache.py -v` 確認本次修改的測試全部通過
+- [x] 7.2 執行 hold_dataset_cache 相關測試確認重試邏輯不影響 hold:`pytest tests/ -k "hold" -v`
+- [x] 7.3 執行 resource / job / msd 相關測試確認回歸:`pytest tests/ -k "resource or job or mid_section" -v`
+- [x] 7.4 若任何跨服務測試失敗,檢查是否為 `_execute_single_chunk` 簽名變更(`max_retries` 參數)導致,確認 keyword-only 預設值不影響既有呼叫
diff --git a/openspec/specs/batch-query-resilience/spec.md b/openspec/specs/batch-query-resilience/spec.md
new file mode 100644
index 0000000..ed98b1d
--- /dev/null
+++ b/openspec/specs/batch-query-resilience/spec.md
@@ -0,0 +1,86 @@
+# batch-query-resilience Specification
+
+## Purpose
+Batch query engine resilience features: failed chunk range tracking, transient error retry, and partial failure metadata propagation to API consumers.
+
+## Requirements
+### Requirement: BatchQueryEngine SHALL track failed chunk time ranges in progress metadata
+The engine SHALL record the time ranges of failed chunks in Redis progress metadata so consumers can report which date intervals have missing data.
+
+#### Scenario: Failed chunk range recorded in sequential path
+- **WHEN** a chunk with `chunk_start` and `chunk_end` keys fails during sequential execution
+- **THEN** `_update_progress()` SHALL store a `failed_ranges` field in the Redis HSET metadata
+- **THEN** `failed_ranges` SHALL be a JSON array of objects, each with `start` and `end` string keys
+- **THEN** the array SHALL contain one entry per failed chunk
+
+#### Scenario: Failed chunk range recorded in parallel path
+- **WHEN** a chunk with `chunk_start` and `chunk_end` keys fails during parallel execution
+- **THEN** the failed chunk's time range SHALL be appended to `failed_ranges` in the same format as the sequential path
+
+#### Scenario: No failed ranges when all chunks succeed
+- **WHEN** all chunks complete successfully
+- **THEN** the `failed_ranges` field SHALL NOT be present in Redis metadata
+
+#### Scenario: ID-batch chunks produce no failed_ranges entries
+- **WHEN** a chunk created by `decompose_by_ids()` (containing only an `ids` key, no `chunk_start`/`chunk_end`) fails
+- **THEN** no entry SHALL be appended to `failed_ranges` for that chunk
+- **THEN** `has_partial_failure` SHALL still be set to `True`
+- **THEN** `failed` count SHALL still be incremented
+
+#### Scenario: get_batch_progress returns failed_ranges
+- **WHEN** `get_batch_progress()` is called after execution with failed chunks
+- **THEN** the returned dict SHALL include `failed_ranges` as a JSON string parseable to a list of `{start, end}` objects
+
+### Requirement: BatchQueryEngine SHALL retry transient chunk failures once
+The engine SHALL retry chunk execution once for transient errors (Oracle timeout, connection errors) but SHALL NOT retry deterministic failures (memory guard, Redis store).
+
+#### Scenario: Oracle timeout retried once
+- **WHEN** `_execute_single_chunk()` raises an exception matching Oracle timeout patterns (`DPY-4024`, `ORA-01013`)
+- **THEN** the chunk SHALL be retried exactly once
+- **WHEN** the retry succeeds
+- **THEN** the chunk SHALL be marked as successful
+
+#### Scenario: Connection error retried once
+- **WHEN** `_execute_single_chunk()` raises `TimeoutError`, `ConnectionError`, or `OSError`
+- **THEN** the chunk SHALL be retried exactly once
+
+#### Scenario: Retry exhausted marks chunk as failed
+- **WHEN** a chunk fails on both the initial attempt and the retry
+- **THEN** the chunk SHALL be marked as failed
+- **THEN** `has_partial_failure` SHALL be set to `True`
+
+#### Scenario: Memory guard failure NOT retried
+- **WHEN** a chunk's DataFrame exceeds `BATCH_CHUNK_MAX_MEMORY_MB`
+- **THEN** the chunk SHALL return `False` immediately without retry
+- **THEN** the query function SHALL have been called exactly once for that chunk
+
+#### Scenario: Redis store failure NOT retried
+- **WHEN** `redis_store_chunk()` returns `False`
+- **THEN** the chunk SHALL return `False` immediately without retry
+
+### Requirement: reject_dataset_cache SHALL propagate partial failure metadata to API response
+The cache service SHALL read batch execution metadata and include partial failure information in the API response `meta` field.
+
+#### Scenario: Partial failure metadata included in response
+- **WHEN** `execute_primary_query()` uses the batch engine path and `get_batch_progress()` returns `has_partial_failure=True`
+- **THEN** the response `meta` dict SHALL include `has_partial_failure: true`
+- **THEN** the response `meta` dict SHALL include `failed_chunk_count` as an integer
+- **THEN** if `failed_ranges` is present, the response `meta` dict SHALL include `failed_ranges` as a list of `{start, end}` objects
+
+#### Scenario: Metadata read before redis_clear_batch
+- **WHEN** `execute_primary_query()` calls `get_batch_progress()`
+- **THEN** the call SHALL occur after `merge_chunks()` and before `redis_clear_batch()`
+
+#### Scenario: No partial failure on successful query
+- **WHEN** all chunks complete successfully
+- **THEN** the response `meta` dict SHALL NOT include `has_partial_failure`
+
+#### Scenario: Cache-hit path restores partial failure flag
+- **WHEN** a cached DataFrame is returned (cache hit) and a partial failure flag was stored during the original query
+- **THEN** the response `meta` dict SHALL include the same `has_partial_failure`, `failed_chunk_count`, and `failed_ranges` as the original response
+
+#### Scenario: Partial failure flag TTL matches data storage layer
+- **WHEN** partial failure is detected and the query result is spilled to parquet spool
+- **THEN** the partial failure flag SHALL be stored with TTL equal to `_REJECT_ENGINE_SPOOL_TTL_SECONDS` (default 21600 seconds)
+- **WHEN** partial failure is detected and the query result is stored in L1/L2 Redis cache
+- **THEN** the partial failure flag SHALL be stored with TTL equal to `_CACHE_TTL` (default 900 seconds)
diff --git a/openspec/specs/reject-history-api/spec.md b/openspec/specs/reject-history-api/spec.md
index 2405d0a..0708e44 100644
--- a/openspec/specs/reject-history-api/spec.md
+++ b/openspec/specs/reject-history-api/spec.md
@@ -14,6 +14,28 @@ The API SHALL validate date parameters and basic paging bounds before executing
- **WHEN** `end_date` is earlier than `start_date`
- **THEN** the API SHALL return HTTP 400 and SHALL NOT run SQL queries
+#### Scenario: Date range exceeds maximum
+- **WHEN** the date range between `start_date` and `end_date` exceeds 730 days
+- **THEN** the API SHALL return HTTP 400 with error message "日期範圍不可超過 730 天"
+
+### Requirement: Reject History API primary query response SHALL include partial failure metadata
+The primary query endpoint SHALL include batch execution completeness information in the response `meta` field when chunks fail during batch query execution.
+
+#### Scenario: Partial failure metadata in response
+- **WHEN** `POST /api/reject-history/query` completes with some chunks failing
+- **THEN** the response SHALL include `meta.has_partial_failure: true`
+- **THEN** the response SHALL include `meta.failed_chunk_count` as a positive integer
+- **THEN** the response SHALL include `meta.failed_ranges` as an array of `{start, end}` date strings (if available)
+- **THEN** the HTTP status SHALL still be 200 (data is partially available)
+
+#### Scenario: No partial failure metadata on full success
+- **WHEN** `POST /api/reject-history/query` completes with all chunks succeeding
+- **THEN** the response `meta` SHALL NOT include `has_partial_failure`, `failed_chunk_count`, or `failed_ranges`
+
+#### Scenario: Partial failure metadata preserved on cache hit
+- **WHEN** `POST /api/reject-history/query` returns cached data that originally had partial failures
+- **THEN** the response SHALL include the same `meta.has_partial_failure`, `meta.failed_chunk_count`, and `meta.failed_ranges` as the original response
+
### Requirement: Reject History API SHALL provide summary metrics endpoint
The API SHALL provide aggregated summary metrics for the selected filter context.
diff --git a/openspec/specs/reject-history-page/spec.md b/openspec/specs/reject-history-page/spec.md
index 14b4627..15b9eb2 100644
--- a/openspec/specs/reject-history-page/spec.md
+++ b/openspec/specs/reject-history-page/spec.md
@@ -236,6 +236,63 @@ The page template SHALL delegate sections to focused sub-components, following t
- **THEN** `App.vue` SHALL hold all reactive state and API logic
- **THEN** sub-components SHALL receive data via props and communicate via events
+### Requirement: Reject History page SHALL display partial failure warning banner
+The page SHALL display an amber warning banner when the query result contains partial failures, informing users that displayed data may be incomplete.
+
+#### Scenario: Warning banner displayed on partial failure
+- **WHEN** the primary query response includes `meta.has_partial_failure: true`
+- **THEN** an amber warning banner SHALL be displayed below the error banner position
+- **THEN** the warning message SHALL be in Traditional Chinese
+
+#### Scenario: Warning banner shows failed date ranges
+- **WHEN** `meta.failed_ranges` contains date range objects
+- **THEN** the warning banner SHALL display the specific failed date ranges (e.g., "以下日期區間的資料擷取失敗:2025-01-01 ~ 2025-01-10")
+
+#### Scenario: Warning banner shows generic message without ranges (container mode or missing range data)
+- **WHEN** `meta.has_partial_failure` is true but `meta.failed_ranges` is empty or absent (e.g., container-id batch query)
+- **THEN** the warning banner SHALL display a generic message with the failed chunk count (e.g., "3 個查詢批次的資料擷取失敗")
+
+#### Scenario: Warning banner cleared on new query
+- **WHEN** user initiates a new primary query
+- **THEN** the warning banner SHALL be cleared before the new query executes
+- **THEN** if the new query also has partial failures, the warning SHALL update with new failure information
+
+#### Scenario: Warning banner coexists with error banner
+- **WHEN** both an error message and a partial failure warning exist
+- **THEN** the error banner SHALL appear first, followed by the warning banner
+
+#### Scenario: Warning banner visual style
+- **WHEN** the warning banner is rendered
+- **THEN** it SHALL use amber/orange color scheme (background `#fffbeb`, text `#b45309`)
+- **THEN** the style SHALL be consistent with the existing `.resolution-warn` color pattern
+
+### Requirement: Reject History page SHALL validate date range before query submission
+The page SHALL validate the date range on the client side before sending the API request, providing immediate feedback for invalid ranges.
+
+#### Scenario: Date range exceeds 730-day limit
+- **WHEN** user selects a date range exceeding 730 days and clicks "查詢"
+- **THEN** the page SHALL display an error message "查詢範圍不可超過 730 天(約兩年)"
+- **THEN** the API request SHALL NOT be sent
+
+#### Scenario: Missing start or end date
+- **WHEN** user clicks "查詢" without setting both start_date and end_date (in date_range mode)
+- **THEN** the page SHALL display an error message "請先設定開始與結束日期"
+- **THEN** the API request SHALL NOT be sent
+
+#### Scenario: End date before start date
+- **WHEN** user selects an end_date earlier than start_date
+- **THEN** the page SHALL display an error message "結束日期必須大於起始日期"
+- **THEN** the API request SHALL NOT be sent
+
+#### Scenario: Valid date range proceeds normally
+- **WHEN** user selects a valid date range within 730 days and clicks "查詢"
+- **THEN** no validation error SHALL be shown
+- **THEN** the API request SHALL proceed normally
+
+#### Scenario: Container mode skips date validation
+- **WHEN** query mode is "container" (not "date_range")
+- **THEN** date range validation SHALL be skipped
+
### Requirement: Frontend API timeout
The reject-history page SHALL use a 360-second API timeout (up from 60 seconds) for all Oracle-backed API calls.
diff --git a/src/mes_dashboard/routes/reject_history_routes.py b/src/mes_dashboard/routes/reject_history_routes.py
index dda6cc3..ba1918a 100644
--- a/src/mes_dashboard/routes/reject_history_routes.py
+++ b/src/mes_dashboard/routes/reject_history_routes.py
@@ -11,6 +11,7 @@ from flask import Blueprint, Response, jsonify, request
from mes_dashboard.core.cache import cache_get, cache_set, make_cache_key
from mes_dashboard.core.rate_limit import configured_rate_limit
+from mes_dashboard.core.request_validation import parse_json_payload
from mes_dashboard.core.utils import parse_bool_query
from mes_dashboard.services.reject_dataset_cache import (
apply_view,
@@ -344,7 +345,7 @@ def api_reject_history_reason_pareto():
pareto_scope=pareto_scope,
packages=_parse_multi_param("packages") or None,
workcenter_groups=_parse_multi_param("workcenter_groups") or None,
- reason=request.args.get("reason", "").strip() or None,
+ reasons=_parse_multi_param("reasons") or None,
trend_dates=_parse_multi_param("trend_dates") or None,
include_excluded_scrap=include_excluded_scrap,
exclude_material_scrap=exclude_material_scrap,
@@ -404,7 +405,7 @@ def api_reject_history_batch_pareto():
pareto_display_scope=pareto_display_scope,
packages=_parse_multi_param("packages") or None,
workcenter_groups=_parse_multi_param("workcenter_groups") or None,
- reason=request.args.get("reason", "").strip() or None,
+ reasons=_parse_multi_param("reasons") or None,
trend_dates=_parse_multi_param("trend_dates") or None,
pareto_selections=_parse_multi_pareto_selections(),
include_excluded_scrap=include_excluded_scrap,
@@ -548,7 +549,9 @@ def api_reject_history_analytics():
@reject_history_bp.route("/api/reject-history/query", methods=["POST"])
def api_reject_history_query():
"""Primary query: execute Oracle → cache DataFrame → return results."""
- body = request.get_json(silent=True) or {}
+ 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
mode = str(body.get("mode", "")).strip()
if mode not in ("date_range", "container"):
@@ -599,7 +602,7 @@ def api_reject_history_view():
page = request.args.get("page", 1, type=int) or 1
per_page = request.args.get("per_page", 50, type=int) or 50
metric_filter = request.args.get("metric_filter", "all").strip().lower() or "all"
- reason = request.args.get("reason", "").strip() or None
+ reasons = _parse_multi_param("reasons") or None
detail_reason = request.args.get("detail_reason", "").strip() or None
pareto_selections = _parse_multi_pareto_selections()
pareto_dimension = None
@@ -618,7 +621,7 @@ def api_reject_history_view():
query_id=query_id,
packages=_parse_multi_param("packages") or None,
workcenter_groups=_parse_multi_param("workcenter_groups") or None,
- reason=reason,
+ reasons=reasons,
metric_filter=metric_filter,
trend_dates=_parse_multi_param("trend_dates") or None,
detail_reason=detail_reason,
@@ -653,7 +656,7 @@ def api_reject_history_export_cached():
return jsonify({"success": False, "error": "缺少必要參數: query_id"}), 400
metric_filter = request.args.get("metric_filter", "all").strip().lower() or "all"
- reason = request.args.get("reason", "").strip() or None
+ reasons = _parse_multi_param("reasons") or None
detail_reason = request.args.get("detail_reason", "").strip() or None
pareto_selections = _parse_multi_pareto_selections()
pareto_dimension = None
@@ -672,7 +675,7 @@ def api_reject_history_export_cached():
query_id=query_id,
packages=_parse_multi_param("packages") or None,
workcenter_groups=_parse_multi_param("workcenter_groups") or None,
- reason=reason,
+ reasons=reasons,
metric_filter=metric_filter,
trend_dates=_parse_multi_param("trend_dates") or None,
detail_reason=detail_reason,
diff --git a/src/mes_dashboard/services/batch_query_engine.py b/src/mes_dashboard/services/batch_query_engine.py
index 85306ca..37ed0bf 100644
--- a/src/mes_dashboard/services/batch_query_engine.py
+++ b/src/mes_dashboard/services/batch_query_engine.py
@@ -56,6 +56,18 @@ from mes_dashboard.core.redis_df_store import (
logger = logging.getLogger("mes_dashboard.batch_query_engine")
+
+_RETRYABLE_PATTERNS = (
+ "dpy-4024",
+ "ora-01013",
+ "ora-03113",
+ "ora-03135",
+ "ora-12514",
+ "ora-12541",
+ "timeout",
+ "timed out",
+)
+
# ============================================================
# Configuration (env-overridable)
# ============================================================
@@ -65,7 +77,7 @@ BATCH_CHUNK_MAX_MEMORY_MB: int = int(
)
BATCH_QUERY_TIME_THRESHOLD_DAYS: int = int(
- os.getenv("BATCH_QUERY_TIME_THRESHOLD_DAYS", "60")
+ os.getenv("BATCH_QUERY_TIME_THRESHOLD_DAYS", "10")
)
BATCH_QUERY_ID_THRESHOLD: int = int(
@@ -196,6 +208,7 @@ def _update_progress(
failed: int,
status: str = "running",
has_partial_failure: bool = False,
+ failed_ranges: Optional[List[Dict[str, str]]] = None,
ttl: int = 900,
) -> None:
"""Write/update batch progress metadata to Redis."""
@@ -212,6 +225,10 @@ def _update_progress(
"status": status,
"has_partial_failure": str(has_partial_failure),
}
+ if failed_ranges is not None:
+ mapping["failed_ranges"] = json.dumps(
+ failed_ranges, ensure_ascii=False, default=str
+ )
try:
client.hset(key, mapping=mapping)
client.expire(key, ttl)
@@ -279,6 +296,7 @@ def execute_plan(
completed = 0
failed = 0
has_partial_failure = False
+ failed_range_list: Optional[List[Dict[str, str]]] = None
_update_progress(
cache_prefix, query_hash,
@@ -296,7 +314,9 @@ def execute_plan(
_update_progress(
cache_prefix, query_hash,
total=total, completed=completed, failed=failed,
- has_partial_failure=has_partial_failure, ttl=chunk_ttl,
+ has_partial_failure=has_partial_failure,
+ failed_ranges=failed_range_list,
+ ttl=chunk_ttl,
)
continue
ok = _execute_single_chunk(
@@ -308,14 +328,24 @@ def execute_plan(
else:
failed += 1
has_partial_failure = True
+ if failed_range_list is None:
+ failed_range_list = []
+ chunk_start = chunk.get("chunk_start")
+ chunk_end = chunk.get("chunk_end")
+ if chunk_start and chunk_end:
+ failed_range_list.append(
+ {"start": str(chunk_start), "end": str(chunk_end)}
+ )
_update_progress(
cache_prefix, query_hash,
total=total, completed=completed, failed=failed,
- has_partial_failure=has_partial_failure, ttl=chunk_ttl,
+ has_partial_failure=has_partial_failure,
+ failed_ranges=failed_range_list,
+ ttl=chunk_ttl,
)
else:
# --- Parallel path ---
- completed, failed, has_partial_failure = _execute_parallel(
+ completed, failed, has_partial_failure, failed_range_list = _execute_parallel(
chunks, query_fn, cache_prefix, query_hash,
chunk_ttl, max_rows_per_chunk, skip_cached,
effective_parallel,
@@ -327,6 +357,7 @@ def execute_plan(
total=total, completed=completed, failed=failed,
status=final_status,
has_partial_failure=has_partial_failure,
+ failed_ranges=failed_range_list,
ttl=chunk_ttl,
)
@@ -366,53 +397,59 @@ def _execute_single_chunk(
query_hash: str,
chunk_ttl: int,
max_rows_per_chunk: Optional[int],
+ max_retries: int = 1,
) -> bool:
"""Run one chunk through *query_fn*, apply guards, store result.
Returns True on success, False on failure.
"""
- try:
- df = query_fn(chunk, max_rows_per_chunk=max_rows_per_chunk)
- if df is None:
- df = pd.DataFrame()
+ attempts = max(0, int(max_retries)) + 1
+ for attempt in range(attempts):
+ try:
+ df = query_fn(chunk, max_rows_per_chunk=max_rows_per_chunk)
+ if df is None:
+ df = pd.DataFrame()
- # ---- Memory guard ----
- mem_bytes = df.memory_usage(deep=True).sum()
- mem_mb = mem_bytes / (1024 * 1024)
- if mem_mb > BATCH_CHUNK_MAX_MEMORY_MB:
- logger.warning(
- "Chunk %d memory %.1f MB exceeds limit %d MB — discarded",
- idx, mem_mb, BATCH_CHUNK_MAX_MEMORY_MB,
+ # ---- Memory guard ----
+ mem_bytes = df.memory_usage(deep=True).sum()
+ mem_mb = mem_bytes / (1024 * 1024)
+ if mem_mb > BATCH_CHUNK_MAX_MEMORY_MB:
+ logger.warning(
+ "Chunk %d memory %.1f MB exceeds limit %d MB — discarded",
+ idx, mem_mb, BATCH_CHUNK_MAX_MEMORY_MB,
+ )
+ return False
+
+ # ---- Store to Redis ----
+ stored = redis_store_chunk(cache_prefix, query_hash, idx, df, ttl=chunk_ttl)
+ if not stored:
+ logger.warning(
+ "Chunk %d failed to persist into Redis, marking as failed", idx
+ )
+ return False
+
+ logger.debug(
+ "Chunk %d completed: %d rows, %.1f MB",
+ idx, len(df), mem_mb,
+ )
+ return True
+
+ except Exception as exc:
+ should_retry = attempt < attempts - 1 and _is_retryable_error(exc)
+ if should_retry:
+ logger.warning(
+ "Chunk %d transient failure on attempt %d/%d: %s; retrying",
+ idx,
+ attempt + 1,
+ attempts,
+ exc,
+ )
+ continue
+ logger.error(
+ "Chunk %d failed: %s", idx, exc, exc_info=True,
)
return False
-
- # ---- Truncation flag ----
- truncated = (
- max_rows_per_chunk is not None
- and len(df) == max_rows_per_chunk
- )
- if truncated:
- logger.info("Chunk %d returned exactly max_rows_per_chunk=%d (truncated)", idx, max_rows_per_chunk)
-
- # ---- Store to Redis ----
- stored = redis_store_chunk(cache_prefix, query_hash, idx, df, ttl=chunk_ttl)
- if not stored:
- logger.warning(
- "Chunk %d failed to persist into Redis, marking as failed", idx
- )
- return False
-
- logger.debug(
- "Chunk %d completed: %d rows, %.1f MB",
- idx, len(df), mem_mb,
- )
- return True
-
- except Exception as exc:
- logger.error(
- "Chunk %d failed: %s", idx, exc, exc_info=True,
- )
- return False
+ return False
def _execute_parallel(
@@ -427,12 +464,13 @@ def _execute_parallel(
) -> tuple:
"""Execute chunks in parallel via ThreadPoolExecutor.
- Returns (completed, failed, has_partial_failure).
+ Returns (completed, failed, has_partial_failure, failed_ranges).
"""
total = len(chunks)
completed = 0
failed = 0
has_partial_failure = False
+ failed_range_list: Optional[List[Dict[str, str]]] = None
futures = {}
with ThreadPoolExecutor(max_workers=max_workers) as executor:
@@ -445,10 +483,10 @@ def _execute_parallel(
idx, chunk, query_fn,
cache_prefix, query_hash, chunk_ttl, max_rows_per_chunk,
)
- futures[future] = idx
+ futures[future] = (idx, chunk)
for future in as_completed(futures):
- idx = futures[future]
+ idx, chunk = futures[future]
try:
ok = future.result()
if ok:
@@ -456,18 +494,46 @@ def _execute_parallel(
else:
failed += 1
has_partial_failure = True
+ if failed_range_list is None:
+ failed_range_list = []
+ chunk_start = chunk.get("chunk_start")
+ chunk_end = chunk.get("chunk_end")
+ if chunk_start and chunk_end:
+ failed_range_list.append(
+ {"start": str(chunk_start), "end": str(chunk_end)}
+ )
except Exception as exc:
logger.error("Chunk %d future error: %s", idx, exc)
failed += 1
has_partial_failure = True
+ if failed_range_list is None:
+ failed_range_list = []
+ chunk_start = chunk.get("chunk_start")
+ chunk_end = chunk.get("chunk_end")
+ if chunk_start and chunk_end:
+ failed_range_list.append(
+ {"start": str(chunk_start), "end": str(chunk_end)}
+ )
_update_progress(
cache_prefix, query_hash,
total=total, completed=completed, failed=failed,
- has_partial_failure=has_partial_failure, ttl=chunk_ttl,
+ has_partial_failure=has_partial_failure,
+ failed_ranges=failed_range_list,
+ ttl=chunk_ttl,
)
- return completed, failed, has_partial_failure
+ return completed, failed, has_partial_failure, failed_range_list
+
+
+def _is_retryable_error(exc: Exception) -> bool:
+ """Return True for transient Oracle/network timeout errors."""
+ if isinstance(exc, (TimeoutError, ConnectionError, OSError)):
+ return True
+ text = str(exc).strip().lower()
+ if not text:
+ return False
+ return any(pattern in text for pattern in _RETRYABLE_PATTERNS)
# ============================================================
diff --git a/src/mes_dashboard/services/container_resolution_policy.py b/src/mes_dashboard/services/container_resolution_policy.py
new file mode 100644
index 0000000..b300110
--- /dev/null
+++ b/src/mes_dashboard/services/container_resolution_policy.py
@@ -0,0 +1,152 @@
+# -*- coding: utf-8 -*-
+"""Shared guardrails for LOT/WAFER/工單 container resolution."""
+
+from __future__ import annotations
+
+import os
+from typing import Any, Dict, Iterable, List, Optional
+
+
+def _env_int(name: str, default: int) -> int:
+ raw = os.getenv(name)
+ if raw is None:
+ return int(default)
+ try:
+ return int(raw)
+ except (TypeError, ValueError):
+ return int(default)
+
+
+def _normalize_wildcard_token(value: str) -> str:
+ return str(value or "").replace("*", "%")
+
+
+def _is_pattern_token(value: str) -> bool:
+ token = _normalize_wildcard_token(value)
+ return "%" in token or "_" in token
+
+
+def _literal_prefix_before_wildcard(value: str) -> str:
+ token = _normalize_wildcard_token(value)
+ for idx, ch in enumerate(token):
+ if ch in ("%", "_"):
+ return token[:idx]
+ return token
+
+
+def normalize_input_values(values: Iterable[Any]) -> List[str]:
+ normalized: List[str] = []
+ seen = set()
+ for raw in values or []:
+ token = str(raw or "").strip()
+ if not token or token in seen:
+ continue
+ seen.add(token)
+ normalized.append(token)
+ return normalized
+
+
+def validate_resolution_request(input_type: str, values: Iterable[Any]) -> Optional[str]:
+ """Validate resolver request without hard-capping raw input count."""
+ tokens = normalize_input_values(values)
+ if not tokens:
+ return "請輸入至少一個查詢條件"
+
+ # Compatibility switch. Default 0 means "no count cap".
+ max_values = max(_env_int("CONTAINER_RESOLVE_INPUT_MAX_VALUES", 0), 0)
+ if max_values and len(tokens) > max_values:
+ return f"輸入數量超過上限 ({max_values} 筆)"
+
+ # Wildcard safety: avoid full-table scans like "%" or "_".
+ min_prefix_len = max(_env_int("CONTAINER_RESOLVE_PATTERN_MIN_PREFIX_LEN", 2), 0)
+ if min_prefix_len > 0:
+ invalid_patterns: List[str] = []
+ for token in tokens:
+ if not _is_pattern_token(token):
+ continue
+ if len(_literal_prefix_before_wildcard(token).strip()) < min_prefix_len:
+ invalid_patterns.append(token)
+ if invalid_patterns:
+ sample = ", ".join(invalid_patterns[:3])
+ suffix = "..." if len(invalid_patterns) > 3 else ""
+ return (
+ f"{input_type} 萬用字元條件過於寬鬆(需至少 {min_prefix_len} 碼前綴): "
+ f"{sample}{suffix}"
+ )
+
+ return None
+
+
+def extract_container_ids(rows: Iterable[Dict[str, Any]]) -> List[str]:
+ ids: List[str] = []
+ seen = set()
+ for row in rows or []:
+ cid = str(
+ row.get("container_id")
+ or row.get("CONTAINERID")
+ or ""
+ ).strip()
+ if not cid or cid in seen:
+ continue
+ seen.add(cid)
+ ids.append(cid)
+ return ids
+
+
+def assess_resolution_result(result: Dict[str, Any]) -> Dict[str, Any]:
+ """Assess expansion result against guardrails."""
+ expansion_info = result.get("expansion_info") or {}
+ max_expand_per_token = max(
+ _env_int("CONTAINER_RESOLVE_MAX_EXPANSION_PER_TOKEN", 2000),
+ 1,
+ )
+ offenders: List[Dict[str, Any]] = []
+ for token, count in expansion_info.items():
+ try:
+ c = int(count)
+ except (TypeError, ValueError):
+ continue
+ if c > max_expand_per_token:
+ offenders.append({"token": str(token), "count": c})
+
+ unique_ids = extract_container_ids(result.get("data") or [])
+ max_container_ids = max(
+ _env_int("CONTAINER_RESOLVE_MAX_CONTAINER_IDS", 30000),
+ 1,
+ )
+ return {
+ "max_expansion_per_token": max_expand_per_token,
+ "expansion_offenders": offenders,
+ "max_container_ids": max_container_ids,
+ "resolved_container_ids": len(unique_ids),
+ "over_container_limit": len(unique_ids) > max_container_ids,
+ }
+
+
+def validate_resolution_result(
+ result: Dict[str, Any],
+ *,
+ strict: bool = True,
+) -> Optional[str]:
+ """Validate expansion result guardrails.
+
+ strict=True: exceed guardrail -> return error message.
+ strict=False: exceed guardrail -> allow caller to continue (split/decompose path).
+ """
+ assessment = assess_resolution_result(result)
+ offenders = assessment.get("expansion_offenders") or []
+ if offenders and strict:
+ first = offenders[0]
+ token = str(first.get("token") or "")
+ count = int(first.get("count") or 0)
+ return (
+ f"單一條件展開過大 ({count} 筆,限制 {assessment['max_expansion_per_token']}),"
+ f"請縮小範圍: {token}"
+ )
+
+ if bool(assessment.get("over_container_limit")) and strict:
+ return (
+ f"解析結果過大({assessment['resolved_container_ids']} 筆 CONTAINERID,限制 {assessment['max_container_ids']})"
+ ",請縮小查詢條件"
+ )
+ return None
diff --git a/src/mes_dashboard/services/event_fetcher.py b/src/mes_dashboard/services/event_fetcher.py
index 55105cb..503f1d3 100644
--- a/src/mes_dashboard/services/event_fetcher.py
+++ b/src/mes_dashboard/services/event_fetcher.py
@@ -21,6 +21,10 @@ logger = logging.getLogger("mes_dashboard.event_fetcher")
ORACLE_IN_BATCH_SIZE = 1000
EVENT_FETCHER_MAX_WORKERS = int(os.getenv('EVENT_FETCHER_MAX_WORKERS', '2'))
CACHE_SKIP_CID_THRESHOLD = int(os.getenv('EVENT_FETCHER_CACHE_SKIP_CID_THRESHOLD', '10000'))
+EVENT_FETCHER_ALLOW_PARTIAL_RESULTS = (
+ os.getenv('EVENT_FETCHER_ALLOW_PARTIAL_RESULTS', 'false').strip().lower()
+ in {'1', 'true', 'yes', 'on'}
+)
_DOMAIN_SPECS: Dict[str, Dict[str, Any]] = {
"history": {
@@ -280,16 +284,23 @@ class EventFetcher:
for batch in batches:
_fetch_and_group_batch(batch)
else:
+ failures = []
with ThreadPoolExecutor(max_workers=min(len(batches), EVENT_FETCHER_MAX_WORKERS)) as executor:
futures = {executor.submit(_fetch_and_group_batch, b): b for b in batches}
for future in as_completed(futures):
try:
future.result()
- except Exception:
+ except Exception as exc:
+ failures.append((futures[future], exc))
logger.error(
"EventFetcher batch query failed domain=%s batch_size=%s",
domain, len(futures[future]), exc_info=True,
)
+ if failures and not EVENT_FETCHER_ALLOW_PARTIAL_RESULTS:
+ failed_cids = sum(len(batch) for batch, _ in failures)
+ raise RuntimeError(
+ f"EventFetcher chunk failed (domain={domain}, failed_chunks={len(failures)}, failed_cids={failed_cids})"
+ )
result = dict(grouped)
del grouped
diff --git a/src/mes_dashboard/services/job_query_service.py b/src/mes_dashboard/services/job_query_service.py
index 649b9e8..77dba5f 100644
--- a/src/mes_dashboard/services/job_query_service.py
+++ b/src/mes_dashboard/services/job_query_service.py
@@ -150,7 +150,7 @@ def get_jobs_by_resources(
) -> Dict[str, Any]:
"""Query jobs for selected resources within date range.
- For date ranges exceeding BATCH_QUERY_TIME_THRESHOLD_DAYS (default 60),
+ For date ranges exceeding BATCH_QUERY_TIME_THRESHOLD_DAYS (default 10),
the query is decomposed into monthly chunks via BatchQueryEngine.
Results are cached in Redis to avoid redundant Oracle queries.
diff --git a/src/mes_dashboard/services/mid_section_defect_service.py b/src/mes_dashboard/services/mid_section_defect_service.py
index c48f46f..b213745 100644
--- a/src/mes_dashboard/services/mid_section_defect_service.py
+++ b/src/mes_dashboard/services/mid_section_defect_service.py
@@ -863,7 +863,7 @@ def _fetch_station_detection_data(
) -> Optional[pd.DataFrame]:
"""Execute station_detection.sql and return raw DataFrame.
- For date ranges exceeding BATCH_QUERY_TIME_THRESHOLD_DAYS (default 60),
+ For date ranges exceeding BATCH_QUERY_TIME_THRESHOLD_DAYS (default 10),
the query is decomposed into monthly chunks via BatchQueryEngine to
prevent Oracle timeout on high-volume stations.
"""
diff --git a/src/mes_dashboard/services/query_tool_service.py b/src/mes_dashboard/services/query_tool_service.py
index 2e97a2f..ea6eb3b 100644
--- a/src/mes_dashboard/services/query_tool_service.py
+++ b/src/mes_dashboard/services/query_tool_service.py
@@ -26,9 +26,15 @@ from typing import Any, Dict, List, Optional, Generator, Iterable, Tuple
import pandas as pd
-from mes_dashboard.core.database import read_sql_df
-from mes_dashboard.sql import QueryBuilder, SQLLoader
-from mes_dashboard.services.event_fetcher import EventFetcher
+from mes_dashboard.core.database import read_sql_df
+from mes_dashboard.sql import QueryBuilder, SQLLoader
+from mes_dashboard.services.container_resolution_policy import (
+ assess_resolution_result,
+ normalize_input_values,
+ validate_resolution_request,
+ validate_resolution_result,
+)
+from mes_dashboard.services.event_fetcher import EventFetcher
try:
from mes_dashboard.core.database import read_sql_df_slow
@@ -89,7 +95,7 @@ def validate_date_range(start_date: str, end_date: str, max_days: int = MAX_DATE
return f'日期格式錯誤: {e}'
-def validate_lot_input(input_type: str, values: List[str]) -> Optional[str]:
+def validate_lot_input(input_type: str, values: List[str]) -> Optional[str]:
"""Validate LOT input based on type.
Args:
@@ -99,23 +105,7 @@ def validate_lot_input(input_type: str, values: List[str]) -> Optional[str]:
Returns:
Error message if validation fails, None if valid.
"""
- if not values:
- return '請輸入至少一個查詢條件'
-
- limits = {
- 'lot_id': MAX_LOT_IDS,
- 'wafer_lot': MAX_LOT_IDS,
- 'gd_lot_id': MAX_LOT_IDS,
- 'serial_number': MAX_SERIAL_NUMBERS,
- 'work_order': MAX_WORK_ORDERS,
- 'gd_work_order': MAX_GD_WORK_ORDERS,
- }
-
- limit = limits.get(input_type, MAX_LOT_IDS)
- if len(values) > limit:
- return f'輸入數量超過上限 ({limit} 筆)'
-
- return None
+ return validate_resolution_request(input_type, values)
def validate_equipment_input(equipment_ids: List[str]) -> Optional[str]:
@@ -344,27 +334,50 @@ def resolve_lots(input_type: str, values: List[str]) -> Dict[str, Any]:
return {'error': validation_error}
# Clean values
- cleaned = [v.strip() for v in values if v.strip()]
- if not cleaned:
- return {'error': '請輸入有效的查詢條件'}
+ cleaned = normalize_input_values(values)
+ if not cleaned:
+ return {'error': '請輸入有效的查詢條件'}
try:
- if input_type == 'lot_id':
- return _resolve_by_lot_id(cleaned)
- elif input_type == 'wafer_lot':
- return _resolve_by_wafer_lot(cleaned)
- elif input_type == 'gd_lot_id':
- return _resolve_by_gd_lot_id(cleaned)
- elif input_type == 'serial_number':
- return _resolve_by_serial_number(cleaned)
- elif input_type == 'work_order':
- return _resolve_by_work_order(cleaned)
- elif input_type == 'gd_work_order':
- return _resolve_by_gd_work_order(cleaned)
- else:
- return {'error': f'不支援的輸入類型: {input_type}'}
-
- except Exception as exc:
+ if input_type == 'lot_id':
+ result = _resolve_by_lot_id(cleaned)
+ elif input_type == 'wafer_lot':
+ result = _resolve_by_wafer_lot(cleaned)
+ elif input_type == 'gd_lot_id':
+ result = _resolve_by_gd_lot_id(cleaned)
+ elif input_type == 'serial_number':
+ result = _resolve_by_serial_number(cleaned)
+ elif input_type == 'work_order':
+ result = _resolve_by_work_order(cleaned)
+ elif input_type == 'gd_work_order':
+ result = _resolve_by_gd_work_order(cleaned)
+ else:
+ return {'error': f'不支援的輸入類型: {input_type}'}
+
+ guard_assessment = assess_resolution_result(result)
+ overflow_tokens = guard_assessment.get("expansion_offenders") or []
+ overflow_total = bool(guard_assessment.get("over_container_limit"))
+ if overflow_tokens or overflow_total:
+ logger.warning(
+ "Resolution guardrail overflow (input_type=%s, offenders=%s, resolved=%s, max=%s); continuing with decompose path",
+ input_type,
+ len(overflow_tokens),
+ guard_assessment.get("resolved_container_ids"),
+ guard_assessment.get("max_container_ids"),
+ )
+ result["guardrail"] = {
+ "overflow": True,
+ "expansion_offenders": overflow_tokens,
+ "resolved_container_ids": guard_assessment.get("resolved_container_ids"),
+ "max_container_ids": guard_assessment.get("max_container_ids"),
+ }
+ # Keep compatibility: validation API remains available for strict call sites.
+ guard_error = validate_resolution_result(result, strict=False)
+ if guard_error:
+ return {'error': guard_error}
+ return result
+
+ except Exception as exc:
logger.error(f"LOT resolution failed: {exc}")
return {'error': f'解析失敗: {str(exc)}'}
diff --git a/src/mes_dashboard/services/reject_dataset_cache.py b/src/mes_dashboard/services/reject_dataset_cache.py
index 4e3568e..204afdf 100644
--- a/src/mes_dashboard/services/reject_dataset_cache.py
+++ b/src/mes_dashboard/services/reject_dataset_cache.py
@@ -1,24 +1,24 @@
-# -*- coding: utf-8 -*-
-"""Two-phase reject-history dataset cache.
-
-Primary query (POST /query) → Oracle → cache full LOT-level DataFrame.
-Supplementary view (GET /view) → read cache → pandas filter/derive.
-
-Cache layers:
- L1: ProcessLevelCache (in-process, per-worker)
- L2: Redis (cross-worker, parquet bytes encoded as base64 string)
-"""
-
-from __future__ import annotations
-
+# -*- coding: utf-8 -*-
+"""Two-phase reject-history dataset cache.
+
+Primary query (POST /query) → Oracle → cache full LOT-level DataFrame.
+Supplementary view (GET /view) → read cache → pandas filter/derive.
+
+Cache layers:
+ L1: ProcessLevelCache (in-process, per-worker)
+ L2: Redis (cross-worker, parquet bytes encoded as base64 string)
+"""
+
+from __future__ import annotations
+
import hashlib
import json
import logging
import os
from typing import Any, Dict, List, Optional
-
-import pandas as pd
-
+
+import pandas as pd
+
from mes_dashboard.core.cache import ProcessLevelCache, register_process_cache
from mes_dashboard.core.database import read_sql_df_slow as read_sql_df
from mes_dashboard.core.query_spool_store import (
@@ -32,29 +32,36 @@ from mes_dashboard.core.redis_df_store import (
redis_load_df,
redis_store_df,
)
-from mes_dashboard.services.filter_cache import get_specs_for_groups
-from mes_dashboard.services.reject_history_service import (
- _as_float,
- _as_int,
- _build_where_clause,
- _derive_summary,
- _extract_distinct_text_values,
- _extract_workcenter_group_options,
- _normalize_text,
- _prepare_sql,
- _to_date_str,
- _to_datetime_str,
- _validate_range,
-)
-from mes_dashboard.services.query_tool_service import (
- _resolve_by_lot_id,
- _resolve_by_wafer_lot,
- _resolve_by_work_order,
-)
-from mes_dashboard.sql import QueryBuilder
-
-logger = logging.getLogger("mes_dashboard.reject_dataset_cache")
-
+from mes_dashboard.services.filter_cache import get_specs_for_groups
+from mes_dashboard.services.container_resolution_policy import (
+ assess_resolution_result,
+ extract_container_ids,
+ normalize_input_values,
+ validate_resolution_request,
+ validate_resolution_result,
+)
+from mes_dashboard.services.reject_history_service import (
+ _as_float,
+ _as_int,
+ _build_where_clause,
+ _derive_summary,
+ _extract_distinct_text_values,
+ _extract_workcenter_group_options,
+ _normalize_text,
+ _prepare_sql,
+ _to_date_str,
+ _to_datetime_str,
+ _validate_range,
+)
+from mes_dashboard.services.query_tool_service import (
+ _resolve_by_lot_id,
+ _resolve_by_wafer_lot,
+ _resolve_by_work_order,
+)
+from mes_dashboard.sql import QueryBuilder
+
+logger = logging.getLogger("mes_dashboard.reject_dataset_cache")
+
_CACHE_TTL = 900 # 15 minutes
_CACHE_MAX_SIZE = 8
_REDIS_NAMESPACE = "reject_dataset"
@@ -79,31 +86,31 @@ _REJECT_ENGINE_MAX_RESULT_MB = max(
_REJECT_ENGINE_SPOOL_TTL_SECONDS = max(
300, int(os.getenv("REJECT_ENGINE_SPOOL_TTL_SECONDS", "21600"))
)
-
-_dataset_cache = ProcessLevelCache(ttl_seconds=_CACHE_TTL, max_size=_CACHE_MAX_SIZE)
-register_process_cache("reject_dataset", _dataset_cache, "Reject Dataset (L1, 15min)")
-
-
-# ============================================================
-# Query ID
-# ============================================================
-
-
-def _make_query_id(params: dict) -> str:
- """Deterministic hash from primary query params + policy toggles."""
- canonical = json.dumps(params, sort_keys=True, ensure_ascii=False, default=str)
- return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:16]
-
-
-# ============================================================
-# Redis L2 helpers (delegated to shared redis_df_store)
-# ============================================================
-
-
-def _redis_store_df(query_id: str, df: pd.DataFrame) -> None:
- redis_store_df(f"{_REDIS_NAMESPACE}:{query_id}", df, ttl=_CACHE_TTL)
-
-
+
+_dataset_cache = ProcessLevelCache(ttl_seconds=_CACHE_TTL, max_size=_CACHE_MAX_SIZE)
+register_process_cache("reject_dataset", _dataset_cache, "Reject Dataset (L1, 15min)")
+
+
+# ============================================================
+# Query ID
+# ============================================================
+
+
+def _make_query_id(params: dict) -> str:
+ """Deterministic hash from primary query params + policy toggles."""
+ canonical = json.dumps(params, sort_keys=True, ensure_ascii=False, default=str)
+ return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:16]
+
+
+# ============================================================
+# Redis L2 helpers (delegated to shared redis_df_store)
+# ============================================================
+
+
+def _redis_store_df(query_id: str, df: pd.DataFrame) -> None:
+ redis_store_df(f"{_REDIS_NAMESPACE}:{query_id}", df, ttl=_CACHE_TTL)
+
+
def _redis_load_df(query_id: str) -> Optional[pd.DataFrame]:
return redis_load_df(f"{_REDIS_NAMESPACE}:{query_id}")
@@ -118,6 +125,93 @@ def _redis_delete_df(query_id: str) -> None:
return
+def _partial_failure_key(query_id: str) -> str:
+ return f"{_REDIS_NAMESPACE}:{query_id}:partial_failure"
+
+
+def _store_partial_failure_flag(
+ query_id: str,
+ failed_count: int,
+ failed_ranges: Optional[List[Dict[str, str]]],
+ ttl: int,
+) -> None:
+ """Persist partial-failure metadata for cache-hit responses."""
+ client = get_redis_client()
+ if client is None:
+ return
+ key = get_key(_partial_failure_key(query_id))
+ mapping = {
+ "has_partial_failure": "True",
+ "failed_chunk_count": str(max(int(failed_count), 0)),
+ "failed_ranges": json.dumps(failed_ranges or [], ensure_ascii=False, default=str),
+ }
+ try:
+ client.hset(key, mapping=mapping)
+ client.expire(key, max(int(ttl), 1))
+ except Exception as exc:
+ logger.warning("Failed to store partial failure flag (query_id=%s): %s", query_id, exc)
+
+
+def _load_partial_failure_flag(query_id: str) -> Dict[str, Any]:
+ """Load persisted partial-failure metadata for cache-hit responses."""
+ client = get_redis_client()
+ if client is None:
+ return {}
+ key = get_key(_partial_failure_key(query_id))
+ try:
+ raw = client.hgetall(key)
+ except Exception:
+ return {}
+ if not raw:
+ return {}
+
+ has_partial = str(raw.get("has_partial_failure", "")).strip().lower() in {
+ "1",
+ "true",
+ "yes",
+ "on",
+ }
+ if not has_partial:
+ return {}
+
+ failed_count_raw = raw.get("failed_chunk_count", raw.get("failed", "0"))
+ try:
+ failed_count = max(int(str(failed_count_raw)), 0)
+ except Exception:
+ failed_count = 0
+
+ failed_ranges: List[Dict[str, str]] = []
+ raw_ranges = raw.get("failed_ranges", "[]")
+ try:
+ parsed_ranges = json.loads(raw_ranges) if raw_ranges else []
+ if isinstance(parsed_ranges, list):
+ for item in parsed_ranges:
+ if not isinstance(item, dict):
+ continue
+ start = str(item.get("start", "")).strip()
+ end = str(item.get("end", "")).strip()
+ if start and end:
+ failed_ranges.append({"start": start, "end": end})
+ except Exception:
+ failed_ranges = []
+
+ return {
+ "has_partial_failure": True,
+ "failed_chunk_count": failed_count,
+ "failed_ranges": failed_ranges,
+ }
+
+
+def _clear_partial_failure_flag(query_id: str) -> None:
+ client = get_redis_client()
+ if client is None:
+ return
+ try:
+ client.delete(get_key(_partial_failure_key(query_id)))
+ except Exception:
+ return
+
+
# ============================================================
# Cache read (L1 → L2 → None)
# ============================================================
@@ -151,10 +245,10 @@ def _store_df(query_id: str, df: pd.DataFrame) -> None:
clear_spooled_df(_REDIS_NAMESPACE, query_id)
-def _store_query_result(query_id: str, df: pd.DataFrame) -> None:
- """Store result using Redis for small sets and parquet spill for large sets."""
+def _store_query_result(query_id: str, df: pd.DataFrame) -> bool:
+ """Store result and return True when persisted via parquet spill."""
if df is None or df.empty:
- return
+ return False
df_mb = df.memory_usage(deep=True).sum() / (1024 * 1024)
should_spill = _REJECT_ENGINE_SPILL_ENABLED and (
@@ -177,7 +271,7 @@ def _store_query_result(query_id: str, df: pd.DataFrame) -> None:
len(df),
df_mb,
)
- return
+ return True
logger.warning(
"Parquet spill failed, fallback to dataset cache (query_id=%s, rows=%d, size_mb=%.1f)",
query_id,
@@ -186,172 +280,199 @@ def _store_query_result(query_id: str, df: pd.DataFrame) -> None:
)
_store_df(query_id, df)
-
-
-# ============================================================
-# Container resolution (reuse query_tool_service resolvers)
-# ============================================================
-
-
-_RESOLVERS = {
- "lot": _resolve_by_lot_id,
- "work_order": _resolve_by_work_order,
- "wafer_lot": _resolve_by_wafer_lot,
-}
-
-
-def resolve_containers(
- input_type: str, values: List[str]
-) -> Dict[str, Any]:
- """Dispatch to existing resolver → return container IDs + resolution info."""
- resolver = _RESOLVERS.get(input_type)
- if resolver is None:
- raise ValueError(f"不支援的輸入類型: {input_type}")
-
- result = resolver(values)
- if "error" in result:
- raise ValueError(result["error"])
-
- container_ids = []
- for row in result.get("data", []):
- cid = row.get("container_id")
- if cid:
- container_ids.append(cid)
-
- return {
- "container_ids": container_ids,
- "resolution_info": {
- "input_count": result.get("input_count", len(values)),
- "resolved_count": len(container_ids),
- "not_found": result.get("not_found", []),
- "expansion_info": result.get("expansion_info", {}),
- },
- }
-
-
-# ============================================================
-# Primary query
-# ============================================================
-
-
-def execute_primary_query(
- *,
- mode: str,
- start_date: Optional[str] = None,
- end_date: Optional[str] = None,
- container_input_type: Optional[str] = None,
- container_values: Optional[List[str]] = None,
- include_excluded_scrap: bool = False,
- exclude_material_scrap: bool = True,
- exclude_pb_diode: bool = True,
-) -> Dict[str, Any]:
- """Execute Oracle query → cache DataFrame → return structured result."""
-
- # ---- Build base_where + params for the primary filter ----
- base_where_parts: List[str] = []
- base_params: Dict[str, Any] = {}
- resolution_info: Optional[Dict[str, Any]] = None
- workflow_filter: str = "" # empty = use default date-based filter
- container_ids: List[str] = [] # populated in container mode
-
- if mode == "date_range":
- if not start_date or not end_date:
- raise ValueError("date_range mode 需要 start_date 和 end_date")
- _validate_range(start_date, end_date)
- base_where_parts.append(
- "r.TXNDATE >= TO_DATE(:start_date, 'YYYY-MM-DD')"
- " AND r.TXNDATE < TO_DATE(:end_date, 'YYYY-MM-DD') + 1"
- )
- base_params["start_date"] = start_date
- base_params["end_date"] = end_date
-
- elif mode == "container":
- if not container_values:
- raise ValueError("container mode 需要至少一個容器值")
- resolved = resolve_containers(
- container_input_type or "lot", container_values
- )
- resolution_info = resolved["resolution_info"]
- container_ids = resolved["container_ids"]
- if not container_ids:
- raise ValueError("未找到任何對應的容器")
-
- builder = QueryBuilder()
- builder.add_in_condition("r.CONTAINERID", container_ids)
- cid_where, cid_params = builder.build_where_only()
- # build_where_only returns "WHERE ..." — strip "WHERE " prefix
- cid_condition = cid_where.strip()
- if cid_condition.upper().startswith("WHERE "):
- cid_condition = cid_condition[6:].strip()
- base_where_parts.append(cid_condition)
- base_params.update(cid_params)
-
- # Build workflow_filter for the workflow_lookup CTE (uses r0 alias).
- # Reuses the same bind param names (p0, p1, ...) already in base_params.
- wf_builder = QueryBuilder()
- wf_builder.add_in_condition("r0.CONTAINERID", container_ids)
- wf_where, _ = wf_builder.build_where_only()
- wf_condition = wf_where.strip()
- if wf_condition.upper().startswith("WHERE "):
- wf_condition = wf_condition[6:].strip()
- workflow_filter = wf_condition
-
- else:
- raise ValueError(f"不支援的查詢模式: {mode}")
-
- base_where = " AND ".join(base_where_parts)
-
- # ---- Build policy meta (for response only, NOT for SQL) ----
- _, _, meta = _build_where_clause(
- include_excluded_scrap=include_excluded_scrap,
- exclude_material_scrap=exclude_material_scrap,
- exclude_pb_diode=exclude_pb_diode,
- )
-
- # ---- Compute query_id from base params only (policy filters applied in-memory) ----
- query_id_input = {
- "cache_schema_version": _CACHE_SCHEMA_VERSION,
- "mode": mode,
- "start_date": start_date,
- "end_date": end_date,
- "container_input_type": container_input_type,
- "container_values": sorted(container_values or []),
- }
- query_id = _make_query_id(query_id_input)
-
- # ---- Check cache first ----
- cached_df = _get_cached_df(query_id)
- if cached_df is not None:
- logger.info("Dataset cache hit for query_id=%s", query_id)
- filtered = _apply_policy_filters(
- cached_df,
- include_excluded_scrap=include_excluded_scrap,
- exclude_material_scrap=exclude_material_scrap,
- exclude_pb_diode=exclude_pb_diode,
- )
- return _build_primary_response(
- query_id, filtered, meta, resolution_info
- )
-
- # ---- Execute Oracle query (NO policy filters — cache unfiltered) ----
- logger.info("Dataset cache miss for query_id=%s, querying Oracle", query_id)
-
- # Decide whether to route through BatchQueryEngine
- from mes_dashboard.services.batch_query_engine import (
- decompose_by_time_range,
- decompose_by_ids,
- execute_plan,
- merge_chunks,
- compute_query_hash,
- should_decompose_by_time,
- should_decompose_by_ids,
- BATCH_QUERY_TIME_THRESHOLD_DAYS,
- )
-
+ return False
+
+
+# ============================================================
+# Container resolution (reuse query_tool_service resolvers)
+# ============================================================
+
+
+_RESOLVERS = {
+ "lot": _resolve_by_lot_id,
+ "work_order": _resolve_by_work_order,
+ "wafer_lot": _resolve_by_wafer_lot,
+}
+
+
+def resolve_containers(
+ input_type: str, values: List[str]
+) -> Dict[str, Any]:
+ """Dispatch to existing resolver → return container IDs + resolution info."""
+ cleaned_values = normalize_input_values(values)
+ validation_error = validate_resolution_request(input_type, cleaned_values)
+ if validation_error:
+ raise ValueError(validation_error)
+
+ resolver = _RESOLVERS.get(input_type)
+ if resolver is None:
+ raise ValueError(f"不支援的輸入類型: {input_type}")
+
+ result = resolver(cleaned_values)
+ if "error" in result:
+ raise ValueError(result["error"])
+
+ guard_assessment = assess_resolution_result(result)
+ overflow_tokens = guard_assessment.get("expansion_offenders") or []
+ overflow_total = bool(guard_assessment.get("over_container_limit"))
+ if overflow_tokens or overflow_total:
+ logger.warning(
+ "Container resolution guardrail overflow (input_type=%s, offenders=%s, resolved=%s, max=%s); continuing with ID decomposition",
+ input_type,
+ len(overflow_tokens),
+ guard_assessment.get("resolved_container_ids"),
+ guard_assessment.get("max_container_ids"),
+ )
+ # strict=False: don't block oversized resolution; continue to downstream ID chunking.
+ _ = validate_resolution_result(result, strict=False)
+
+ container_ids = extract_container_ids(result.get("data", []))
+
+ return {
+ "container_ids": container_ids,
+ "resolution_info": {
+ "input_count": result.get("input_count", len(cleaned_values)),
+ "resolved_count": len(container_ids),
+ "not_found": result.get("not_found", []),
+ "expansion_info": result.get("expansion_info", {}),
+ "guardrail": {
+ "overflow": bool(overflow_tokens or overflow_total),
+ "expansion_offenders": overflow_tokens,
+ "resolved_container_ids": guard_assessment.get("resolved_container_ids"),
+ "max_container_ids": guard_assessment.get("max_container_ids"),
+ },
+ },
+ }
+
+
+# ============================================================
+# Primary query
+# ============================================================
+
+
+def execute_primary_query(
+ *,
+ mode: str,
+ start_date: Optional[str] = None,
+ end_date: Optional[str] = None,
+ container_input_type: Optional[str] = None,
+ container_values: Optional[List[str]] = None,
+ include_excluded_scrap: bool = False,
+ exclude_material_scrap: bool = True,
+ exclude_pb_diode: bool = True,
+) -> Dict[str, Any]:
+ """Execute Oracle query → cache DataFrame → return structured result."""
+
+ # ---- Build base_where + params for the primary filter ----
+ base_where_parts: List[str] = []
+ base_params: Dict[str, Any] = {}
+ resolution_info: Optional[Dict[str, Any]] = None
+ workflow_filter: str = "" # empty = use default date-based filter
+ container_ids: List[str] = [] # populated in container mode
+
+ if mode == "date_range":
+ if not start_date or not end_date:
+ raise ValueError("date_range mode 需要 start_date 和 end_date")
+ _validate_range(start_date, end_date)
+ base_where_parts.append(
+ "r.TXNDATE >= TO_DATE(:start_date, 'YYYY-MM-DD')"
+ " AND r.TXNDATE < TO_DATE(:end_date, 'YYYY-MM-DD') + 1"
+ )
+ base_params["start_date"] = start_date
+ base_params["end_date"] = end_date
+
+ elif mode == "container":
+ if not container_values:
+ raise ValueError("container mode 需要至少一個容器值")
+ resolved = resolve_containers(
+ container_input_type or "lot", container_values
+ )
+ resolution_info = resolved["resolution_info"]
+ container_ids = resolved["container_ids"]
+ if not container_ids:
+ raise ValueError("未找到任何對應的容器")
+
+ builder = QueryBuilder()
+ builder.add_in_condition("r.CONTAINERID", container_ids)
+ cid_where, cid_params = builder.build_where_only()
+ # build_where_only returns "WHERE ..." — strip "WHERE " prefix
+ cid_condition = cid_where.strip()
+ if cid_condition.upper().startswith("WHERE "):
+ cid_condition = cid_condition[6:].strip()
+ base_where_parts.append(cid_condition)
+ base_params.update(cid_params)
+
+ # Build workflow_filter for the workflow_lookup CTE (uses r0 alias).
+ # Reuses the same bind param names (p0, p1, ...) already in base_params.
+ wf_builder = QueryBuilder()
+ wf_builder.add_in_condition("r0.CONTAINERID", container_ids)
+ wf_where, _ = wf_builder.build_where_only()
+ wf_condition = wf_where.strip()
+ if wf_condition.upper().startswith("WHERE "):
+ wf_condition = wf_condition[6:].strip()
+ workflow_filter = wf_condition
+
+ else:
+ raise ValueError(f"不支援的查詢模式: {mode}")
+
+ base_where = " AND ".join(base_where_parts)
+
+ # ---- Build policy meta (for response only, NOT for SQL) ----
+ _, _, meta = _build_where_clause(
+ include_excluded_scrap=include_excluded_scrap,
+ exclude_material_scrap=exclude_material_scrap,
+ exclude_pb_diode=exclude_pb_diode,
+ )
+
+ # ---- Compute query_id from base params only (policy filters applied in-memory) ----
+ query_id_input = {
+ "cache_schema_version": _CACHE_SCHEMA_VERSION,
+ "mode": mode,
+ "start_date": start_date,
+ "end_date": end_date,
+ "container_input_type": container_input_type,
+ "container_values": sorted(container_values or []),
+ }
+ query_id = _make_query_id(query_id_input)
+
+ # ---- Check cache first ----
+ cached_df = _get_cached_df(query_id)
+ if cached_df is not None:
+ logger.info("Dataset cache hit for query_id=%s", query_id)
+ cached_partial_meta = _load_partial_failure_flag(query_id)
+ if cached_partial_meta:
+ meta.update(cached_partial_meta)
+ filtered = _apply_policy_filters(
+ cached_df,
+ include_excluded_scrap=include_excluded_scrap,
+ exclude_material_scrap=exclude_material_scrap,
+ exclude_pb_diode=exclude_pb_diode,
+ )
+ return _build_primary_response(
+ query_id, filtered, meta, resolution_info
+ )
+
+ # ---- Execute Oracle query (NO policy filters — cache unfiltered) ----
+ logger.info("Dataset cache miss for query_id=%s, querying Oracle", query_id)
+
+ # Decide whether to route through BatchQueryEngine
+ from mes_dashboard.services.batch_query_engine import (
+ decompose_by_time_range,
+ decompose_by_ids,
+ execute_plan,
+ merge_chunks,
+ get_batch_progress,
+ compute_query_hash,
+ should_decompose_by_time,
+ should_decompose_by_ids,
+ BATCH_QUERY_TIME_THRESHOLD_DAYS,
+ )
+
use_engine = False
engine_chunks: Optional[list] = None
engine_parallel = 1
engine_hash: Optional[str] = None
+ partial_failure_meta: Dict[str, Any] = {}
if mode == "date_range" and should_decompose_by_time(start_date, end_date):
engine_chunks = decompose_by_time_range(
@@ -369,63 +490,87 @@ def execute_primary_query(
id_batches = decompose_by_ids(container_ids)
engine_chunks = [{"ids": batch} for batch in id_batches]
use_engine = True
- logger.info(
- "Engine activated for container IDs: %d batches (query_id=%s)",
- len(engine_chunks), query_id,
- )
-
+ logger.info(
+ "Engine activated for container IDs: %d batches (query_id=%s)",
+ len(engine_chunks), query_id,
+ )
+
if use_engine and engine_chunks:
# --- Engine path ---
engine_hash = compute_query_hash(query_id_input)
redis_clear_batch("reject", engine_hash)
-
- def _run_reject_chunk(chunk, max_rows_per_chunk=None):
- """Execute one chunk of the reject query via read_sql_df_slow."""
- chunk_where_parts: List[str] = []
- chunk_params: Dict[str, Any] = {}
- chunk_wf_filter = ""
-
- if "chunk_start" in chunk:
- # Time-range chunk
- chunk_where_parts.append(
- "r.TXNDATE >= TO_DATE(:start_date, 'YYYY-MM-DD')"
- " AND r.TXNDATE < TO_DATE(:end_date, 'YYYY-MM-DD') + 1"
- )
- chunk_params["start_date"] = chunk["chunk_start"]
- chunk_params["end_date"] = chunk["chunk_end"]
- elif "ids" in chunk:
- # ID-batch chunk
- b = QueryBuilder()
- b.add_in_condition("r.CONTAINERID", chunk["ids"])
- cid_w, cid_p = b.build_where_only()
- cid_c = cid_w.strip()
- if cid_c.upper().startswith("WHERE "):
- cid_c = cid_c[6:].strip()
- chunk_where_parts.append(cid_c)
- chunk_params.update(cid_p)
- # Workflow filter for container mode
- wfb = QueryBuilder()
- wfb.add_in_condition("r0.CONTAINERID", chunk["ids"])
- wf_w, _ = wfb.build_where_only()
- wf_c = wf_w.strip()
- if wf_c.upper().startswith("WHERE "):
- wf_c = wf_c[6:].strip()
- chunk_wf_filter = wf_c
-
- chunk_where = " AND ".join(chunk_where_parts)
- chunk_sql = _prepare_sql(
- "list",
- where_clause="",
- base_variant="lot",
- base_where=chunk_where,
- workflow_filter=chunk_wf_filter,
- )
- limit = max_rows_per_chunk if max_rows_per_chunk else 500000
- chunk_params["offset"] = 0
- chunk_params["limit"] = limit
- result = read_sql_df(chunk_sql, chunk_params)
- return result if result is not None else pd.DataFrame()
-
+
+ def _run_reject_chunk(chunk, max_rows_per_chunk=None):
+ """Execute one chunk of the reject query via read_sql_df_slow.
+
+ NOTE:
+ `max_rows_per_chunk` is used as paging size per Oracle roundtrip,
+ not a hard cap. We continue fetching until a page returns less
+ than page size, so chunk results are complete (no truncation).
+ """
+ chunk_where_parts: List[str] = []
+ chunk_params: Dict[str, Any] = {}
+ chunk_wf_filter = ""
+
+ if "chunk_start" in chunk:
+ # Time-range chunk
+ chunk_where_parts.append(
+ "r.TXNDATE >= TO_DATE(:start_date, 'YYYY-MM-DD')"
+ " AND r.TXNDATE < TO_DATE(:end_date, 'YYYY-MM-DD') + 1"
+ )
+ chunk_params["start_date"] = chunk["chunk_start"]
+ chunk_params["end_date"] = chunk["chunk_end"]
+ elif "ids" in chunk:
+ # ID-batch chunk
+ b = QueryBuilder()
+ b.add_in_condition("r.CONTAINERID", chunk["ids"])
+ cid_w, cid_p = b.build_where_only()
+ cid_c = cid_w.strip()
+ if cid_c.upper().startswith("WHERE "):
+ cid_c = cid_c[6:].strip()
+ chunk_where_parts.append(cid_c)
+ chunk_params.update(cid_p)
+ # Workflow filter for container mode
+ wfb = QueryBuilder()
+ wfb.add_in_condition("r0.CONTAINERID", chunk["ids"])
+ wf_w, _ = wfb.build_where_only()
+ wf_c = wf_w.strip()
+ if wf_c.upper().startswith("WHERE "):
+ wf_c = wf_c[6:].strip()
+ chunk_wf_filter = wf_c
+
+ chunk_where = " AND ".join(chunk_where_parts)
+ chunk_sql = _prepare_sql(
+ "list",
+ where_clause="",
+ base_variant="lot",
+ base_where=chunk_where,
+ workflow_filter=chunk_wf_filter,
+ )
+ page_size = int(max_rows_per_chunk) if max_rows_per_chunk else 500000
+ page_size = max(page_size, 1)
+ offset = 0
+ frames: List[pd.DataFrame] = []
+
+ while True:
+ page_params = dict(chunk_params)
+ page_params["offset"] = offset
+ page_params["limit"] = page_size
+ page_df = read_sql_df(chunk_sql, page_params)
+ if page_df is None or page_df.empty:
+ break
+ frames.append(page_df)
+ fetched = len(page_df)
+ if fetched < page_size:
+ break
+ offset += page_size
+
+ if not frames:
+ return pd.DataFrame()
+ if len(frames) == 1:
+ return frames[0]
+ return pd.concat(frames, ignore_index=True)
+
execute_plan(
engine_chunks,
_run_reject_chunk,
@@ -439,907 +584,956 @@ def execute_primary_query(
df = merge_chunks(
"reject",
engine_hash,
- max_total_rows=_REJECT_ENGINE_MAX_TOTAL_ROWS,
)
- else:
- # --- Direct path (short query, no engine overhead) ---
- sql = _prepare_sql(
- "list",
- where_clause="",
- base_variant="lot",
- base_where=base_where,
- workflow_filter=workflow_filter,
- )
- all_params = {**base_params, "offset": 0, "limit": 500000}
- df = read_sql_df(sql, all_params)
- if df is None:
- df = pd.DataFrame()
-
+ progress_meta = get_batch_progress("reject", engine_hash) or {}
+ has_partial_failure = str(
+ progress_meta.get("has_partial_failure", "")
+ ).strip().lower() in {"1", "true", "yes", "on"}
+ if has_partial_failure:
+ failed_raw = progress_meta.get("failed", "0")
+ try:
+ failed_count = max(int(str(failed_raw)), 0)
+ except Exception:
+ failed_count = 0
+
+ failed_ranges: List[Dict[str, str]] = []
+ raw_failed_ranges = progress_meta.get("failed_ranges", "")
+ if raw_failed_ranges:
+ try:
+ parsed = json.loads(raw_failed_ranges)
+ except Exception:
+ parsed = []
+ if isinstance(parsed, list):
+ for item in parsed:
+ if not isinstance(item, dict):
+ continue
+ start = str(item.get("start", "")).strip()
+ end = str(item.get("end", "")).strip()
+ if start and end:
+ failed_ranges.append({"start": start, "end": end})
+
+ partial_failure_meta = {
+ "has_partial_failure": True,
+ "failed_chunk_count": failed_count,
+ "failed_ranges": failed_ranges,
+ }
+ else:
+ # --- Direct path (short query, no engine overhead) ---
+ sql = _prepare_sql(
+ "list",
+ where_clause="",
+ base_variant="lot",
+ base_where=base_where,
+ workflow_filter=workflow_filter,
+ )
+ all_params = {**base_params, "offset": 0, "limit": 500000}
+ df = read_sql_df(sql, all_params)
+ if df is None:
+ df = pd.DataFrame()
+
# ---- Cache unfiltered, return filtered ----
+ if partial_failure_meta:
+ meta.update(partial_failure_meta)
+
+ stored_via_spool = False
if not df.empty:
- _store_query_result(query_id, df)
+ stored_via_spool = _store_query_result(query_id, df)
+ if partial_failure_meta.get("has_partial_failure"):
+ flag_ttl = (
+ _REJECT_ENGINE_SPOOL_TTL_SECONDS if stored_via_spool else _CACHE_TTL
+ )
+ _store_partial_failure_flag(
+ query_id,
+ partial_failure_meta.get("failed_chunk_count", 0),
+ partial_failure_meta.get("failed_ranges"),
+ flag_ttl,
+ )
+ else:
+ _clear_partial_failure_flag(query_id)
if engine_hash:
redis_clear_batch("reject", engine_hash)
filtered = _apply_policy_filters(
df,
- include_excluded_scrap=include_excluded_scrap,
- exclude_material_scrap=exclude_material_scrap,
- exclude_pb_diode=exclude_pb_diode,
- )
- return _build_primary_response(query_id, filtered, meta, resolution_info)
-
-
-def _apply_policy_filters(
- df: pd.DataFrame,
- *,
- include_excluded_scrap: bool = False,
- exclude_material_scrap: bool = True,
- exclude_pb_diode: bool = True,
-) -> pd.DataFrame:
- """Apply policy toggle filters in-memory (pandas).
-
- Mirrors the SQL-level policy from _build_where_clause but operates
- on the cached DataFrame so that toggling filters doesn't require
- a new Oracle round-trip.
- """
- if df is None or df.empty:
- return df
-
- mask = pd.Series(True, index=df.index)
-
- # ---- Material scrap exclusion ----
- if exclude_material_scrap and "SCRAP_OBJECTTYPE" in df.columns:
- obj_type = df["SCRAP_OBJECTTYPE"].fillna("").str.strip().str.upper()
- mask &= obj_type != "MATERIAL"
-
- # ---- PB diode exclusion ----
- if exclude_pb_diode and "PRODUCTLINENAME" in df.columns:
- mask &= ~df["PRODUCTLINENAME"].fillna("").str.match(r"(?i)^PB_")
-
- # ---- Scrap reason exclusion policy ----
- if not include_excluded_scrap:
- from mes_dashboard.services.scrap_reason_exclusion_cache import (
- get_excluded_reasons,
- )
-
- excluded = get_excluded_reasons()
- if excluded and "LOSSREASON_CODE" in df.columns:
- code_upper = df["LOSSREASON_CODE"].fillna("").str.strip().str.upper()
- mask &= ~code_upper.isin(excluded)
- if excluded and "LOSSREASONNAME" in df.columns:
- name_upper = df["LOSSREASONNAME"].fillna("").str.strip().str.upper()
- mask &= ~name_upper.isin(excluded)
-
- # Only keep reasons matching ^[0-9]{3}_ pattern
- if "LOSSREASONNAME" in df.columns:
- name_trimmed = df["LOSSREASONNAME"].fillna("").str.strip().str.upper()
- mask &= name_trimmed.str.match(r"^[0-9]{3}_")
- # Exclude XXX_ and ZZZ_ prefixes
- mask &= ~name_trimmed.str.match(r"^(XXX|ZZZ)_")
-
- return df[mask]
-
-
-def _build_primary_response(
- query_id: str,
- df: pd.DataFrame,
- meta: Dict[str, Any],
- resolution_info: Optional[Dict[str, Any]],
-) -> Dict[str, Any]:
- """Build the full response from a LOT-level DataFrame."""
- analytics_raw = _derive_analytics_raw(df)
- summary = _derive_summary_from_analytics(analytics_raw)
- trend_items = _derive_trend_from_analytics(analytics_raw)
- first_page = _paginate_detail(df, page=1, per_page=50)
- available = _extract_available_filters(df)
-
- result: Dict[str, Any] = {
- "query_id": query_id,
- "analytics_raw": analytics_raw,
- "summary": summary,
- "trend": {"items": trend_items, "granularity": "day"},
- "detail": first_page,
- "available_filters": available,
- "meta": meta,
- }
- if resolution_info is not None:
- result["resolution_info"] = resolution_info
- return result
-
-
-# ============================================================
-# View (supplementary + interactive filtering on cache)
-# ============================================================
-
-
-def apply_view(
- *,
- query_id: str,
- packages: Optional[List[str]] = None,
- workcenter_groups: Optional[List[str]] = None,
- reason: Optional[str] = None,
- metric_filter: str = "all",
- trend_dates: Optional[List[str]] = None,
- detail_reason: Optional[str] = None,
- pareto_dimension: Optional[str] = None,
- pareto_values: Optional[List[str]] = None,
- pareto_selections: Optional[Dict[str, List[str]]] = None,
- page: int = 1,
- per_page: int = 50,
- include_excluded_scrap: bool = False,
- exclude_material_scrap: bool = True,
- exclude_pb_diode: bool = True,
-) -> Optional[Dict[str, Any]]:
- """Read cache → apply filters → return derived data. Returns None if expired."""
- df = _get_cached_df(query_id)
- if df is None:
- return None
-
- # Apply policy filters first (cache stores unfiltered data)
- df = _apply_policy_filters(
- df,
- include_excluded_scrap=include_excluded_scrap,
- exclude_material_scrap=exclude_material_scrap,
- exclude_pb_diode=exclude_pb_diode,
- )
-
- filtered = _apply_supplementary_filters(
- df,
- packages=packages,
- workcenter_groups=workcenter_groups,
- reason=reason,
- metric_filter=metric_filter,
- )
-
- # Analytics always uses full date range (supplementary-filtered only).
- # The frontend derives trend from analytics_raw and filters Pareto by
- # selectedTrendDates client-side.
- analytics_raw = _derive_analytics_raw(filtered)
- summary = _derive_summary_from_analytics(analytics_raw)
-
- # Detail list: additionally filter by detail_reason and trend_dates
- detail_df = filtered
- if trend_dates:
- date_set = set(trend_dates)
- detail_df = detail_df[
- detail_df["TXN_DAY"].apply(lambda d: _to_date_str(d) in date_set)
- ]
- if detail_reason:
- detail_df = detail_df[
- detail_df["LOSSREASONNAME"].str.strip() == detail_reason.strip()
- ]
- detail_df = _apply_pareto_selection_filter(
- detail_df,
- pareto_dimension=pareto_dimension,
- pareto_values=pareto_values,
- pareto_selections=pareto_selections,
- )
-
- detail_page = _paginate_detail(detail_df, page=page, per_page=per_page)
-
- return {
- "analytics_raw": analytics_raw,
- "summary": summary,
- "detail": detail_page,
- }
-
-
-def _apply_supplementary_filters(
- df: pd.DataFrame,
- *,
- packages: Optional[List[str]] = None,
- workcenter_groups: Optional[List[str]] = None,
- reason: Optional[str] = None,
- metric_filter: str = "all",
-) -> pd.DataFrame:
- """Apply supplementary filters via pandas boolean indexing."""
- if df is None or df.empty:
- return df
-
- mask = pd.Series(True, index=df.index)
-
- if packages:
- pkg_set = {p.strip() for p in packages if p.strip()}
- if pkg_set and "PRODUCTLINENAME" in df.columns:
- mask &= df["PRODUCTLINENAME"].isin(pkg_set)
-
- if workcenter_groups:
- wc_groups = [g.strip() for g in workcenter_groups if g.strip()]
- if wc_groups:
- specs = get_specs_for_groups(wc_groups)
- if specs and "SPECNAME" in df.columns:
- spec_set = {s.upper() for s in specs}
- mask &= df["SPECNAME"].str.strip().str.upper().isin(spec_set)
- elif "WORKCENTER_GROUP" in df.columns:
- mask &= df["WORKCENTER_GROUP"].isin(wc_groups)
-
- if reason and "LOSSREASONNAME" in df.columns:
- mask &= df["LOSSREASONNAME"].str.strip() == reason.strip()
-
- if metric_filter == "reject" and "REJECT_TOTAL_QTY" in df.columns:
- mask &= df["REJECT_TOTAL_QTY"] > 0
- elif metric_filter == "defect" and "DEFECT_QTY" in df.columns:
- mask &= df["DEFECT_QTY"] > 0
-
- return df[mask]
-
-
-def _normalize_pareto_values(values: Optional[List[str]]) -> List[str]:
- normalized: List[str] = []
- seen = set()
- for value in values or []:
- item = _normalize_text(value)
- if not item or item in seen:
- continue
- seen.add(item)
- normalized.append(item)
- return normalized
-
-
-def _apply_pareto_selection_filter(
- df: pd.DataFrame,
- *,
- pareto_dimension: Optional[str] = None,
- pareto_values: Optional[List[str]] = None,
- pareto_selections: Optional[Dict[str, List[str]]] = None,
-) -> pd.DataFrame:
- """Apply Pareto multi-select filters on detail/export datasets."""
- if df is None or df.empty:
- return df
-
- normalized_selections = _normalize_pareto_selections(pareto_selections)
- if normalized_selections:
- filtered = df
- for dim in _PARETO_DIMENSIONS:
- selected_values = normalized_selections.get(dim)
- if not selected_values:
- continue
- dim_col = _DIM_TO_DF_COLUMN.get(dim)
- if not dim_col:
- raise ValueError(f"不支援的 pareto_dimension: {dim}")
- if dim_col not in filtered.columns:
- return filtered.iloc[0:0]
- value_set = set(selected_values)
- normalized_dimension_values = filtered[dim_col].map(
- lambda value: _normalize_text(value) or "(未知)"
- )
- filtered = filtered[normalized_dimension_values.isin(value_set)]
- if filtered.empty:
- return filtered
- return filtered
-
- normalized_values = _normalize_pareto_values(pareto_values)
- if not normalized_values:
- return df
-
- dimension = _normalize_text(pareto_dimension).lower() or "reason"
- dim_col = _DIM_TO_DF_COLUMN.get(dimension)
- if not dim_col:
- raise ValueError(f"不支援的 pareto_dimension: {pareto_dimension}")
- if dim_col not in df.columns:
- return df.iloc[0:0]
-
- value_set = set(normalized_values)
- normalized_dimension_values = df[dim_col].map(
- lambda value: _normalize_text(value) or "(未知)"
- )
- return df[normalized_dimension_values.isin(value_set)]
-
-
-# ============================================================
-# Derivation helpers
-# ============================================================
-
-
-def _derive_analytics_raw(df: pd.DataFrame) -> list:
- """GROUP BY (TXN_DAY, LOSSREASONNAME) → per date×reason rows."""
- if df is None or df.empty:
- return []
-
- agg_cols = {
- "MOVEIN_QTY": ("MOVEIN_QTY", "sum"),
- "REJECT_TOTAL_QTY": ("REJECT_TOTAL_QTY", "sum"),
- "DEFECT_QTY": ("DEFECT_QTY", "sum"),
- }
- # Add optional columns if present
- if "AFFECTED_WORKORDER_COUNT" in df.columns:
- agg_cols["AFFECTED_WORKORDER_COUNT"] = ("AFFECTED_WORKORDER_COUNT", "sum")
-
- grouped = (
- df.groupby(["TXN_DAY", "LOSSREASONNAME"], sort=True)
- .agg(**agg_cols)
- .reset_index()
- )
-
- # Count distinct CONTAINERIDs per group for AFFECTED_LOT_COUNT
- if "CONTAINERID" in df.columns:
- lot_counts = (
- df.groupby(["TXN_DAY", "LOSSREASONNAME"])["CONTAINERID"]
- .nunique()
- .reset_index()
- .rename(columns={"CONTAINERID": "AFFECTED_LOT_COUNT"})
- )
- grouped = grouped.merge(
- lot_counts, on=["TXN_DAY", "LOSSREASONNAME"], how="left"
- )
- else:
- grouped["AFFECTED_LOT_COUNT"] = 0
-
- items = []
- for _, row in grouped.iterrows():
- items.append(
- {
- "bucket_date": _to_date_str(row["TXN_DAY"]),
- "reason": _normalize_text(row["LOSSREASONNAME"]) or "(未填寫)",
- "MOVEIN_QTY": _as_int(row.get("MOVEIN_QTY")),
- "REJECT_TOTAL_QTY": _as_int(row.get("REJECT_TOTAL_QTY")),
- "DEFECT_QTY": _as_int(row.get("DEFECT_QTY")),
- "AFFECTED_LOT_COUNT": _as_int(row.get("AFFECTED_LOT_COUNT")),
- "AFFECTED_WORKORDER_COUNT": _as_int(
- row.get("AFFECTED_WORKORDER_COUNT")
- ),
- }
- )
- return items
-
-
-def _derive_summary_from_analytics(analytics_raw: list) -> dict:
- """Aggregate analytics_raw into a single summary dict."""
- movein = sum(r.get("MOVEIN_QTY", 0) for r in analytics_raw)
- reject_total = sum(r.get("REJECT_TOTAL_QTY", 0) for r in analytics_raw)
- defect = sum(r.get("DEFECT_QTY", 0) for r in analytics_raw)
- affected_lot = sum(r.get("AFFECTED_LOT_COUNT", 0) for r in analytics_raw)
- affected_wo = sum(r.get("AFFECTED_WORKORDER_COUNT", 0) for r in analytics_raw)
-
- total_scrap = reject_total + defect
- return {
- "MOVEIN_QTY": movein,
- "REJECT_TOTAL_QTY": reject_total,
- "DEFECT_QTY": defect,
- "REJECT_RATE_PCT": round((reject_total / movein * 100) if movein else 0, 4),
- "DEFECT_RATE_PCT": round((defect / movein * 100) if movein else 0, 4),
- "REJECT_SHARE_PCT": round(
- (reject_total / total_scrap * 100) if total_scrap else 0, 4
- ),
- "AFFECTED_LOT_COUNT": affected_lot,
- "AFFECTED_WORKORDER_COUNT": affected_wo,
- }
-
-
-def _derive_trend_from_analytics(analytics_raw: list) -> list:
- """Group analytics_raw by date into trend items."""
- by_date: Dict[str, Dict[str, int]] = {}
- for row in analytics_raw:
- d = row.get("bucket_date", "")
- if d not in by_date:
- by_date[d] = {"MOVEIN_QTY": 0, "REJECT_TOTAL_QTY": 0, "DEFECT_QTY": 0}
- by_date[d]["MOVEIN_QTY"] += row.get("MOVEIN_QTY", 0)
- by_date[d]["REJECT_TOTAL_QTY"] += row.get("REJECT_TOTAL_QTY", 0)
- by_date[d]["DEFECT_QTY"] += row.get("DEFECT_QTY", 0)
-
- items = []
- for date_str in sorted(by_date.keys()):
- vals = by_date[date_str]
- movein = vals["MOVEIN_QTY"]
- reject = vals["REJECT_TOTAL_QTY"]
- defect = vals["DEFECT_QTY"]
- items.append(
- {
- "bucket_date": date_str,
- "MOVEIN_QTY": movein,
- "REJECT_TOTAL_QTY": reject,
- "DEFECT_QTY": defect,
- "REJECT_RATE_PCT": round(
- (reject / movein * 100) if movein else 0, 4
- ),
- "DEFECT_RATE_PCT": round(
- (defect / movein * 100) if movein else 0, 4
- ),
- }
- )
- return items
-
-
-def _paginate_detail(
- df: pd.DataFrame, *, page: int = 1, per_page: int = 50
-) -> dict:
- """Sort + paginate LOT-level rows."""
- if df is None or df.empty:
- return {
- "items": [],
- "pagination": {
- "page": 1,
- "perPage": per_page,
- "total": 0,
- "totalPages": 1,
- },
- }
-
- page = max(int(page), 1)
- per_page = min(max(int(per_page), 1), 200)
-
- # Sort
- sort_cols = []
- sort_asc = []
- for col, asc in [
- ("TXN_DAY", False),
- ("WORKCENTERSEQUENCE_GROUP", True),
- ("WORKCENTERNAME", True),
- ("REJECT_TOTAL_QTY", False),
- ("CONTAINERNAME", True),
- ]:
- if col in df.columns:
- sort_cols.append(col)
- sort_asc.append(asc)
-
- if sort_cols:
- sorted_df = df.sort_values(sort_cols, ascending=sort_asc)
- else:
- sorted_df = df
-
- total = len(sorted_df)
- total_pages = max((total + per_page - 1) // per_page, 1)
- offset = (page - 1) * per_page
- page_df = sorted_df.iloc[offset : offset + per_page]
-
- items = []
- for _, row in page_df.iterrows():
- items.append(
- {
- "TXN_TIME": _to_datetime_str(row.get("TXN_TIME")),
- "TXN_DAY": _to_date_str(row.get("TXN_DAY")),
- "TXN_MONTH": _normalize_text(row.get("TXN_MONTH")),
- "WORKCENTER_GROUP": _normalize_text(row.get("WORKCENTER_GROUP")),
- "WORKCENTERNAME": _normalize_text(row.get("WORKCENTERNAME")),
- "SPECNAME": _normalize_text(row.get("SPECNAME")),
- "WORKFLOWNAME": _normalize_text(row.get("WORKFLOWNAME")),
- "EQUIPMENTNAME": _normalize_text(row.get("EQUIPMENTNAME")),
- "PRODUCTLINENAME": _normalize_text(row.get("PRODUCTLINENAME")),
- "PJ_TYPE": _normalize_text(row.get("PJ_TYPE")),
- "CONTAINERNAME": _normalize_text(row.get("CONTAINERNAME")),
- "PJ_FUNCTION": _normalize_text(row.get("PJ_FUNCTION")),
- "PRODUCTNAME": _normalize_text(row.get("PRODUCTNAME")),
- "LOSSREASONNAME": _normalize_text(row.get("LOSSREASONNAME")),
- "LOSSREASON_CODE": _normalize_text(row.get("LOSSREASON_CODE")),
- "REJECTCOMMENT": _normalize_text(row.get("REJECTCOMMENT")),
- "MOVEIN_QTY": _as_int(row.get("MOVEIN_QTY")),
- "REJECT_QTY": _as_int(row.get("REJECT_QTY")),
- "STANDBY_QTY": _as_int(row.get("STANDBY_QTY")),
- "QTYTOPROCESS_QTY": _as_int(row.get("QTYTOPROCESS_QTY")),
- "INPROCESS_QTY": _as_int(row.get("INPROCESS_QTY")),
- "PROCESSED_QTY": _as_int(row.get("PROCESSED_QTY")),
- "REJECT_TOTAL_QTY": _as_int(row.get("REJECT_TOTAL_QTY")),
- "DEFECT_QTY": _as_int(row.get("DEFECT_QTY")),
- "REJECT_RATE_PCT": round(
- _as_float(row.get("REJECT_RATE_PCT")), 4
- ),
- "DEFECT_RATE_PCT": round(
- _as_float(row.get("DEFECT_RATE_PCT")), 4
- ),
- "REJECT_SHARE_PCT": round(
- _as_float(row.get("REJECT_SHARE_PCT")), 4
- ),
- "AFFECTED_WORKORDER_COUNT": _as_int(
- row.get("AFFECTED_WORKORDER_COUNT")
- ),
- }
- )
-
- return {
- "items": items,
- "pagination": {
- "page": page,
- "perPage": per_page,
- "total": total,
- "totalPages": total_pages,
- },
- }
-
-
-def _extract_available_filters(df: pd.DataFrame) -> dict:
- """Extract distinct packages/reasons/WC groups from the full cache DF."""
- return {
- "workcenter_groups": _extract_workcenter_group_options(df),
- "packages": _extract_distinct_text_values(df, "PRODUCTLINENAME"),
- "reasons": _extract_distinct_text_values(df, "LOSSREASONNAME"),
- }
-
-
-# ============================================================
-# Dimension Pareto from cache
-# ============================================================
-
-# Dimension → DF column mapping (matches _DIMENSION_COLUMN_MAP in reject_history_service)
-_DIM_TO_DF_COLUMN = {
- "reason": "LOSSREASONNAME",
- "package": "PRODUCTLINENAME",
- "type": "PJ_TYPE",
- "workflow": "WORKFLOWNAME",
- "workcenter": "WORKCENTER_GROUP",
- "equipment": "PRIMARY_EQUIPMENTNAME",
-}
-_PARETO_DIMENSIONS = tuple(_DIM_TO_DF_COLUMN.keys())
-_PARETO_TOP20_DIMENSIONS = {"type", "workflow", "equipment"}
-
-
-def _normalize_metric_mode(metric_mode: str) -> str:
- mode = _normalize_text(metric_mode).lower()
- if mode not in {"reject_total", "defect"}:
- raise ValueError("Invalid metric_mode, supported: reject_total, defect")
- return mode
-
-
-def _normalize_pareto_scope(pareto_scope: str) -> str:
- scope = _normalize_text(pareto_scope).lower() or "top80"
- if scope not in {"top80", "all"}:
- raise ValueError("Invalid pareto_scope, supported: top80, all")
- return scope
-
-
-def _normalize_pareto_display_scope(display_scope: str) -> str:
- scope = _normalize_text(display_scope).lower() or "all"
- if scope not in {"all", "top20"}:
- raise ValueError("Invalid pareto_display_scope, supported: all, top20")
- return scope
-
-
-def _normalize_pareto_selections(
- pareto_selections: Optional[Dict[str, List[str]]],
-) -> Dict[str, List[str]]:
- normalized: Dict[str, List[str]] = {}
- for dim, values in (pareto_selections or {}).items():
- dim_key = _normalize_text(dim).lower()
- if not dim_key:
- continue
- if dim_key not in _DIM_TO_DF_COLUMN:
- raise ValueError(f"不支援的 pareto_dimension: {dim}")
- normalized_values = _normalize_pareto_values(values)
- if normalized_values:
- normalized[dim_key] = normalized_values
- return normalized
-
-
-def _build_dimension_pareto_items(
- df: pd.DataFrame,
- *,
- dim_col: str,
- metric_mode: str,
- pareto_scope: str,
-) -> List[Dict[str, Any]]:
- if df is None or df.empty:
- return []
- if dim_col not in df.columns:
- return []
-
- metric_col = "DEFECT_QTY" if metric_mode == "defect" else "REJECT_TOTAL_QTY"
- if metric_col not in df.columns:
- return []
-
- agg_dict = {}
- for col in ["MOVEIN_QTY", "REJECT_TOTAL_QTY", "DEFECT_QTY"]:
- if col in df.columns:
- agg_dict[col] = (col, "sum")
-
- grouped = df.groupby(dim_col, sort=False).agg(**agg_dict).reset_index()
- if grouped.empty:
- return []
-
- if "CONTAINERID" in df.columns:
- lot_counts = (
- df.groupby(dim_col)["CONTAINERID"]
- .nunique()
- .reset_index()
- .rename(columns={"CONTAINERID": "AFFECTED_LOT_COUNT"})
- )
- grouped = grouped.merge(lot_counts, on=dim_col, how="left")
- else:
- grouped["AFFECTED_LOT_COUNT"] = 0
-
- grouped["METRIC_VALUE"] = grouped[metric_col].fillna(0)
- grouped = grouped[grouped["METRIC_VALUE"] > 0].sort_values(
- "METRIC_VALUE", ascending=False
- )
- if grouped.empty:
- return []
-
- total_metric = grouped["METRIC_VALUE"].sum()
- grouped["PCT"] = (grouped["METRIC_VALUE"] / total_metric * 100).round(4)
- grouped["CUM_PCT"] = grouped["PCT"].cumsum().round(4)
-
- items: List[Dict[str, Any]] = []
- for _, row in grouped.iterrows():
- items.append({
- "reason": _normalize_text(row.get(dim_col)) or "(未知)",
- "metric_value": _as_float(row.get("METRIC_VALUE")),
- "MOVEIN_QTY": _as_int(row.get("MOVEIN_QTY")),
- "REJECT_TOTAL_QTY": _as_int(row.get("REJECT_TOTAL_QTY")),
- "DEFECT_QTY": _as_int(row.get("DEFECT_QTY")),
- "count": _as_int(row.get("AFFECTED_LOT_COUNT")),
- "pct": round(_as_float(row.get("PCT")), 4),
- "cumPct": round(_as_float(row.get("CUM_PCT")), 4),
- })
-
- if pareto_scope == "top80" and items:
- top_items = [item for item in items if _as_float(item.get("cumPct")) <= 80.0]
- if not top_items:
- top_items = [items[0]]
- return top_items
- return items
-
-
-def _apply_cross_filter(
- df: pd.DataFrame,
- selections: Dict[str, List[str]],
- exclude_dim: str,
-) -> pd.DataFrame:
- if df is None or df.empty or not selections:
- return df
-
- filtered = df
- for dim in _PARETO_DIMENSIONS:
- if dim == exclude_dim:
- continue
- selected_values = selections.get(dim)
- if not selected_values:
- continue
- dim_col = _DIM_TO_DF_COLUMN.get(dim)
- if not dim_col:
- raise ValueError(f"不支援的 pareto_dimension: {dim}")
- if dim_col not in filtered.columns:
- return filtered.iloc[0:0]
- value_set = set(selected_values)
- normalized_dimension_values = filtered[dim_col].map(
- lambda value: _normalize_text(value) or "(未知)"
- )
- filtered = filtered[normalized_dimension_values.isin(value_set)]
- if filtered.empty:
- return filtered
- return filtered
-
-
-def compute_dimension_pareto(
- *,
- query_id: str,
- dimension: str = "reason",
- metric_mode: str = "reject_total",
- pareto_scope: str = "top80",
- packages: Optional[List[str]] = None,
- workcenter_groups: Optional[List[str]] = None,
- reason: Optional[str] = None,
- trend_dates: Optional[List[str]] = None,
- include_excluded_scrap: bool = False,
- exclude_material_scrap: bool = True,
- exclude_pb_diode: bool = True,
-) -> Optional[Dict[str, Any]]:
- """Compute dimension pareto from cached DataFrame (no Oracle query)."""
- metric_mode = _normalize_metric_mode(metric_mode)
- pareto_scope = _normalize_pareto_scope(pareto_scope)
- dimension = _normalize_text(dimension).lower() or "reason"
- if dimension not in _DIM_TO_DF_COLUMN:
- raise ValueError(
- f"Invalid dimension, supported: {', '.join(sorted(_DIM_TO_DF_COLUMN.keys()))}"
- )
-
- df = _get_cached_df(query_id)
- if df is None:
- return None
-
- # Keep cache-based pareto behavior aligned with primary/view policy filters.
- df = _apply_policy_filters(
- df,
- include_excluded_scrap=include_excluded_scrap,
- exclude_material_scrap=exclude_material_scrap,
- exclude_pb_diode=exclude_pb_diode,
- )
- if df is None or df.empty:
- return {"items": [], "dimension": dimension, "metric_mode": metric_mode}
-
- dim_col = _DIM_TO_DF_COLUMN.get(dimension)
- if dim_col not in df.columns:
- return {"items": [], "dimension": dimension, "metric_mode": metric_mode}
-
- # Apply supplementary filters
- filtered = _apply_supplementary_filters(
- df,
- packages=packages,
- workcenter_groups=workcenter_groups,
- reason=reason,
- )
- if filtered is None or filtered.empty:
- return {"items": [], "dimension": dimension, "metric_mode": metric_mode}
-
- # Apply trend date filter
- if trend_dates and "TXN_DAY" in filtered.columns:
- date_set = set(trend_dates)
- filtered = filtered[
- filtered["TXN_DAY"].apply(lambda d: _to_date_str(d) in date_set)
- ]
- if filtered.empty:
- return {"items": [], "dimension": dimension, "metric_mode": metric_mode}
-
- items = _build_dimension_pareto_items(
- filtered,
- dim_col=dim_col,
- metric_mode=metric_mode,
- pareto_scope=pareto_scope,
- )
-
- return {
- "items": items,
- "dimension": dimension,
- "metric_mode": metric_mode,
- }
-
-
-def compute_batch_pareto(
- *,
- query_id: str,
- metric_mode: str = "reject_total",
- pareto_scope: str = "top80",
- pareto_display_scope: str = "all",
- packages: Optional[List[str]] = None,
- workcenter_groups: Optional[List[str]] = None,
- reason: Optional[str] = None,
- trend_dates: Optional[List[str]] = None,
- pareto_selections: Optional[Dict[str, List[str]]] = None,
- include_excluded_scrap: bool = False,
- exclude_material_scrap: bool = True,
- exclude_pb_diode: bool = True,
-) -> Optional[Dict[str, Any]]:
- """Compute all six Pareto dimensions from cached DataFrame (no Oracle query)."""
- metric_mode = _normalize_metric_mode(metric_mode)
- pareto_scope = _normalize_pareto_scope(pareto_scope)
- pareto_display_scope = _normalize_pareto_display_scope(pareto_display_scope)
- normalized_selections = _normalize_pareto_selections(pareto_selections)
-
- df = _get_cached_df(query_id)
- if df is None:
- return None
-
- df = _apply_policy_filters(
- df,
- include_excluded_scrap=include_excluded_scrap,
- exclude_material_scrap=exclude_material_scrap,
- exclude_pb_diode=exclude_pb_diode,
- )
- if df is None or df.empty:
- return {
- "dimensions": {
- dim: {"items": [], "dimension": dim, "metric_mode": metric_mode}
- for dim in _PARETO_DIMENSIONS
- }
- }
-
- filtered = _apply_supplementary_filters(
- df,
- packages=packages,
- workcenter_groups=workcenter_groups,
- reason=reason,
- )
- if filtered is None or filtered.empty:
- return {
- "dimensions": {
- dim: {"items": [], "dimension": dim, "metric_mode": metric_mode}
- for dim in _PARETO_DIMENSIONS
- }
- }
-
- if trend_dates and "TXN_DAY" in filtered.columns:
- date_set = set(trend_dates)
- filtered = filtered[
- filtered["TXN_DAY"].apply(lambda d: _to_date_str(d) in date_set)
- ]
-
- dimensions: Dict[str, Dict[str, Any]] = {}
- for dim in _PARETO_DIMENSIONS:
- dim_col = _DIM_TO_DF_COLUMN.get(dim)
- dim_df = _apply_cross_filter(filtered, normalized_selections, exclude_dim=dim)
- items = _build_dimension_pareto_items(
- dim_df,
- dim_col=dim_col,
- metric_mode=metric_mode,
- pareto_scope=pareto_scope,
- )
- if pareto_display_scope == "top20" and dim in _PARETO_TOP20_DIMENSIONS:
- items = items[:20]
- dimensions[dim] = {
- "items": items,
- "dimension": dim,
- "metric_mode": metric_mode,
- }
-
- return {
- "dimensions": dimensions,
- "metric_mode": metric_mode,
- "pareto_scope": pareto_scope,
- "pareto_display_scope": pareto_display_scope,
- }
-
-
-# ============================================================
-# CSV export from cache
-# ============================================================
-
-
-def export_csv_from_cache(
- *,
- query_id: str,
- packages: Optional[List[str]] = None,
- workcenter_groups: Optional[List[str]] = None,
- reason: Optional[str] = None,
- metric_filter: str = "all",
- trend_dates: Optional[List[str]] = None,
- detail_reason: Optional[str] = None,
- pareto_dimension: Optional[str] = None,
- pareto_values: Optional[List[str]] = None,
- pareto_selections: Optional[Dict[str, List[str]]] = None,
- include_excluded_scrap: bool = False,
- exclude_material_scrap: bool = True,
- exclude_pb_diode: bool = True,
-) -> Optional[list]:
- """Read cache → apply filters → return list of dicts for CSV export."""
- df = _get_cached_df(query_id)
- if df is None:
- return None
-
- df = _apply_policy_filters(
- df,
- include_excluded_scrap=include_excluded_scrap,
- exclude_material_scrap=exclude_material_scrap,
- exclude_pb_diode=exclude_pb_diode,
- )
-
- filtered = _apply_supplementary_filters(
- df,
- packages=packages,
- workcenter_groups=workcenter_groups,
- reason=reason,
- metric_filter=metric_filter,
- )
-
- if trend_dates:
- date_set = set(trend_dates)
- filtered = filtered[
- filtered["TXN_DAY"].apply(lambda d: _to_date_str(d) in date_set)
- ]
- if detail_reason and "LOSSREASONNAME" in filtered.columns:
- filtered = filtered[
- filtered["LOSSREASONNAME"].str.strip() == detail_reason.strip()
- ]
- filtered = _apply_pareto_selection_filter(
- filtered,
- pareto_dimension=pareto_dimension,
- pareto_values=pareto_values,
- pareto_selections=pareto_selections,
- )
-
- rows = []
- for _, row in filtered.iterrows():
- rows.append(
- {
- "LOT": _normalize_text(row.get("CONTAINERNAME")),
- "WORKCENTER": _normalize_text(row.get("WORKCENTERNAME")),
- "WORKCENTER_GROUP": _normalize_text(row.get("WORKCENTER_GROUP")),
- "Package": _normalize_text(row.get("PRODUCTLINENAME")),
- "FUNCTION": _normalize_text(row.get("PJ_FUNCTION")),
- "TYPE": _normalize_text(row.get("PJ_TYPE")),
- "WORKFLOW": _normalize_text(row.get("WORKFLOWNAME")),
- "PRODUCT": _normalize_text(row.get("PRODUCTNAME")),
- "原因": _normalize_text(row.get("LOSSREASONNAME")),
- "EQUIPMENT": _normalize_text(row.get("EQUIPMENTNAME")),
- "COMMENT": _normalize_text(row.get("REJECTCOMMENT")),
- "SPEC": _normalize_text(row.get("SPECNAME")),
- "REJECT_QTY": _as_int(row.get("REJECT_QTY")),
- "STANDBY_QTY": _as_int(row.get("STANDBY_QTY")),
- "QTYTOPROCESS_QTY": _as_int(row.get("QTYTOPROCESS_QTY")),
- "INPROCESS_QTY": _as_int(row.get("INPROCESS_QTY")),
- "PROCESSED_QTY": _as_int(row.get("PROCESSED_QTY")),
- "扣帳報廢量": _as_int(row.get("REJECT_TOTAL_QTY")),
- "不扣帳報廢量": _as_int(row.get("DEFECT_QTY")),
- "MOVEIN_QTY": _as_int(row.get("MOVEIN_QTY")),
- "報廢時間": _to_datetime_str(row.get("TXN_TIME")),
- "日期": _to_date_str(row.get("TXN_DAY")),
- }
- )
- return rows
+ include_excluded_scrap=include_excluded_scrap,
+ exclude_material_scrap=exclude_material_scrap,
+ exclude_pb_diode=exclude_pb_diode,
+ )
+ return _build_primary_response(query_id, filtered, meta, resolution_info)
+
+
+def _apply_policy_filters(
+ df: pd.DataFrame,
+ *,
+ include_excluded_scrap: bool = False,
+ exclude_material_scrap: bool = True,
+ exclude_pb_diode: bool = True,
+) -> pd.DataFrame:
+ """Apply policy toggle filters in-memory (pandas).
+
+ Mirrors the SQL-level policy from _build_where_clause but operates
+ on the cached DataFrame so that toggling filters doesn't require
+ a new Oracle round-trip.
+ """
+ if df is None or df.empty:
+ return df
+
+ mask = pd.Series(True, index=df.index)
+
+ # ---- Material scrap exclusion ----
+ if exclude_material_scrap and "SCRAP_OBJECTTYPE" in df.columns:
+ obj_type = df["SCRAP_OBJECTTYPE"].fillna("").str.strip().str.upper()
+ mask &= obj_type != "MATERIAL"
+
+ # ---- PB diode exclusion ----
+ if exclude_pb_diode and "PRODUCTLINENAME" in df.columns:
+ mask &= ~df["PRODUCTLINENAME"].fillna("").str.match(r"(?i)^PB_")
+
+ # ---- Scrap reason exclusion policy ----
+ if not include_excluded_scrap:
+ from mes_dashboard.services.scrap_reason_exclusion_cache import (
+ get_excluded_reasons,
+ )
+
+ excluded = get_excluded_reasons()
+ if excluded and "LOSSREASON_CODE" in df.columns:
+ code_upper = df["LOSSREASON_CODE"].fillna("").str.strip().str.upper()
+ mask &= ~code_upper.isin(excluded)
+ if excluded and "LOSSREASONNAME" in df.columns:
+ name_upper = df["LOSSREASONNAME"].fillna("").str.strip().str.upper()
+ mask &= ~name_upper.isin(excluded)
+
+ # Only keep reasons matching ^[0-9]{3}_ pattern
+ if "LOSSREASONNAME" in df.columns:
+ name_trimmed = df["LOSSREASONNAME"].fillna("").str.strip().str.upper()
+ mask &= name_trimmed.str.match(r"^[0-9]{3}_")
+ # Exclude XXX_ and ZZZ_ prefixes
+ mask &= ~name_trimmed.str.match(r"^(XXX|ZZZ)_")
+
+ return df[mask]
+
+
+def _build_primary_response(
+ query_id: str,
+ df: pd.DataFrame,
+ meta: Dict[str, Any],
+ resolution_info: Optional[Dict[str, Any]],
+) -> Dict[str, Any]:
+ """Build the full response from a LOT-level DataFrame."""
+ analytics_raw = _derive_analytics_raw(df)
+ summary = _derive_summary_from_analytics(analytics_raw)
+ trend_items = _derive_trend_from_analytics(analytics_raw)
+ first_page = _paginate_detail(df, page=1, per_page=50)
+ available = _extract_available_filters(df)
+
+ result: Dict[str, Any] = {
+ "query_id": query_id,
+ "analytics_raw": analytics_raw,
+ "summary": summary,
+ "trend": {"items": trend_items, "granularity": "day"},
+ "detail": first_page,
+ "available_filters": available,
+ "meta": meta,
+ }
+ if resolution_info is not None:
+ result["resolution_info"] = resolution_info
+ return result
+
+
+# ============================================================
+# View (supplementary + interactive filtering on cache)
+# ============================================================
+
+
+def apply_view(
+ *,
+ query_id: str,
+ packages: Optional[List[str]] = None,
+ workcenter_groups: Optional[List[str]] = None,
+ reasons: Optional[List[str]] = None,
+ metric_filter: str = "all",
+ trend_dates: Optional[List[str]] = None,
+ detail_reason: Optional[str] = None,
+ pareto_dimension: Optional[str] = None,
+ pareto_values: Optional[List[str]] = None,
+ pareto_selections: Optional[Dict[str, List[str]]] = None,
+ page: int = 1,
+ per_page: int = 50,
+ include_excluded_scrap: bool = False,
+ exclude_material_scrap: bool = True,
+ exclude_pb_diode: bool = True,
+) -> Optional[Dict[str, Any]]:
+ """Read cache → apply filters → return derived data. Returns None if expired."""
+ df = _get_cached_df(query_id)
+ if df is None:
+ return None
+
+ # Apply policy filters first (cache stores unfiltered data)
+ df = _apply_policy_filters(
+ df,
+ include_excluded_scrap=include_excluded_scrap,
+ exclude_material_scrap=exclude_material_scrap,
+ exclude_pb_diode=exclude_pb_diode,
+ )
+
+ filtered = _apply_supplementary_filters(
+ df,
+ packages=packages,
+ workcenter_groups=workcenter_groups,
+ reasons=reasons,
+ metric_filter=metric_filter,
+ )
+
+ # Analytics always uses full date range (supplementary-filtered only).
+ # The frontend derives trend from analytics_raw and filters Pareto by
+ # selectedTrendDates client-side.
+ analytics_raw = _derive_analytics_raw(filtered)
+ summary = _derive_summary_from_analytics(analytics_raw)
+
+ # Detail list: additionally filter by detail_reason and trend_dates
+ detail_df = filtered
+ if trend_dates:
+ date_set = set(trend_dates)
+ detail_df = detail_df[
+ detail_df["TXN_DAY"].apply(lambda d: _to_date_str(d) in date_set)
+ ]
+ if detail_reason:
+ detail_df = detail_df[
+ detail_df["LOSSREASONNAME"].str.strip() == detail_reason.strip()
+ ]
+ detail_df = _apply_pareto_selection_filter(
+ detail_df,
+ pareto_dimension=pareto_dimension,
+ pareto_values=pareto_values,
+ pareto_selections=pareto_selections,
+ )
+
+ detail_page = _paginate_detail(detail_df, page=page, per_page=per_page)
+
+ return {
+ "analytics_raw": analytics_raw,
+ "summary": summary,
+ "detail": detail_page,
+ }
+
+
+def _apply_supplementary_filters(
+ df: pd.DataFrame,
+ *,
+ packages: Optional[List[str]] = None,
+ workcenter_groups: Optional[List[str]] = None,
+ reasons: Optional[List[str]] = None,
+ metric_filter: str = "all",
+) -> pd.DataFrame:
+ """Apply supplementary filters via pandas boolean indexing."""
+ if df is None or df.empty:
+ return df
+
+ mask = pd.Series(True, index=df.index)
+
+ if packages:
+ pkg_set = {p.strip() for p in packages if p.strip()}
+ if pkg_set and "PRODUCTLINENAME" in df.columns:
+ mask &= df["PRODUCTLINENAME"].isin(pkg_set)
+
+ if workcenter_groups:
+ wc_groups = [g.strip() for g in workcenter_groups if g.strip()]
+ if wc_groups:
+ specs = get_specs_for_groups(wc_groups)
+ if specs and "SPECNAME" in df.columns:
+ spec_set = {s.upper() for s in specs}
+ mask &= df["SPECNAME"].str.strip().str.upper().isin(spec_set)
+ elif "WORKCENTER_GROUP" in df.columns:
+ mask &= df["WORKCENTER_GROUP"].isin(wc_groups)
+
+ if reasons and "LOSSREASONNAME" in df.columns:
+ reason_set = {r.strip() for r in reasons if r.strip()}
+ if reason_set:
+ mask &= df["LOSSREASONNAME"].str.strip().isin(reason_set)
+
+ if metric_filter == "reject" and "REJECT_TOTAL_QTY" in df.columns:
+ mask &= df["REJECT_TOTAL_QTY"] > 0
+ elif metric_filter == "defect" and "DEFECT_QTY" in df.columns:
+ mask &= df["DEFECT_QTY"] > 0
+
+ return df[mask]
+
+
+def _normalize_pareto_values(values: Optional[List[str]]) -> List[str]:
+ normalized: List[str] = []
+ seen = set()
+ for value in values or []:
+ item = _normalize_text(value)
+ if not item or item in seen:
+ continue
+ seen.add(item)
+ normalized.append(item)
+ return normalized
+
+
+def _apply_pareto_selection_filter(
+ df: pd.DataFrame,
+ *,
+ pareto_dimension: Optional[str] = None,
+ pareto_values: Optional[List[str]] = None,
+ pareto_selections: Optional[Dict[str, List[str]]] = None,
+) -> pd.DataFrame:
+ """Apply Pareto multi-select filters on detail/export datasets."""
+ if df is None or df.empty:
+ return df
+
+ normalized_selections = _normalize_pareto_selections(pareto_selections)
+ if normalized_selections:
+ filtered = df
+ for dim in _PARETO_DIMENSIONS:
+ selected_values = normalized_selections.get(dim)
+ if not selected_values:
+ continue
+ dim_col = _DIM_TO_DF_COLUMN.get(dim)
+ if not dim_col:
+ raise ValueError(f"不支援的 pareto_dimension: {dim}")
+ if dim_col not in filtered.columns:
+ return filtered.iloc[0:0]
+ value_set = set(selected_values)
+ normalized_dimension_values = filtered[dim_col].map(
+ lambda value: _normalize_text(value) or "(未知)"
+ )
+ filtered = filtered[normalized_dimension_values.isin(value_set)]
+ if filtered.empty:
+ return filtered
+ return filtered
+
+ normalized_values = _normalize_pareto_values(pareto_values)
+ if not normalized_values:
+ return df
+
+ dimension = _normalize_text(pareto_dimension).lower() or "reason"
+ dim_col = _DIM_TO_DF_COLUMN.get(dimension)
+ if not dim_col:
+ raise ValueError(f"不支援的 pareto_dimension: {pareto_dimension}")
+ if dim_col not in df.columns:
+ return df.iloc[0:0]
+
+ value_set = set(normalized_values)
+ normalized_dimension_values = df[dim_col].map(
+ lambda value: _normalize_text(value) or "(未知)"
+ )
+ return df[normalized_dimension_values.isin(value_set)]
+
+
+# ============================================================
+# Derivation helpers
+# ============================================================
+
+
+def _derive_analytics_raw(df: pd.DataFrame) -> list:
+ """GROUP BY (TXN_DAY, LOSSREASONNAME) → per date×reason rows."""
+ if df is None or df.empty:
+ return []
+
+ agg_cols = {
+ "MOVEIN_QTY": ("MOVEIN_QTY", "sum"),
+ "REJECT_TOTAL_QTY": ("REJECT_TOTAL_QTY", "sum"),
+ "DEFECT_QTY": ("DEFECT_QTY", "sum"),
+ }
+ # Add optional columns if present
+ if "AFFECTED_WORKORDER_COUNT" in df.columns:
+ agg_cols["AFFECTED_WORKORDER_COUNT"] = ("AFFECTED_WORKORDER_COUNT", "sum")
+
+ grouped = (
+ df.groupby(["TXN_DAY", "LOSSREASONNAME"], sort=True)
+ .agg(**agg_cols)
+ .reset_index()
+ )
+
+ # Count distinct CONTAINERIDs per group for AFFECTED_LOT_COUNT
+ if "CONTAINERID" in df.columns:
+ lot_counts = (
+ df.groupby(["TXN_DAY", "LOSSREASONNAME"])["CONTAINERID"]
+ .nunique()
+ .reset_index()
+ .rename(columns={"CONTAINERID": "AFFECTED_LOT_COUNT"})
+ )
+ grouped = grouped.merge(
+ lot_counts, on=["TXN_DAY", "LOSSREASONNAME"], how="left"
+ )
+ else:
+ grouped["AFFECTED_LOT_COUNT"] = 0
+
+ items = []
+ for _, row in grouped.iterrows():
+ items.append(
+ {
+ "bucket_date": _to_date_str(row["TXN_DAY"]),
+ "reason": _normalize_text(row["LOSSREASONNAME"]) or "(未填寫)",
+ "MOVEIN_QTY": _as_int(row.get("MOVEIN_QTY")),
+ "REJECT_TOTAL_QTY": _as_int(row.get("REJECT_TOTAL_QTY")),
+ "DEFECT_QTY": _as_int(row.get("DEFECT_QTY")),
+ "AFFECTED_LOT_COUNT": _as_int(row.get("AFFECTED_LOT_COUNT")),
+ "AFFECTED_WORKORDER_COUNT": _as_int(
+ row.get("AFFECTED_WORKORDER_COUNT")
+ ),
+ }
+ )
+ return items
+
+
+def _derive_summary_from_analytics(analytics_raw: list) -> dict:
+ """Aggregate analytics_raw into a single summary dict."""
+ movein = sum(r.get("MOVEIN_QTY", 0) for r in analytics_raw)
+ reject_total = sum(r.get("REJECT_TOTAL_QTY", 0) for r in analytics_raw)
+ defect = sum(r.get("DEFECT_QTY", 0) for r in analytics_raw)
+ affected_lot = sum(r.get("AFFECTED_LOT_COUNT", 0) for r in analytics_raw)
+ affected_wo = sum(r.get("AFFECTED_WORKORDER_COUNT", 0) for r in analytics_raw)
+
+ total_scrap = reject_total + defect
+ return {
+ "MOVEIN_QTY": movein,
+ "REJECT_TOTAL_QTY": reject_total,
+ "DEFECT_QTY": defect,
+ "REJECT_RATE_PCT": round((reject_total / movein * 100) if movein else 0, 4),
+ "DEFECT_RATE_PCT": round((defect / movein * 100) if movein else 0, 4),
+ "REJECT_SHARE_PCT": round(
+ (reject_total / total_scrap * 100) if total_scrap else 0, 4
+ ),
+ "AFFECTED_LOT_COUNT": affected_lot,
+ "AFFECTED_WORKORDER_COUNT": affected_wo,
+ }
+
+
+def _derive_trend_from_analytics(analytics_raw: list) -> list:
+ """Group analytics_raw by date into trend items."""
+ by_date: Dict[str, Dict[str, int]] = {}
+ for row in analytics_raw:
+ d = row.get("bucket_date", "")
+ if d not in by_date:
+ by_date[d] = {"MOVEIN_QTY": 0, "REJECT_TOTAL_QTY": 0, "DEFECT_QTY": 0}
+ by_date[d]["MOVEIN_QTY"] += row.get("MOVEIN_QTY", 0)
+ by_date[d]["REJECT_TOTAL_QTY"] += row.get("REJECT_TOTAL_QTY", 0)
+ by_date[d]["DEFECT_QTY"] += row.get("DEFECT_QTY", 0)
+
+ items = []
+ for date_str in sorted(by_date.keys()):
+ vals = by_date[date_str]
+ movein = vals["MOVEIN_QTY"]
+ reject = vals["REJECT_TOTAL_QTY"]
+ defect = vals["DEFECT_QTY"]
+ items.append(
+ {
+ "bucket_date": date_str,
+ "MOVEIN_QTY": movein,
+ "REJECT_TOTAL_QTY": reject,
+ "DEFECT_QTY": defect,
+ "REJECT_RATE_PCT": round(
+ (reject / movein * 100) if movein else 0, 4
+ ),
+ "DEFECT_RATE_PCT": round(
+ (defect / movein * 100) if movein else 0, 4
+ ),
+ }
+ )
+ return items
+
+
+def _paginate_detail(
+ df: pd.DataFrame, *, page: int = 1, per_page: int = 50
+) -> dict:
+ """Sort + paginate LOT-level rows."""
+ if df is None or df.empty:
+ return {
+ "items": [],
+ "pagination": {
+ "page": 1,
+ "perPage": per_page,
+ "total": 0,
+ "totalPages": 1,
+ },
+ }
+
+ page = max(int(page), 1)
+ per_page = min(max(int(per_page), 1), 200)
+
+ # Sort
+ sort_cols = []
+ sort_asc = []
+ for col, asc in [
+ ("TXN_DAY", False),
+ ("WORKCENTERSEQUENCE_GROUP", True),
+ ("WORKCENTERNAME", True),
+ ("REJECT_TOTAL_QTY", False),
+ ("CONTAINERNAME", True),
+ ]:
+ if col in df.columns:
+ sort_cols.append(col)
+ sort_asc.append(asc)
+
+ if sort_cols:
+ sorted_df = df.sort_values(sort_cols, ascending=sort_asc)
+ else:
+ sorted_df = df
+
+ total = len(sorted_df)
+ total_pages = max((total + per_page - 1) // per_page, 1)
+ offset = (page - 1) * per_page
+ page_df = sorted_df.iloc[offset : offset + per_page]
+
+ items = []
+ for _, row in page_df.iterrows():
+ items.append(
+ {
+ "TXN_TIME": _to_datetime_str(row.get("TXN_TIME")),
+ "TXN_DAY": _to_date_str(row.get("TXN_DAY")),
+ "TXN_MONTH": _normalize_text(row.get("TXN_MONTH")),
+ "WORKCENTER_GROUP": _normalize_text(row.get("WORKCENTER_GROUP")),
+ "WORKCENTERNAME": _normalize_text(row.get("WORKCENTERNAME")),
+ "SPECNAME": _normalize_text(row.get("SPECNAME")),
+ "WORKFLOWNAME": _normalize_text(row.get("WORKFLOWNAME")),
+ "EQUIPMENTNAME": _normalize_text(row.get("EQUIPMENTNAME")),
+ "PRODUCTLINENAME": _normalize_text(row.get("PRODUCTLINENAME")),
+ "PJ_TYPE": _normalize_text(row.get("PJ_TYPE")),
+ "CONTAINERNAME": _normalize_text(row.get("CONTAINERNAME")),
+ "PJ_FUNCTION": _normalize_text(row.get("PJ_FUNCTION")),
+ "PRODUCTNAME": _normalize_text(row.get("PRODUCTNAME")),
+ "LOSSREASONNAME": _normalize_text(row.get("LOSSREASONNAME")),
+ "LOSSREASON_CODE": _normalize_text(row.get("LOSSREASON_CODE")),
+ "REJECTCOMMENT": _normalize_text(row.get("REJECTCOMMENT")),
+ "MOVEIN_QTY": _as_int(row.get("MOVEIN_QTY")),
+ "REJECT_QTY": _as_int(row.get("REJECT_QTY")),
+ "STANDBY_QTY": _as_int(row.get("STANDBY_QTY")),
+ "QTYTOPROCESS_QTY": _as_int(row.get("QTYTOPROCESS_QTY")),
+ "INPROCESS_QTY": _as_int(row.get("INPROCESS_QTY")),
+ "PROCESSED_QTY": _as_int(row.get("PROCESSED_QTY")),
+ "REJECT_TOTAL_QTY": _as_int(row.get("REJECT_TOTAL_QTY")),
+ "DEFECT_QTY": _as_int(row.get("DEFECT_QTY")),
+ "REJECT_RATE_PCT": round(
+ _as_float(row.get("REJECT_RATE_PCT")), 4
+ ),
+ "DEFECT_RATE_PCT": round(
+ _as_float(row.get("DEFECT_RATE_PCT")), 4
+ ),
+ "REJECT_SHARE_PCT": round(
+ _as_float(row.get("REJECT_SHARE_PCT")), 4
+ ),
+ "AFFECTED_WORKORDER_COUNT": _as_int(
+ row.get("AFFECTED_WORKORDER_COUNT")
+ ),
+ }
+ )
+
+ return {
+ "items": items,
+ "pagination": {
+ "page": page,
+ "perPage": per_page,
+ "total": total,
+ "totalPages": total_pages,
+ },
+ }
+
+
+def _extract_available_filters(df: pd.DataFrame) -> dict:
+ """Extract distinct packages/reasons/WC groups from the full cache DF."""
+ return {
+ "workcenter_groups": _extract_workcenter_group_options(df),
+ "packages": _extract_distinct_text_values(df, "PRODUCTLINENAME"),
+ "reasons": _extract_distinct_text_values(df, "LOSSREASONNAME"),
+ }
+
+
+# ============================================================
+# Dimension Pareto from cache
+# ============================================================
+
+# Dimension → DF column mapping (matches _DIMENSION_COLUMN_MAP in reject_history_service)
+_DIM_TO_DF_COLUMN = {
+ "reason": "LOSSREASONNAME",
+ "package": "PRODUCTLINENAME",
+ "type": "PJ_TYPE",
+ "workflow": "WORKFLOWNAME",
+ "workcenter": "WORKCENTER_GROUP",
+ "equipment": "PRIMARY_EQUIPMENTNAME",
+}
+_PARETO_DIMENSIONS = tuple(_DIM_TO_DF_COLUMN.keys())
+_PARETO_TOP20_DIMENSIONS = {"type", "workflow", "equipment"}
+
+
+def _normalize_metric_mode(metric_mode: str) -> str:
+ mode = _normalize_text(metric_mode).lower()
+ if mode not in {"reject_total", "defect"}:
+ raise ValueError("Invalid metric_mode, supported: reject_total, defect")
+ return mode
+
+
+def _normalize_pareto_scope(pareto_scope: str) -> str:
+ scope = _normalize_text(pareto_scope).lower() or "top80"
+ if scope not in {"top80", "all"}:
+ raise ValueError("Invalid pareto_scope, supported: top80, all")
+ return scope
+
+
+def _normalize_pareto_display_scope(display_scope: str) -> str:
+ scope = _normalize_text(display_scope).lower() or "all"
+ if scope not in {"all", "top20"}:
+ raise ValueError("Invalid pareto_display_scope, supported: all, top20")
+ return scope
+
+
+def _normalize_pareto_selections(
+ pareto_selections: Optional[Dict[str, List[str]]],
+) -> Dict[str, List[str]]:
+ normalized: Dict[str, List[str]] = {}
+ for dim, values in (pareto_selections or {}).items():
+ dim_key = _normalize_text(dim).lower()
+ if not dim_key:
+ continue
+ if dim_key not in _DIM_TO_DF_COLUMN:
+ raise ValueError(f"不支援的 pareto_dimension: {dim}")
+ normalized_values = _normalize_pareto_values(values)
+ if normalized_values:
+ normalized[dim_key] = normalized_values
+ return normalized
+
+
+def _build_dimension_pareto_items(
+ df: pd.DataFrame,
+ *,
+ dim_col: str,
+ metric_mode: str,
+ pareto_scope: str,
+) -> List[Dict[str, Any]]:
+ if df is None or df.empty:
+ return []
+ if dim_col not in df.columns:
+ return []
+
+ metric_col = "DEFECT_QTY" if metric_mode == "defect" else "REJECT_TOTAL_QTY"
+ if metric_col not in df.columns:
+ return []
+
+ agg_dict = {}
+ for col in ["MOVEIN_QTY", "REJECT_TOTAL_QTY", "DEFECT_QTY"]:
+ if col in df.columns:
+ agg_dict[col] = (col, "sum")
+
+ grouped = df.groupby(dim_col, sort=False).agg(**agg_dict).reset_index()
+ if grouped.empty:
+ return []
+
+ if "CONTAINERID" in df.columns:
+ lot_counts = (
+ df.groupby(dim_col)["CONTAINERID"]
+ .nunique()
+ .reset_index()
+ .rename(columns={"CONTAINERID": "AFFECTED_LOT_COUNT"})
+ )
+ grouped = grouped.merge(lot_counts, on=dim_col, how="left")
+ else:
+ grouped["AFFECTED_LOT_COUNT"] = 0
+
+ grouped["METRIC_VALUE"] = grouped[metric_col].fillna(0)
+ grouped = grouped[grouped["METRIC_VALUE"] > 0].sort_values(
+ "METRIC_VALUE", ascending=False
+ )
+ if grouped.empty:
+ return []
+
+ total_metric = grouped["METRIC_VALUE"].sum()
+ grouped["PCT"] = (grouped["METRIC_VALUE"] / total_metric * 100).round(4)
+ grouped["CUM_PCT"] = grouped["PCT"].cumsum().round(4)
+
+ items: List[Dict[str, Any]] = []
+ for _, row in grouped.iterrows():
+ items.append({
+ "reason": _normalize_text(row.get(dim_col)) or "(未知)",
+ "metric_value": _as_float(row.get("METRIC_VALUE")),
+ "MOVEIN_QTY": _as_int(row.get("MOVEIN_QTY")),
+ "REJECT_TOTAL_QTY": _as_int(row.get("REJECT_TOTAL_QTY")),
+ "DEFECT_QTY": _as_int(row.get("DEFECT_QTY")),
+ "count": _as_int(row.get("AFFECTED_LOT_COUNT")),
+ "pct": round(_as_float(row.get("PCT")), 4),
+ "cumPct": round(_as_float(row.get("CUM_PCT")), 4),
+ })
+
+ if pareto_scope == "top80" and items:
+ top_items = [item for item in items if _as_float(item.get("cumPct")) <= 80.0]
+ if not top_items:
+ top_items = [items[0]]
+ return top_items
+ return items
+
+
+def _apply_cross_filter(
+ df: pd.DataFrame,
+ selections: Dict[str, List[str]],
+ exclude_dim: str,
+) -> pd.DataFrame:
+ if df is None or df.empty or not selections:
+ return df
+
+ filtered = df
+ for dim in _PARETO_DIMENSIONS:
+ if dim == exclude_dim:
+ continue
+ selected_values = selections.get(dim)
+ if not selected_values:
+ continue
+ dim_col = _DIM_TO_DF_COLUMN.get(dim)
+ if not dim_col:
+ raise ValueError(f"不支援的 pareto_dimension: {dim}")
+ if dim_col not in filtered.columns:
+ return filtered.iloc[0:0]
+ value_set = set(selected_values)
+ normalized_dimension_values = filtered[dim_col].map(
+ lambda value: _normalize_text(value) or "(未知)"
+ )
+ filtered = filtered[normalized_dimension_values.isin(value_set)]
+ if filtered.empty:
+ return filtered
+ return filtered
+
+
+def compute_dimension_pareto(
+ *,
+ query_id: str,
+ dimension: str = "reason",
+ metric_mode: str = "reject_total",
+ pareto_scope: str = "top80",
+ packages: Optional[List[str]] = None,
+ workcenter_groups: Optional[List[str]] = None,
+ reasons: Optional[List[str]] = None,
+ trend_dates: Optional[List[str]] = None,
+ include_excluded_scrap: bool = False,
+ exclude_material_scrap: bool = True,
+ exclude_pb_diode: bool = True,
+) -> Optional[Dict[str, Any]]:
+ """Compute dimension pareto from cached DataFrame (no Oracle query)."""
+ metric_mode = _normalize_metric_mode(metric_mode)
+ pareto_scope = _normalize_pareto_scope(pareto_scope)
+ dimension = _normalize_text(dimension).lower() or "reason"
+ if dimension not in _DIM_TO_DF_COLUMN:
+ raise ValueError(
+ f"Invalid dimension, supported: {', '.join(sorted(_DIM_TO_DF_COLUMN.keys()))}"
+ )
+
+ df = _get_cached_df(query_id)
+ if df is None:
+ return None
+
+ # Keep cache-based pareto behavior aligned with primary/view policy filters.
+ df = _apply_policy_filters(
+ df,
+ include_excluded_scrap=include_excluded_scrap,
+ exclude_material_scrap=exclude_material_scrap,
+ exclude_pb_diode=exclude_pb_diode,
+ )
+ if df is None or df.empty:
+ return {"items": [], "dimension": dimension, "metric_mode": metric_mode}
+
+ dim_col = _DIM_TO_DF_COLUMN.get(dimension)
+ if dim_col not in df.columns:
+ return {"items": [], "dimension": dimension, "metric_mode": metric_mode}
+
+ # Apply supplementary filters
+ filtered = _apply_supplementary_filters(
+ df,
+ packages=packages,
+ workcenter_groups=workcenter_groups,
+ reasons=reasons,
+ )
+ if filtered is None or filtered.empty:
+ return {"items": [], "dimension": dimension, "metric_mode": metric_mode}
+
+ # Apply trend date filter
+ if trend_dates and "TXN_DAY" in filtered.columns:
+ date_set = set(trend_dates)
+ filtered = filtered[
+ filtered["TXN_DAY"].apply(lambda d: _to_date_str(d) in date_set)
+ ]
+ if filtered.empty:
+ return {"items": [], "dimension": dimension, "metric_mode": metric_mode}
+
+ items = _build_dimension_pareto_items(
+ filtered,
+ dim_col=dim_col,
+ metric_mode=metric_mode,
+ pareto_scope=pareto_scope,
+ )
+
+ return {
+ "items": items,
+ "dimension": dimension,
+ "metric_mode": metric_mode,
+ }
+
+
+def compute_batch_pareto(
+ *,
+ query_id: str,
+ metric_mode: str = "reject_total",
+ pareto_scope: str = "top80",
+ pareto_display_scope: str = "all",
+ packages: Optional[List[str]] = None,
+ workcenter_groups: Optional[List[str]] = None,
+ reasons: Optional[List[str]] = None,
+ trend_dates: Optional[List[str]] = None,
+ pareto_selections: Optional[Dict[str, List[str]]] = None,
+ include_excluded_scrap: bool = False,
+ exclude_material_scrap: bool = True,
+ exclude_pb_diode: bool = True,
+) -> Optional[Dict[str, Any]]:
+ """Compute all six Pareto dimensions from cached DataFrame (no Oracle query)."""
+ metric_mode = _normalize_metric_mode(metric_mode)
+ pareto_scope = _normalize_pareto_scope(pareto_scope)
+ pareto_display_scope = _normalize_pareto_display_scope(pareto_display_scope)
+ normalized_selections = _normalize_pareto_selections(pareto_selections)
+
+ df = _get_cached_df(query_id)
+ if df is None:
+ return None
+
+ df = _apply_policy_filters(
+ df,
+ include_excluded_scrap=include_excluded_scrap,
+ exclude_material_scrap=exclude_material_scrap,
+ exclude_pb_diode=exclude_pb_diode,
+ )
+ if df is None or df.empty:
+ return {
+ "dimensions": {
+ dim: {"items": [], "dimension": dim, "metric_mode": metric_mode}
+ for dim in _PARETO_DIMENSIONS
+ }
+ }
+
+ filtered = _apply_supplementary_filters(
+ df,
+ packages=packages,
+ workcenter_groups=workcenter_groups,
+ reasons=reasons,
+ )
+ if filtered is None or filtered.empty:
+ return {
+ "dimensions": {
+ dim: {"items": [], "dimension": dim, "metric_mode": metric_mode}
+ for dim in _PARETO_DIMENSIONS
+ }
+ }
+
+ if trend_dates and "TXN_DAY" in filtered.columns:
+ date_set = set(trend_dates)
+ filtered = filtered[
+ filtered["TXN_DAY"].apply(lambda d: _to_date_str(d) in date_set)
+ ]
+
+ dimensions: Dict[str, Dict[str, Any]] = {}
+ for dim in _PARETO_DIMENSIONS:
+ dim_col = _DIM_TO_DF_COLUMN.get(dim)
+ dim_df = _apply_cross_filter(filtered, normalized_selections, exclude_dim=dim)
+ items = _build_dimension_pareto_items(
+ dim_df,
+ dim_col=dim_col,
+ metric_mode=metric_mode,
+ pareto_scope=pareto_scope,
+ )
+ if pareto_display_scope == "top20" and dim in _PARETO_TOP20_DIMENSIONS:
+ items = items[:20]
+ dimensions[dim] = {
+ "items": items,
+ "dimension": dim,
+ "metric_mode": metric_mode,
+ }
+
+ return {
+ "dimensions": dimensions,
+ "metric_mode": metric_mode,
+ "pareto_scope": pareto_scope,
+ "pareto_display_scope": pareto_display_scope,
+ }
+
+
+# ============================================================
+# CSV export from cache
+# ============================================================
+
+
+def export_csv_from_cache(
+ *,
+ query_id: str,
+ packages: Optional[List[str]] = None,
+ workcenter_groups: Optional[List[str]] = None,
+ reasons: Optional[List[str]] = None,
+ metric_filter: str = "all",
+ trend_dates: Optional[List[str]] = None,
+ detail_reason: Optional[str] = None,
+ pareto_dimension: Optional[str] = None,
+ pareto_values: Optional[List[str]] = None,
+ pareto_selections: Optional[Dict[str, List[str]]] = None,
+ include_excluded_scrap: bool = False,
+ exclude_material_scrap: bool = True,
+ exclude_pb_diode: bool = True,
+) -> Optional[list]:
+ """Read cache → apply filters → return list of dicts for CSV export."""
+ df = _get_cached_df(query_id)
+ if df is None:
+ return None
+
+ df = _apply_policy_filters(
+ df,
+ include_excluded_scrap=include_excluded_scrap,
+ exclude_material_scrap=exclude_material_scrap,
+ exclude_pb_diode=exclude_pb_diode,
+ )
+
+ filtered = _apply_supplementary_filters(
+ df,
+ packages=packages,
+ workcenter_groups=workcenter_groups,
+ reasons=reasons,
+ metric_filter=metric_filter,
+ )
+
+ if trend_dates:
+ date_set = set(trend_dates)
+ filtered = filtered[
+ filtered["TXN_DAY"].apply(lambda d: _to_date_str(d) in date_set)
+ ]
+ if detail_reason and "LOSSREASONNAME" in filtered.columns:
+ filtered = filtered[
+ filtered["LOSSREASONNAME"].str.strip() == detail_reason.strip()
+ ]
+ filtered = _apply_pareto_selection_filter(
+ filtered,
+ pareto_dimension=pareto_dimension,
+ pareto_values=pareto_values,
+ pareto_selections=pareto_selections,
+ )
+
+ rows = []
+ for _, row in filtered.iterrows():
+ rows.append(
+ {
+ "LOT": _normalize_text(row.get("CONTAINERNAME")),
+ "WORKCENTER": _normalize_text(row.get("WORKCENTERNAME")),
+ "WORKCENTER_GROUP": _normalize_text(row.get("WORKCENTER_GROUP")),
+ "Package": _normalize_text(row.get("PRODUCTLINENAME")),
+ "FUNCTION": _normalize_text(row.get("PJ_FUNCTION")),
+ "TYPE": _normalize_text(row.get("PJ_TYPE")),
+ "WORKFLOW": _normalize_text(row.get("WORKFLOWNAME")),
+ "PRODUCT": _normalize_text(row.get("PRODUCTNAME")),
+ "原因": _normalize_text(row.get("LOSSREASONNAME")),
+ "EQUIPMENT": _normalize_text(row.get("EQUIPMENTNAME")),
+ "COMMENT": _normalize_text(row.get("REJECTCOMMENT")),
+ "SPEC": _normalize_text(row.get("SPECNAME")),
+ "REJECT_QTY": _as_int(row.get("REJECT_QTY")),
+ "STANDBY_QTY": _as_int(row.get("STANDBY_QTY")),
+ "QTYTOPROCESS_QTY": _as_int(row.get("QTYTOPROCESS_QTY")),
+ "INPROCESS_QTY": _as_int(row.get("INPROCESS_QTY")),
+ "PROCESSED_QTY": _as_int(row.get("PROCESSED_QTY")),
+ "扣帳報廢量": _as_int(row.get("REJECT_TOTAL_QTY")),
+ "不扣帳報廢量": _as_int(row.get("DEFECT_QTY")),
+ "MOVEIN_QTY": _as_int(row.get("MOVEIN_QTY")),
+ "報廢時間": _to_datetime_str(row.get("TXN_TIME")),
+ "日期": _to_date_str(row.get("TXN_DAY")),
+ }
+ )
+ return rows
diff --git a/src/mes_dashboard/sql/reject_history/performance_daily.sql b/src/mes_dashboard/sql/reject_history/performance_daily.sql
index b4f4635..17a88ac 100644
--- a/src/mes_dashboard/sql/reject_history/performance_daily.sql
+++ b/src/mes_dashboard/sql/reject_history/performance_daily.sql
@@ -30,6 +30,31 @@ WITH spec_map AS (
WHERE SPEC IS NOT NULL
GROUP BY SPEC
),
+reject_scope AS (
+ SELECT DISTINCT
+ r.WIPTRACKINGGROUPKEYID
+ FROM DWH.DW_MES_LOTREJECTHISTORY r
+ WHERE {{ BASE_WHERE }}
+ AND r.WIPTRACKINGGROUPKEYID IS NOT NULL
+),
+wip_workflow_map AS (
+ SELECT
+ WIPTRACKINGGROUPKEYID,
+ WORKFLOWNAME
+ FROM (
+ SELECT
+ lwh.WIPTRACKINGGROUPKEYID,
+ lwh.WORKFLOWNAME,
+ ROW_NUMBER() OVER (
+ PARTITION BY lwh.WIPTRACKINGGROUPKEYID
+ ORDER BY lwh.MOVEOUTTIMESTAMP DESC NULLS LAST
+ ) AS rn
+ FROM DWH.DW_MES_LOTWIPHISTORY lwh
+ INNER JOIN reject_scope rs
+ ON rs.WIPTRACKINGGROUPKEYID = lwh.WIPTRACKINGGROUPKEYID
+ )
+ WHERE rn = 1
+),
reject_raw AS (
SELECT
TRUNC(r.TXNDATE) AS TXN_DAY,
@@ -105,7 +130,7 @@ reject_raw AS (
FROM DWH.DW_MES_LOTREJECTHISTORY r
LEFT JOIN DWH.DW_MES_CONTAINER c
ON c.CONTAINERID = r.CONTAINERID
- LEFT JOIN DWH.DW_MES_LOTWIPHISTORY lwh
+ LEFT JOIN wip_workflow_map lwh
ON lwh.WIPTRACKINGGROUPKEYID = r.WIPTRACKINGGROUPKEYID
LEFT JOIN spec_map sm
ON sm.SPEC = TRIM(r.SPECNAME)
diff --git a/src/mes_dashboard/sql/reject_history/performance_daily_lot.sql b/src/mes_dashboard/sql/reject_history/performance_daily_lot.sql
index f04a92f..e7591a6 100644
--- a/src/mes_dashboard/sql/reject_history/performance_daily_lot.sql
+++ b/src/mes_dashboard/sql/reject_history/performance_daily_lot.sql
@@ -6,8 +6,8 @@
-- :end_date - End date (YYYY-MM-DD)
WITH spec_map AS (
- SELECT
- SPEC,
+ SELECT
+ SPEC,
MIN(WORK_CENTER) KEEP (
DENSE_RANK FIRST ORDER BY WORKCENTERSEQUENCE_GROUP
) AS WORK_CENTER,
@@ -15,9 +15,34 @@ WITH spec_map AS (
DENSE_RANK FIRST ORDER BY WORKCENTERSEQUENCE_GROUP
) AS WORKCENTER_GROUP,
MIN(WORKCENTERSEQUENCE_GROUP) AS WORKCENTERSEQUENCE_GROUP
- FROM DWH.DW_MES_SPEC_WORKCENTER_V
- WHERE SPEC IS NOT NULL
- GROUP BY SPEC
+ FROM DWH.DW_MES_SPEC_WORKCENTER_V
+ WHERE SPEC IS NOT NULL
+ GROUP BY SPEC
+),
+reject_scope AS (
+ SELECT DISTINCT
+ r.WIPTRACKINGGROUPKEYID
+ FROM DWH.DW_MES_LOTREJECTHISTORY r
+ WHERE {{ BASE_WHERE }}
+ AND r.WIPTRACKINGGROUPKEYID IS NOT NULL
+),
+wip_workflow_map AS (
+ SELECT
+ WIPTRACKINGGROUPKEYID,
+ WORKFLOWNAME
+ FROM (
+ SELECT
+ lwh.WIPTRACKINGGROUPKEYID,
+ lwh.WORKFLOWNAME,
+ ROW_NUMBER() OVER (
+ PARTITION BY lwh.WIPTRACKINGGROUPKEYID
+ ORDER BY lwh.MOVEOUTTIMESTAMP DESC NULLS LAST
+ ) AS rn
+ FROM DWH.DW_MES_LOTWIPHISTORY lwh
+ INNER JOIN reject_scope rs
+ ON rs.WIPTRACKINGGROUPKEYID = lwh.WIPTRACKINGGROUPKEYID
+ )
+ WHERE rn = 1
),
reject_raw AS (
SELECT
@@ -99,7 +124,7 @@ reject_raw AS (
FROM DWH.DW_MES_LOTREJECTHISTORY r
LEFT JOIN DWH.DW_MES_CONTAINER c
ON c.CONTAINERID = r.CONTAINERID
- LEFT JOIN DWH.DW_MES_LOTWIPHISTORY lwh
+ LEFT JOIN wip_workflow_map lwh
ON lwh.WIPTRACKINGGROUPKEYID = r.WIPTRACKINGGROUPKEYID
LEFT JOIN spec_map sm
ON sm.SPEC = TRIM(r.SPECNAME)
diff --git a/tests/test_batch_query_engine.py b/tests/test_batch_query_engine.py
index 77553b9..52eb748 100644
--- a/tests/test_batch_query_engine.py
+++ b/tests/test_batch_query_engine.py
@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
"""Unit tests for BatchQueryEngine module."""
+import json
import pytest
from unittest.mock import patch, MagicMock, call
@@ -482,8 +483,8 @@ class TestChunkFailureResilience:
skip_cached=False,
)
- # All 3 chunks attempted
- assert call_count["n"] == 3
+ # One chunk retried once due retryable timeout pattern.
+ assert call_count["n"] == 4
# Final metadata should reflect partial failure
last = hset_calls[-1]
assert last["status"] == "partial"
@@ -567,10 +568,147 @@ class TestShouldDecompose:
assert should_decompose_by_time("2025-01-01", "2025-12-31")
def test_short_range_false(self):
- assert not should_decompose_by_time("2025-01-01", "2025-02-01")
+ assert not should_decompose_by_time("2025-01-01", "2025-01-11")
def test_large_ids_true(self):
assert should_decompose_by_ids(list(range(2000)))
def test_small_ids_false(self):
assert not should_decompose_by_ids(list(range(500)))
+
+
+class TestRetryAndFailedRanges:
+ def _mock_redis(self):
+ mock_client = MagicMock()
+ stored = {}
+ hashes = {}
+
+ mock_client.setex.side_effect = lambda k, t, v: stored.update({k: v})
+ mock_client.get.side_effect = lambda k: stored.get(k)
+ mock_client.exists.side_effect = lambda k: 1 if k in stored else 0
+ mock_client.hset.side_effect = lambda k, mapping=None: hashes.setdefault(k, {}).update(mapping or {})
+ mock_client.hgetall.side_effect = lambda k: hashes.get(k, {})
+ mock_client.expire.return_value = None
+ return mock_client
+
+ def test_transient_failure_retried_once(self):
+ import mes_dashboard.core.redis_df_store as rds
+ import mes_dashboard.services.batch_query_engine as bqe
+
+ mock_client = self._mock_redis()
+ call_count = {"n": 0}
+
+ def flaky_query_fn(chunk, max_rows_per_chunk=None):
+ call_count["n"] += 1
+ if call_count["n"] == 1:
+ raise TimeoutError("connection timed out")
+ return pd.DataFrame({"V": [1]})
+
+ with patch.object(rds, "REDIS_ENABLED", True), \
+ patch.object(rds, "get_redis_client", return_value=mock_client), \
+ patch.object(bqe, "get_redis_client", return_value=mock_client):
+ execute_plan(
+ [{"chunk_start": "2025-01-01", "chunk_end": "2025-01-10"}],
+ flaky_query_fn,
+ query_hash="retryonce",
+ cache_prefix="retry",
+ skip_cached=False,
+ )
+ progress = bqe.get_batch_progress("retry", "retryonce")
+
+ assert call_count["n"] == 2
+ assert progress is not None
+ assert progress.get("status") == "completed"
+ assert progress.get("failed") == "0"
+
+ def test_memory_guard_not_retried(self):
+ import mes_dashboard.core.redis_df_store as rds
+ import mes_dashboard.services.batch_query_engine as bqe
+
+ mock_client = self._mock_redis()
+ call_count = {"n": 0}
+
+ def large_df_query_fn(chunk, max_rows_per_chunk=None):
+ call_count["n"] += 1
+ return pd.DataFrame({"V": [1]})
+
+ with patch.object(rds, "REDIS_ENABLED", True), \
+ patch.object(rds, "get_redis_client", return_value=mock_client), \
+ patch.object(bqe, "get_redis_client", return_value=mock_client), \
+ patch.object(bqe, "BATCH_CHUNK_MAX_MEMORY_MB", 0):
+ execute_plan(
+ [{"chunk_start": "2025-01-01", "chunk_end": "2025-01-10"}],
+ large_df_query_fn,
+ query_hash="memnoretry",
+ cache_prefix="retry",
+ skip_cached=False,
+ )
+
+ assert call_count["n"] == 1
+
+ def test_failed_ranges_tracked(self):
+ import mes_dashboard.core.redis_df_store as rds
+ import mes_dashboard.services.batch_query_engine as bqe
+
+ mock_client = self._mock_redis()
+
+ def query_fn(chunk, max_rows_per_chunk=None):
+ if chunk["chunk_start"] == "2025-01-11":
+ raise RuntimeError("chunk failure")
+ return pd.DataFrame({"V": [1]})
+
+ chunks = [
+ {"chunk_start": "2025-01-01", "chunk_end": "2025-01-10"},
+ {"chunk_start": "2025-01-11", "chunk_end": "2025-01-20"},
+ {"chunk_start": "2025-01-21", "chunk_end": "2025-01-30"},
+ ]
+ with patch.object(rds, "REDIS_ENABLED", True), \
+ patch.object(rds, "get_redis_client", return_value=mock_client), \
+ patch.object(bqe, "get_redis_client", return_value=mock_client):
+ execute_plan(
+ chunks,
+ query_fn,
+ query_hash="franges",
+ cache_prefix="retry",
+ skip_cached=False,
+ )
+ progress = bqe.get_batch_progress("retry", "franges")
+
+ assert progress is not None
+ assert progress.get("has_partial_failure") == "True"
+ assert progress.get("failed") == "1"
+ failed_ranges = json.loads(progress.get("failed_ranges", "[]"))
+ assert failed_ranges == [{"start": "2025-01-11", "end": "2025-01-20"}]
+
+ def test_id_batch_chunk_no_failed_ranges(self):
+ import mes_dashboard.core.redis_df_store as rds
+ import mes_dashboard.services.batch_query_engine as bqe
+
+ mock_client = self._mock_redis()
+
+ def query_fn(chunk, max_rows_per_chunk=None):
+ if chunk.get("ids") == ["B"]:
+ raise RuntimeError("id chunk failed")
+ return pd.DataFrame({"V": [1]})
+
+ chunks = [
+ {"ids": ["A"]},
+ {"ids": ["B"]},
+ ]
+ with patch.object(rds, "REDIS_ENABLED", True), \
+ patch.object(rds, "get_redis_client", return_value=mock_client), \
+ patch.object(bqe, "get_redis_client", return_value=mock_client):
+ execute_plan(
+ chunks,
+ query_fn,
+ query_hash="idfail",
+ cache_prefix="retry",
+ skip_cached=False,
+ )
+ progress = bqe.get_batch_progress("retry", "idfail")
+
+ assert progress is not None
+ assert progress.get("has_partial_failure") == "True"
+ assert progress.get("failed") == "1"
+ failed_ranges = json.loads(progress.get("failed_ranges", "[]"))
+ assert failed_ranges == []
diff --git a/tests/test_container_resolution_policy.py b/tests/test_container_resolution_policy.py
new file mode 100644
index 0000000..3eadcf2
--- /dev/null
+++ b/tests/test_container_resolution_policy.py
@@ -0,0 +1,73 @@
+# -*- coding: utf-8 -*-
+"""Unit tests for shared container resolution policy helpers."""
+
+from __future__ import annotations
+
+from mes_dashboard.services import container_resolution_policy as policy
+
+
+def test_validate_resolution_request_rejects_empty_values():
+ assert policy.validate_resolution_request("lot_id", []) is not None
+
+
+def test_validate_resolution_request_rejects_broad_pattern(monkeypatch):
+ monkeypatch.setenv("CONTAINER_RESOLVE_PATTERN_MIN_PREFIX_LEN", "2")
+ error = policy.validate_resolution_request("lot_id", ["%"])
+ assert error is not None
+ assert "萬用字元條件過於寬鬆" in error
+
+
+def test_validate_resolution_request_allows_pattern_with_prefix(monkeypatch):
+ monkeypatch.setenv("CONTAINER_RESOLVE_PATTERN_MIN_PREFIX_LEN", "2")
+ error = policy.validate_resolution_request("lot_id", ["GA26%"])
+ assert error is None
+
+
+def test_validate_resolution_result_rejects_excessive_expansion(monkeypatch):
+ monkeypatch.setenv("CONTAINER_RESOLVE_MAX_EXPANSION_PER_TOKEN", "3")
+ result = {
+ "data": [{"container_id": "C1"}],
+ "expansion_info": {"GA%": 10},
+ }
+ error = policy.validate_resolution_result(result)
+ assert error is not None
+ assert "單一條件展開過大" in error
+
+
+def test_validate_resolution_result_rejects_excessive_container_count(monkeypatch):
+ monkeypatch.setenv("CONTAINER_RESOLVE_MAX_CONTAINER_IDS", "2")
+ result = {
+ "data": [
+ {"container_id": "C1"},
+ {"container_id": "C2"},
+ {"container_id": "C3"},
+ ],
+ "expansion_info": {},
+ }
+ error = policy.validate_resolution_result(result)
+ assert error is not None
+ assert "解析結果過大" in error
+
+
+def test_validate_resolution_result_non_strict_allows_overflow(monkeypatch):
+ monkeypatch.setenv("CONTAINER_RESOLVE_MAX_CONTAINER_IDS", "2")
+ result = {
+ "data": [
+ {"container_id": "C1"},
+ {"container_id": "C2"},
+ {"container_id": "C3"},
+ ],
+ "expansion_info": {"GA%": 999},
+ }
+ error = policy.validate_resolution_result(result, strict=False)
+ assert error is None
+
+
+def test_extract_container_ids_deduplicates_and_preserves_order():
+ rows = [
+ {"container_id": "C1"},
+ {"container_id": "C1"},
+ {"CONTAINERID": "C2"},
+ {"container_id": "C3"},
+ ]
+ assert policy.extract_container_ids(rows) == ["C1", "C2", "C3"]
diff --git a/tests/test_event_fetcher.py b/tests/test_event_fetcher.py
index bd9082e..c6b10a8 100644
--- a/tests/test_event_fetcher.py
+++ b/tests/test_event_fetcher.py
@@ -198,3 +198,60 @@ def test_fetch_events_sanitizes_nan_values(
result = EventFetcher.fetch_events(["CID-1"], "upstream_history")
assert result["CID-1"][0]["VALUE"] is None
+
+
+@patch("mes_dashboard.services.event_fetcher.cache_set")
+@patch("mes_dashboard.services.event_fetcher.cache_get", return_value=None)
+@patch("mes_dashboard.services.event_fetcher.read_sql_df_slow_iter")
+@patch("mes_dashboard.services.event_fetcher.SQLLoader.load")
+def test_fetch_events_raises_when_parallel_batch_fails_and_partial_disabled(
+ mock_sql_load,
+ mock_iter,
+ _mock_cache_get,
+ _mock_cache_set,
+ monkeypatch,
+):
+ mock_sql_load.return_value = "SELECT * FROM t WHERE h.CONTAINERID = :container_id {{ WORKCENTER_FILTER }}"
+ monkeypatch.setattr("mes_dashboard.services.event_fetcher.EVENT_FETCHER_ALLOW_PARTIAL_RESULTS", False)
+ monkeypatch.setattr("mes_dashboard.services.event_fetcher.EVENT_FETCHER_MAX_WORKERS", 2)
+
+ def _side_effect(sql, params, timeout_seconds=60):
+ if "CID-1000" in params.values():
+ raise RuntimeError("chunk fail")
+ return iter([])
+
+ mock_iter.side_effect = _side_effect
+ cids = [f"CID-{i}" for i in range(1001)] # force >1 batch
+
+ try:
+ EventFetcher.fetch_events(cids, "history")
+ assert False, "expected RuntimeError"
+ except RuntimeError as exc:
+ assert "chunk failed" in str(exc)
+
+
+@patch("mes_dashboard.services.event_fetcher.cache_set")
+@patch("mes_dashboard.services.event_fetcher.cache_get", return_value=None)
+@patch("mes_dashboard.services.event_fetcher.read_sql_df_slow_iter")
+@patch("mes_dashboard.services.event_fetcher.SQLLoader.load")
+def test_fetch_events_allows_partial_when_enabled(
+ mock_sql_load,
+ mock_iter,
+ _mock_cache_get,
+ _mock_cache_set,
+ monkeypatch,
+):
+ mock_sql_load.return_value = "SELECT * FROM t WHERE h.CONTAINERID = :container_id {{ WORKCENTER_FILTER }}"
+ monkeypatch.setattr("mes_dashboard.services.event_fetcher.EVENT_FETCHER_ALLOW_PARTIAL_RESULTS", True)
+ monkeypatch.setattr("mes_dashboard.services.event_fetcher.EVENT_FETCHER_MAX_WORKERS", 2)
+
+ def _side_effect(sql, params, timeout_seconds=60):
+ if "CID-1000" in params.values():
+ raise RuntimeError("chunk fail")
+ return iter([])
+
+ mock_iter.side_effect = _side_effect
+ cids = [f"CID-{i}" for i in range(1001)]
+
+ result = EventFetcher.fetch_events(cids, "history")
+ assert result == {}
diff --git a/tests/test_job_query_engine.py b/tests/test_job_query_engine.py
index f3c4333..e73be37 100644
--- a/tests/test_job_query_engine.py
+++ b/tests/test_job_query_engine.py
@@ -77,7 +77,7 @@ class TestJobQueryEngineDecomposition:
result = job_svc.get_jobs_by_resources(
resource_ids=["R1"],
start_date="2025-06-01",
- end_date="2025-06-30",
+ end_date="2025-06-05",
)
assert engine_calls["execute"] == 0 # Engine NOT used
diff --git a/tests/test_job_query_service.py b/tests/test_job_query_service.py
index 951a66b..c05c73a 100644
--- a/tests/test_job_query_service.py
+++ b/tests/test_job_query_service.py
@@ -191,7 +191,7 @@ class TestErrorLeakageProtection:
def test_query_error_masks_internal_details(self, mock_read):
mock_read.side_effect = RuntimeError("ORA-00942: table or view does not exist")
- result = get_jobs_by_resources(["RES001"], "2024-01-01", "2024-01-31")
+ result = get_jobs_by_resources(["RES001"], "2024-01-01", "2024-01-05")
assert result["error"] == QUERY_ERROR_MESSAGE
assert "ORA-00942" not in result["error"]
diff --git a/tests/test_mid_section_defect_engine.py b/tests/test_mid_section_defect_engine.py
index 0c7c9d8..50a7c1d 100644
--- a/tests/test_mid_section_defect_engine.py
+++ b/tests/test_mid_section_defect_engine.py
@@ -85,7 +85,7 @@ class TestDetectionEngineDecomposition:
df = msd_svc._fetch_station_detection_data(
start_date="2025-06-01",
- end_date="2025-06-30",
+ end_date="2025-06-05",
station="測試",
)
diff --git a/tests/test_query_tool_routes.py b/tests/test_query_tool_routes.py
index 22633a5..0e67947 100644
--- a/tests/test_query_tool_routes.py
+++ b/tests/test_query_tool_routes.py
@@ -14,7 +14,7 @@ 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
-from mes_dashboard.services.query_tool_service import MAX_DATE_RANGE_DAYS, MAX_LOT_IDS
+from mes_dashboard.services.query_tool_service import MAX_DATE_RANGE_DAYS
@pytest.fixture
@@ -118,20 +118,19 @@ class TestResolveEndpoint:
data = json.loads(response.data)
assert 'error' in data
- def test_values_over_limit(self, client):
- """Should reject values exceeding limit."""
- values = [f'GA{i:09d}' for i in range(MAX_LOT_IDS + 1)]
+ def test_rejects_too_broad_wildcard(self, client):
+ """Should reject wildcard patterns that are too broad."""
response = client.post(
'/api/query-tool/resolve',
json={
'input_type': 'lot_id',
- 'values': values
- }
- )
+ 'values': ['%']
+ }
+ )
assert response.status_code == 400
data = json.loads(response.data)
assert 'error' in data
- assert '超過上限' in data['error'] or str(MAX_LOT_IDS) in data['error']
+ assert '萬用字元條件過於寬鬆' in data['error']
@patch('mes_dashboard.routes.query_tool_routes.resolve_lots')
def test_resolve_success(self, mock_resolve, client):
diff --git a/tests/test_query_tool_service.py b/tests/test_query_tool_service.py
index b6cc5ee..e1816bc 100644
--- a/tests/test_query_tool_service.py
+++ b/tests/test_query_tool_service.py
@@ -90,7 +90,7 @@ class TestValidateDateRange:
assert '格式' in result or 'format' in result.lower()
-class TestValidateLotInput:
+class TestValidateLotInput:
"""Tests for validate_lot_input function."""
def test_valid_lot_ids(self):
@@ -117,53 +117,24 @@ class TestValidateLotInput:
assert result is not None
assert '至少一個' in result
- def test_exceeds_lot_id_limit(self):
- """Should reject LOT IDs exceeding limit."""
- values = [f'GA{i:09d}' for i in range(MAX_LOT_IDS + 1)]
- result = validate_lot_input('lot_id', values)
- assert result is not None
- assert '超過上限' in result
- assert str(MAX_LOT_IDS) in result
-
- def test_exceeds_serial_number_limit(self):
- """Should reject serial numbers exceeding limit."""
- values = [f'SN{i:06d}' for i in range(MAX_SERIAL_NUMBERS + 1)]
- result = validate_lot_input('serial_number', values)
- assert result is not None
- assert '超過上限' in result
- assert str(MAX_SERIAL_NUMBERS) in result
-
- def test_exceeds_work_order_limit(self):
- """Should reject work orders exceeding limit."""
- values = [f'WO{i:06d}' for i in range(MAX_WORK_ORDERS + 1)]
- result = validate_lot_input('work_order', values)
- assert result is not None
- assert '超過上限' in result
- assert str(MAX_WORK_ORDERS) in result
+ def test_large_input_list_allowed_when_no_count_cap(self, monkeypatch):
+ """Should allow large lists when count cap is disabled."""
+ monkeypatch.setenv("CONTAINER_RESOLVE_INPUT_MAX_VALUES", "0")
+ values = [f'GA{i:09d}' for i in range(MAX_LOT_IDS + 50)]
+ result = validate_lot_input('lot_id', values)
+ assert result is None
- def test_exceeds_gd_work_order_limit(self):
- """Should reject GD work orders exceeding limit."""
- values = [f'GD{i:06d}' for i in range(MAX_GD_WORK_ORDERS + 1)]
- result = validate_lot_input('gd_work_order', values)
+ def test_rejects_too_broad_wildcard_pattern(self, monkeypatch):
+ """Should reject broad wildcard like '%' to prevent full scan."""
+ monkeypatch.setenv("CONTAINER_RESOLVE_PATTERN_MIN_PREFIX_LEN", "2")
+ result = validate_lot_input('lot_id', ['%'])
assert result is not None
- assert '超過上限' in result
- assert str(MAX_GD_WORK_ORDERS) in result
-
- def test_exactly_at_limit(self):
- """Should accept values exactly at limit."""
- values = [f'GA{i:09d}' for i in range(MAX_LOT_IDS)]
- result = validate_lot_input('lot_id', values)
- assert result is None
-
- def test_unknown_input_type_uses_default_limit(self):
- """Should use default limit for unknown input types."""
- values = [f'X{i}' for i in range(MAX_LOT_IDS)]
- result = validate_lot_input('unknown_type', values)
- assert result is None
-
- values_over = [f'X{i}' for i in range(MAX_LOT_IDS + 1)]
- result = validate_lot_input('unknown_type', values_over)
- assert result is not None
+ assert '萬用字元條件過於寬鬆' in result
+
+ def test_accepts_wildcard_with_prefix(self, monkeypatch):
+ monkeypatch.setenv("CONTAINER_RESOLVE_PATTERN_MIN_PREFIX_LEN", "2")
+ result = validate_lot_input('lot_id', ['GA25%'])
+ assert result is None
class TestValidateEquipmentInput:
diff --git a/tests/test_reject_dataset_cache.py b/tests/test_reject_dataset_cache.py
index 34339d4..8bd6776 100644
--- a/tests/test_reject_dataset_cache.py
+++ b/tests/test_reject_dataset_cache.py
@@ -3,6 +3,7 @@
from __future__ import annotations
+import json
from decimal import Decimal
from unittest.mock import MagicMock
@@ -400,6 +401,72 @@ class TestEngineDecompositionDateRange:
assert engine_calls["parallel"] == cache_svc._REJECT_ENGINE_PARALLEL
assert engine_calls["max_rows_per_chunk"] == cache_svc._REJECT_ENGINE_MAX_ROWS_PER_CHUNK
+ def test_engine_chunk_uses_paged_fetch_without_truncation(self, monkeypatch):
+ """Engine chunk should fetch all pages (offset paging), not truncate at page size."""
+ import mes_dashboard.services.batch_query_engine as engine_mod
+
+ offsets = []
+ captured = {"df": pd.DataFrame(), "merge_kwargs": None}
+
+ def fake_read_sql(sql, params):
+ offset = int(params.get("offset", 0))
+ limit = int(params.get("limit", 0))
+ offsets.append(offset)
+ total_rows = 5
+ remaining = max(total_rows - offset, 0)
+ take = min(limit, remaining)
+ if take <= 0:
+ return pd.DataFrame()
+ return pd.DataFrame(
+ {
+ "CONTAINERID": [f"C{offset + i}" for i in range(take)],
+ "LOSSREASONNAME": ["R1"] * take,
+ "REJECT_TOTAL_QTY": [1] * take,
+ }
+ )
+
+ def fake_execute_plan(chunks, query_fn, **kwargs):
+ page_size = kwargs.get("max_rows_per_chunk")
+ captured["df"] = query_fn(chunks[0], max_rows_per_chunk=page_size)
+ return kwargs.get("query_hash", "qh")
+
+ def fake_merge_chunks(prefix, qhash, **kwargs):
+ captured["merge_kwargs"] = kwargs
+ return captured["df"]
+
+ monkeypatch.setattr(cache_svc, "_REJECT_ENGINE_MAX_ROWS_PER_CHUNK", 2)
+ monkeypatch.setattr(engine_mod, "should_decompose_by_time", lambda *_a, **_kw: True)
+ monkeypatch.setattr(
+ engine_mod,
+ "decompose_by_time_range",
+ lambda *_a, **_kw: [{"chunk_start": "2025-01-01", "chunk_end": "2025-01-31"}],
+ )
+ monkeypatch.setattr(engine_mod, "execute_plan", fake_execute_plan)
+ monkeypatch.setattr(engine_mod, "merge_chunks", fake_merge_chunks)
+ monkeypatch.setattr(cache_svc, "read_sql_df", fake_read_sql)
+ monkeypatch.setattr(cache_svc, "_get_cached_df", lambda _qid: None)
+ monkeypatch.setattr(cache_svc, "_prepare_sql", lambda *a, **kw: "SELECT 1 FROM dual")
+ monkeypatch.setattr(cache_svc, "_build_where_clause", lambda **kw: ("", {}, {}))
+ monkeypatch.setattr(cache_svc, "_validate_range", lambda *_a, **_kw: None)
+ monkeypatch.setattr(cache_svc, "_apply_policy_filters", lambda df, **kw: df)
+ monkeypatch.setattr(cache_svc, "_store_query_result", lambda *_a, **_kw: None)
+ monkeypatch.setattr(cache_svc, "redis_clear_batch", lambda *_a, **_kw: 0)
+ monkeypatch.setattr(
+ cache_svc,
+ "_build_primary_response",
+ lambda qid, df, meta, ri: {"query_id": qid, "rows": len(df)},
+ )
+
+ result = cache_svc.execute_primary_query(
+ mode="date_range",
+ start_date="2025-01-01",
+ end_date="2025-03-01",
+ )
+
+ assert result["rows"] == 5
+ assert offsets == [0, 2, 4]
+ assert captured["merge_kwargs"] == {}
+
def test_short_range_skips_engine(self, monkeypatch):
"""Short date range (<= threshold) uses direct path, no engine."""
import mes_dashboard.services.batch_query_engine as engine_mod
@@ -453,7 +520,7 @@ class TestEngineDecompositionDateRange:
result = cache_svc.execute_primary_query(
mode="date_range",
start_date="2025-06-01",
- end_date="2025-06-30",
+ end_date="2025-06-10",
)
assert engine_calls["decompose"] == 0 # Engine NOT used
@@ -629,7 +696,7 @@ def test_large_result_spills_to_parquet_and_view_export_use_spool_fallback(monke
result = cache_svc.execute_primary_query(
mode="date_range",
start_date="2025-01-01",
- end_date="2025-01-31",
+ end_date="2025-01-05",
)
query_id = result["query_id"]
@@ -651,3 +718,185 @@ def test_large_result_spills_to_parquet_and_view_export_use_spool_fallback(monke
export_rows = cache_svc.export_csv_from_cache(query_id=query_id)
assert export_rows is not None
assert len(export_rows) == len(df)
+
+
+def test_resolve_containers_deduplicates_container_ids(monkeypatch):
+ monkeypatch.setattr(
+ cache_svc,
+ "_RESOLVERS",
+ {
+ "lot": lambda values: {
+ "data": [
+ {"container_id": "CID-1"},
+ {"container_id": "CID-1"},
+ {"container_id": "CID-2"},
+ ],
+ "input_count": len(values),
+ "not_found": [],
+ "expansion_info": {"LOT%": 2},
+ }
+ },
+ )
+ monkeypatch.setenv("CONTAINER_RESOLVE_MAX_EXPANSION_PER_TOKEN", "10")
+ monkeypatch.setenv("CONTAINER_RESOLVE_MAX_CONTAINER_IDS", "10")
+
+ resolved = cache_svc.resolve_containers("lot", ["LOT%"])
+
+ assert resolved["container_ids"] == ["CID-1", "CID-2"]
+ assert resolved["resolution_info"]["resolved_count"] == 2
+
+
+def test_resolve_containers_allows_oversized_expansion_and_sets_guardrail(monkeypatch):
+ monkeypatch.setattr(
+ cache_svc,
+ "_RESOLVERS",
+ {
+ "lot": lambda values: {
+ "data": [{"container_id": "CID-1"}],
+ "input_count": len(values),
+ "not_found": [],
+ "expansion_info": {"GA%": 999},
+ }
+ },
+ )
+ monkeypatch.setenv("CONTAINER_RESOLVE_MAX_EXPANSION_PER_TOKEN", "50")
+ monkeypatch.setenv("CONTAINER_RESOLVE_PATTERN_MIN_PREFIX_LEN", "2")
+
+ resolved = cache_svc.resolve_containers("lot", ["GA%"])
+ guardrail = resolved["resolution_info"].get("guardrail") or {}
+ assert guardrail.get("overflow") is True
+ assert len(guardrail.get("expansion_offenders") or []) == 1
+
+
+def test_partial_failure_in_response_meta(monkeypatch):
+ import mes_dashboard.services.batch_query_engine as engine_mod
+
+ df = pd.DataFrame({"CONTAINERID": ["C1"], "LOSSREASONNAME": ["R1"], "REJECT_TOTAL_QTY": [1]})
+
+ monkeypatch.setattr(cache_svc, "_get_cached_df", lambda _qid: None)
+ monkeypatch.setattr(cache_svc, "_validate_range", lambda *_a, **_kw: None)
+ monkeypatch.setattr(cache_svc, "_build_where_clause", lambda **kw: ("", {}, {}))
+ monkeypatch.setattr(cache_svc, "_prepare_sql", lambda *a, **kw: "SELECT 1 FROM dual")
+ monkeypatch.setattr(cache_svc, "_apply_policy_filters", lambda data, **kw: data)
+ monkeypatch.setattr(cache_svc, "_store_query_result", lambda *_a, **_kw: False)
+ monkeypatch.setattr(cache_svc, "redis_clear_batch", lambda *_a, **_kw: None)
+ monkeypatch.setattr(
+ cache_svc,
+ "_build_primary_response",
+ lambda qid, result_df, meta, resolution_info: {"query_id": qid, "meta": meta},
+ )
+ monkeypatch.setattr(cache_svc, "_store_partial_failure_flag", lambda *_a, **_kw: None)
+
+ monkeypatch.setattr(engine_mod, "should_decompose_by_time", lambda *_a, **_kw: True)
+ monkeypatch.setattr(
+ engine_mod,
+ "decompose_by_time_range",
+ lambda *_a, **_kw: [{"chunk_start": "2025-01-01", "chunk_end": "2025-01-10"}],
+ )
+ monkeypatch.setattr(engine_mod, "execute_plan", lambda *a, **kw: kw.get("query_hash"))
+ monkeypatch.setattr(engine_mod, "merge_chunks", lambda *a, **kw: df.copy())
+ monkeypatch.setattr(
+ engine_mod,
+ "get_batch_progress",
+ lambda *_a, **_kw: {
+ "has_partial_failure": "True",
+ "failed": "2",
+ "failed_ranges": json.dumps([{"start": "2025-01-01", "end": "2025-01-10"}]),
+ },
+ )
+
+ result = cache_svc.execute_primary_query(
+ mode="date_range",
+ start_date="2025-01-01",
+ end_date="2025-03-01",
+ )
+ meta = result.get("meta") or {}
+ assert meta.get("has_partial_failure") is True
+ assert meta.get("failed_chunk_count") == 2
+ assert meta.get("failed_ranges") == [{"start": "2025-01-01", "end": "2025-01-10"}]
+
+
+def test_cache_hit_restores_partial_failure(monkeypatch):
+ cached_df = pd.DataFrame({"CONTAINERID": ["C1"], "LOSSREASONNAME": ["R1"], "REJECT_TOTAL_QTY": [1]})
+
+ monkeypatch.setattr(cache_svc, "_get_cached_df", lambda _qid: cached_df)
+ monkeypatch.setattr(cache_svc, "_validate_range", lambda *_a, **_kw: None)
+ monkeypatch.setattr(cache_svc, "_build_where_clause", lambda **kw: ("", {}, {}))
+ monkeypatch.setattr(cache_svc, "_apply_policy_filters", lambda data, **kw: data)
+ monkeypatch.setattr(
+ cache_svc,
+ "_load_partial_failure_flag",
+ lambda _qid: {
+ "has_partial_failure": True,
+ "failed_chunk_count": 3,
+ "failed_ranges": [],
+ },
+ )
+ monkeypatch.setattr(
+ cache_svc,
+ "_build_primary_response",
+ lambda qid, result_df, meta, resolution_info: {"query_id": qid, "meta": meta},
+ )
+
+ result = cache_svc.execute_primary_query(
+ mode="date_range",
+ start_date="2025-01-01",
+ end_date="2025-01-31",
+ )
+ meta = result.get("meta") or {}
+ assert meta.get("has_partial_failure") is True
+ assert meta.get("failed_chunk_count") == 3
+ assert meta.get("failed_ranges") == []
+
+
+@pytest.mark.parametrize(
+ "store_result,expected_ttl",
+ [
+ (True, cache_svc._REJECT_ENGINE_SPOOL_TTL_SECONDS),
+ (False, cache_svc._CACHE_TTL),
+ ],
+)
+def test_partial_failure_ttl_matches_spool(monkeypatch, store_result, expected_ttl):
+ import mes_dashboard.services.batch_query_engine as engine_mod
+
+ df = pd.DataFrame({"CONTAINERID": ["C1"], "LOSSREASONNAME": ["R1"], "REJECT_TOTAL_QTY": [1]})
+ captured = {"ttls": []}
+
+ monkeypatch.setattr(cache_svc, "_get_cached_df", lambda _qid: None)
+ monkeypatch.setattr(cache_svc, "_validate_range", lambda *_a, **_kw: None)
+ monkeypatch.setattr(cache_svc, "_build_where_clause", lambda **kw: ("", {}, {}))
+ monkeypatch.setattr(cache_svc, "_prepare_sql", lambda *a, **kw: "SELECT 1 FROM dual")
+ monkeypatch.setattr(cache_svc, "_apply_policy_filters", lambda data, **kw: data)
+ monkeypatch.setattr(cache_svc, "_store_query_result", lambda *_a, **_kw: store_result)
+ monkeypatch.setattr(cache_svc, "redis_clear_batch", lambda *_a, **_kw: None)
+ monkeypatch.setattr(
+ cache_svc,
+ "_build_primary_response",
+ lambda qid, result_df, meta, resolution_info: {"query_id": qid, "meta": meta},
+ )
+ monkeypatch.setattr(
+ cache_svc,
+ "_store_partial_failure_flag",
+ lambda _qid, _failed, _ranges, ttl: captured["ttls"].append(ttl),
+ )
+
+ monkeypatch.setattr(engine_mod, "should_decompose_by_time", lambda *_a, **_kw: True)
+ monkeypatch.setattr(
+ engine_mod,
+ "decompose_by_time_range",
+ lambda *_a, **_kw: [{"chunk_start": "2025-01-01", "chunk_end": "2025-01-10"}],
+ )
+ monkeypatch.setattr(engine_mod, "execute_plan", lambda *a, **kw: kw.get("query_hash"))
+ monkeypatch.setattr(engine_mod, "merge_chunks", lambda *a, **kw: df.copy())
+ monkeypatch.setattr(
+ engine_mod,
+ "get_batch_progress",
+ lambda *_a, **_kw: {"has_partial_failure": "True", "failed": "1", "failed_ranges": "[]"},
+ )
+
+ cache_svc.execute_primary_query(
+ mode="date_range",
+ start_date="2025-01-01",
+ end_date="2025-03-01",
+ )
+ assert captured["ttls"] == [expected_ttl]