chore(docs): add OOM cross-apply analysis and agent work plan, clean up obsolete artifacts

Add OOM/cache cross-tool gap analysis and structured agent work plan for
Material Trace, Query Tool, and Mid-Section Defect hardening. Remove
obsolete migration blueprint and portal-no-iframe baseline artifacts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
egg
2026-03-04 19:23:31 +08:00
parent 5517f7e85c
commit 981ae5614e
18 changed files with 516 additions and 1080 deletions

View File

@@ -1,20 +0,0 @@
{
"in_scope_required_assets": {
"/wip-overview": ["wip-overview.js"],
"/wip-detail": ["wip-detail.js"],
"/hold-overview": ["hold-overview.js"],
"/hold-detail": ["hold-detail.js"],
"/hold-history": ["hold-history.js"],
"/reject-history": ["reject-history.js"],
"/resource": ["resource-status.js"],
"/resource-history": ["resource-history.js"],
"/qc-gate": ["qc-gate.js"],
"/job-query": ["job-query.js"],
"/admin/performance": ["admin-performance.js"],
"/tables": ["tables.js"],
"/excel-query": ["excel-query.js"],
"/query-tool": ["query-tool.js"],
"/mid-section-defect": ["mid-section-defect.js"]
},
"deferred_routes": []
}

View File

@@ -1,116 +0,0 @@
{
"entries": [
{
"id": "style-legacy-wip-overview",
"scope": "/wip-overview",
"owner": "frontend-mes-reporting",
"milestone": "full-modernization-phase2",
"reason": "Legacy styles pending full token and scope migration"
},
{
"id": "style-legacy-wip-detail",
"scope": "/wip-detail",
"owner": "frontend-mes-reporting",
"milestone": "full-modernization-phase2",
"reason": "Legacy styles pending full token and scope migration"
},
{
"id": "style-legacy-hold-overview",
"scope": "/hold-overview",
"owner": "frontend-mes-reporting",
"milestone": "full-modernization-phase2",
"reason": "Legacy styles pending full token and scope migration"
},
{
"id": "style-legacy-hold-detail",
"scope": "/hold-detail",
"owner": "frontend-mes-reporting",
"milestone": "full-modernization-phase2",
"reason": "Legacy styles pending full token and scope migration"
},
{
"id": "style-legacy-hold-history",
"scope": "/hold-history",
"owner": "frontend-mes-reporting",
"milestone": "full-modernization-phase2",
"reason": "Legacy styles pending full token and scope migration"
},
{
"id": "style-legacy-reject-history",
"scope": "/reject-history",
"owner": "frontend-mes-reporting",
"milestone": "full-modernization-phase2",
"reason": "Legacy styles pending full token and scope migration"
},
{
"id": "style-legacy-resource",
"scope": "/resource",
"owner": "frontend-mes-reporting",
"milestone": "full-modernization-phase2",
"reason": "Legacy styles pending full token and scope migration"
},
{
"id": "style-legacy-resource-history",
"scope": "/resource-history",
"owner": "frontend-mes-reporting",
"milestone": "full-modernization-phase2",
"reason": "Legacy styles pending full token and scope migration"
},
{
"id": "style-legacy-qc-gate",
"scope": "/qc-gate",
"owner": "frontend-mes-reporting",
"milestone": "full-modernization-phase2",
"reason": "Legacy styles pending full token and scope migration"
},
{
"id": "style-legacy-job-query",
"scope": "/job-query",
"owner": "frontend-mes-reporting",
"milestone": "full-modernization-phase2",
"reason": "Legacy styles pending full token and scope migration"
},
{
"id": "style-legacy-admin-pages",
"scope": "/admin/pages",
"owner": "frontend-platform-admin",
"milestone": "full-modernization-phase2",
"reason": "Legacy styles pending full token and scope migration"
},
{
"id": "style-legacy-admin-performance",
"scope": "/admin/performance",
"owner": "frontend-platform-admin",
"milestone": "full-modernization-phase2",
"reason": "Legacy styles pending full token and scope migration"
},
{
"id": "style-legacy-tables",
"scope": "/tables",
"owner": "frontend-mes-reporting",
"milestone": "full-modernization-phase2",
"reason": "Legacy styles pending full token and scope migration"
},
{
"id": "style-legacy-excel-query",
"scope": "/excel-query",
"owner": "frontend-mes-reporting",
"milestone": "full-modernization-phase2",
"reason": "Legacy styles pending full token and scope migration"
},
{
"id": "style-legacy-query-tool",
"scope": "/query-tool",
"owner": "frontend-mes-reporting",
"milestone": "full-modernization-phase2",
"reason": "Legacy styles pending full token and scope migration"
},
{
"id": "style-legacy-mid-section-defect",
"scope": "/mid-section-defect",
"owner": "frontend-mes-reporting",
"milestone": "full-modernization-phase2",
"reason": "Legacy styles pending full token and scope migration"
}
]
}

View File

@@ -1,20 +0,0 @@
{
"routes": {
"/wip-overview": { "known_issues": [] },
"/wip-detail": { "known_issues": [] },
"/hold-overview": { "known_issues": [] },
"/hold-detail": { "known_issues": [] },
"/hold-history": { "known_issues": [] },
"/reject-history": { "known_issues": [] },
"/resource": { "known_issues": [] },
"/resource-history": { "known_issues": [] },
"/qc-gate": { "known_issues": [] },
"/job-query": { "known_issues": [] },
"/tables": { "known_issues": [] },
"/excel-query": { "known_issues": [] },
"/query-tool": { "known_issues": [] },
"/mid-section-defect": { "known_issues": [] },
"/admin/pages": { "known_issues": [] },
"/admin/performance": { "known_issues": [] }
}
}

View File

@@ -1,6 +0,0 @@
{
"severity_mode": {
"current": "block"
},
"deferred_routes_excluded": []
}

View File

@@ -1,9 +0,0 @@
{
"mode": "block",
"errors": [],
"warnings": [
"/excel-query uses shell tokens without fallback ['--portal-shadow-panel'] in frontend/src/excel-query/style.css with approved exception"
],
"info": [],
"passed": true
}

View File

@@ -1,208 +0,0 @@
{
"routes": [
{
"route": "/wip-overview",
"route_id": "wip-overview",
"title": "WIP Overview",
"scope": "in-scope",
"render_mode": "native",
"owner": "frontend-mes-reporting",
"visibility_policy": "released_or_admin",
"canonical_shell_path": "/portal-shell/wip-overview",
"rollback_strategy": "fallback_to_legacy_route",
"compatibility_policy": "redirect_to_shell_when_spa_enabled"
},
{
"route": "/wip-detail",
"route_id": "wip-detail",
"title": "WIP Detail",
"scope": "in-scope",
"render_mode": "native",
"owner": "frontend-mes-reporting",
"visibility_policy": "released_or_admin",
"canonical_shell_path": "/portal-shell/wip-detail",
"rollback_strategy": "fallback_to_legacy_route",
"compatibility_policy": "redirect_to_shell_when_spa_enabled"
},
{
"route": "/hold-overview",
"route_id": "hold-overview",
"title": "Hold Overview",
"scope": "in-scope",
"render_mode": "native",
"owner": "frontend-mes-reporting",
"visibility_policy": "released_or_admin",
"canonical_shell_path": "/portal-shell/hold-overview",
"rollback_strategy": "fallback_to_legacy_route",
"compatibility_policy": "redirect_to_shell_when_spa_enabled"
},
{
"route": "/hold-detail",
"route_id": "hold-detail",
"title": "Hold Detail",
"scope": "in-scope",
"render_mode": "native",
"owner": "frontend-mes-reporting",
"visibility_policy": "released_or_admin",
"canonical_shell_path": "/portal-shell/hold-detail",
"rollback_strategy": "fallback_to_legacy_route",
"compatibility_policy": "redirect_to_shell_when_spa_enabled"
},
{
"route": "/hold-history",
"route_id": "hold-history",
"title": "Hold History",
"scope": "in-scope",
"render_mode": "native",
"owner": "frontend-mes-reporting",
"visibility_policy": "released_or_admin",
"canonical_shell_path": "/portal-shell/hold-history",
"rollback_strategy": "fallback_to_legacy_route",
"compatibility_policy": "redirect_to_shell_when_spa_enabled"
},
{
"route": "/reject-history",
"route_id": "reject-history",
"title": "Reject History",
"scope": "in-scope",
"render_mode": "native",
"owner": "frontend-mes-reporting",
"visibility_policy": "released_or_admin",
"canonical_shell_path": "/portal-shell/reject-history",
"rollback_strategy": "fallback_to_legacy_route",
"compatibility_policy": "redirect_to_shell_when_spa_enabled"
},
{
"route": "/resource",
"route_id": "resource",
"title": "Resource",
"scope": "in-scope",
"render_mode": "native",
"owner": "frontend-mes-reporting",
"visibility_policy": "released_or_admin",
"canonical_shell_path": "/portal-shell/resource",
"rollback_strategy": "fallback_to_legacy_route",
"compatibility_policy": "redirect_to_shell_when_spa_enabled"
},
{
"route": "/resource-history",
"route_id": "resource-history",
"title": "Resource History",
"scope": "in-scope",
"render_mode": "native",
"owner": "frontend-mes-reporting",
"visibility_policy": "released_or_admin",
"canonical_shell_path": "/portal-shell/resource-history",
"rollback_strategy": "fallback_to_legacy_route",
"compatibility_policy": "redirect_to_shell_when_spa_enabled"
},
{
"route": "/qc-gate",
"route_id": "qc-gate",
"title": "QC Gate",
"scope": "in-scope",
"render_mode": "native",
"owner": "frontend-mes-reporting",
"visibility_policy": "released_or_admin",
"canonical_shell_path": "/portal-shell/qc-gate",
"rollback_strategy": "fallback_to_legacy_route",
"compatibility_policy": "redirect_to_shell_when_spa_enabled"
},
{
"route": "/job-query",
"route_id": "job-query",
"title": "Job Query",
"scope": "in-scope",
"render_mode": "native",
"owner": "frontend-mes-reporting",
"visibility_policy": "released_or_admin",
"canonical_shell_path": "/portal-shell/job-query",
"rollback_strategy": "fallback_to_legacy_route",
"compatibility_policy": "redirect_to_shell_when_spa_enabled"
},
{
"route": "/admin/pages",
"route_id": "admin-pages",
"title": "Admin Pages",
"scope": "in-scope",
"render_mode": "external",
"owner": "frontend-platform-admin",
"visibility_policy": "admin_only",
"canonical_shell_path": "/portal-shell/admin/pages",
"rollback_strategy": "external_route_reversion",
"compatibility_policy": "external_target_redirect"
},
{
"route": "/admin/performance",
"route_id": "admin-performance",
"title": "Admin Performance",
"scope": "in-scope",
"render_mode": "native",
"owner": "frontend-platform-admin",
"visibility_policy": "admin_only",
"canonical_shell_path": "/portal-shell/admin/performance",
"rollback_strategy": "fallback_to_legacy_route",
"compatibility_policy": "redirect_to_shell_when_spa_enabled"
},
{
"route": "/tables",
"route_id": "tables",
"title": "Tables",
"scope": "in-scope",
"render_mode": "native",
"owner": "frontend-mes-reporting",
"visibility_policy": "released_or_admin",
"canonical_shell_path": "/portal-shell/tables",
"rollback_strategy": "fallback_to_legacy_route",
"compatibility_policy": "redirect_to_shell_when_spa_enabled"
},
{
"route": "/excel-query",
"route_id": "excel-query",
"title": "Excel Query",
"scope": "in-scope",
"render_mode": "native",
"owner": "frontend-mes-reporting",
"visibility_policy": "released_or_admin",
"canonical_shell_path": "/portal-shell/excel-query",
"rollback_strategy": "fallback_to_legacy_route",
"compatibility_policy": "redirect_to_shell_when_spa_enabled"
},
{
"route": "/query-tool",
"route_id": "query-tool",
"title": "Query Tool",
"scope": "in-scope",
"render_mode": "native",
"owner": "frontend-mes-reporting",
"visibility_policy": "released_or_admin",
"canonical_shell_path": "/portal-shell/query-tool",
"rollback_strategy": "fallback_to_legacy_route",
"compatibility_policy": "redirect_to_shell_when_spa_enabled"
},
{
"route": "/mid-section-defect",
"route_id": "mid-section-defect",
"title": "Mid Section Defect",
"scope": "in-scope",
"render_mode": "native",
"owner": "frontend-mes-reporting",
"visibility_policy": "released_or_admin",
"canonical_shell_path": "/portal-shell/mid-section-defect",
"rollback_strategy": "fallback_to_legacy_route",
"compatibility_policy": "redirect_to_shell_when_spa_enabled"
},
{
"route": "/material-trace",
"route_id": "material-trace",
"title": "Material Trace",
"scope": "in-scope",
"render_mode": "native",
"owner": "frontend-mes-reporting",
"visibility_policy": "released_or_admin",
"canonical_shell_path": "/portal-shell/material-trace",
"rollback_strategy": "fallback_to_legacy_route",
"compatibility_policy": "redirect_to_shell_when_spa_enabled"
}
]
}

View File

@@ -1,21 +0,0 @@
{
"in_scope": [
{ "route": "/wip-overview", "category": "report" },
{ "route": "/wip-detail", "category": "report" },
{ "route": "/hold-overview", "category": "report" },
{ "route": "/hold-detail", "category": "report" },
{ "route": "/hold-history", "category": "report" },
{ "route": "/reject-history", "category": "report" },
{ "route": "/resource", "category": "report" },
{ "route": "/resource-history", "category": "report" },
{ "route": "/qc-gate", "category": "report" },
{ "route": "/job-query", "category": "report" },
{ "route": "/tables", "category": "report" },
{ "route": "/excel-query", "category": "report" },
{ "route": "/query-tool", "category": "report" },
{ "route": "/mid-section-defect", "category": "report" },
{ "route": "/admin/pages", "category": "admin" },
{ "route": "/admin/performance", "category": "admin" }
],
"deferred": []
}

View File

@@ -1,4 +0,0 @@
{
"routes": {},
"notes": "Baseline placeholder inventory"
}

View File

@@ -1,46 +0,0 @@
{
"source": "current frontend API consumption contracts",
"apis": {
"/api/wip/overview/summary": {
"required_keys": [
"dataUpdateDate",
"runLots",
"queueLots",
"holdLots"
],
"notes": "summary header and cards depend on these fields"
},
"/api/wip/overview/matrix": {
"required_keys": [
"workcenters",
"packages",
"matrix",
"workcenter_totals"
],
"notes": "matrix table rendering contract"
},
"/api/wip/hold-detail/summary": {
"required_keys": [
"workcenterCount",
"packageCount",
"lotCount"
],
"notes": "hold detail summary cards contract"
},
"/api/resource/history/summary": {
"required_keys": [
"kpi",
"trend",
"heatmap",
"workcenter_comparison"
],
"notes": "resource history chart summary contract"
},
"/api/resource/history/detail": {
"required_keys": [
"data"
],
"notes": "detail table contract (plus truncated/max_records metadata when present)"
}
}
}

View File

@@ -1,4 +0,0 @@
{
"source": "data/page_status.json",
"errors": []
}

View File

@@ -1,207 +0,0 @@
{
"source": "data/page_status.json",
"admin": [
{
"id": "reports",
"name": "即時報表",
"order": 1,
"admin_only": false,
"pages": [
{
"route": "/wip-overview",
"name": "WIP 即時概況",
"status": "released",
"order": 1
},
{
"route": "/hold-overview",
"name": "Hold 即時概況",
"status": "released",
"order": 2
},
{
"route": "/resource",
"name": "設備即時概況",
"status": "released",
"order": 4
},
{
"route": "/qc-gate",
"name": "QC-GATE 狀態",
"status": "released",
"order": 6
}
]
},
{
"id": "drawer-2",
"name": "歷史報表",
"order": 2,
"admin_only": false,
"pages": [
{
"route": "/hold-history",
"name": "Hold 歷史績效",
"status": "released",
"order": 3
},
{
"route": "/reject-history",
"name": "報廢歷史查詢",
"status": "released",
"order": 4
},
{
"route": "/resource-history",
"name": "設備歷史績效",
"status": "released",
"order": 5
}
]
},
{
"id": "drawer",
"name": "查詢工具",
"order": 3,
"admin_only": false,
"pages": [
{
"route": "/job-query",
"name": "設備維修查詢",
"status": "released",
"order": 1
},
{
"route": "/query-tool",
"name": "批次追蹤工具",
"status": "released",
"order": 2
},
{
"route": "/mid-section-defect",
"name": "製程不良追溯分析",
"status": "released",
"order": 3
}
]
},
{
"id": "dev-tools",
"name": "開發工具",
"order": 4,
"admin_only": true,
"pages": [
{
"route": "/tables",
"name": "表格總覽",
"status": "dev",
"order": 1
},
{
"route": "/admin/pages",
"name": "頁面管理",
"status": "released",
"order": 1
},
{
"route": "/excel-query",
"name": "Excel 批次查詢",
"status": "dev",
"order": 2
},
{
"route": "/admin/performance",
"name": "效能監控",
"status": "dev",
"order": 2
}
]
}
],
"non_admin": [
{
"id": "reports",
"name": "即時報表",
"order": 1,
"admin_only": false,
"pages": [
{
"route": "/wip-overview",
"name": "WIP 即時概況",
"status": "released",
"order": 1
},
{
"route": "/hold-overview",
"name": "Hold 即時概況",
"status": "released",
"order": 2
},
{
"route": "/resource",
"name": "設備即時概況",
"status": "released",
"order": 4
},
{
"route": "/qc-gate",
"name": "QC-GATE 狀態",
"status": "released",
"order": 6
}
]
},
{
"id": "drawer-2",
"name": "歷史報表",
"order": 2,
"admin_only": false,
"pages": [
{
"route": "/hold-history",
"name": "Hold 歷史績效",
"status": "released",
"order": 3
},
{
"route": "/reject-history",
"name": "報廢歷史查詢",
"status": "released",
"order": 4
},
{
"route": "/resource-history",
"name": "設備歷史績效",
"status": "released",
"order": 5
}
]
},
{
"id": "drawer",
"name": "查詢工具",
"order": 3,
"admin_only": false,
"pages": [
{
"route": "/job-query",
"name": "設備維修查詢",
"status": "released",
"order": 1
},
{
"route": "/query-tool",
"name": "批次追蹤工具",
"status": "released",
"order": 2
},
{
"route": "/mid-section-defect",
"name": "製程不良追溯分析",
"status": "released",
"order": 3
}
]
}
]
}

View File

@@ -1,46 +0,0 @@
{
"source": "frontend route parsing and current parity matrix",
"routes": {
"/wip-overview": {
"query_keys": [
"workorder",
"lotid",
"package",
"type",
"status"
],
"notes": "filters + status URL state must remain compatible"
},
"/wip-detail": {
"query_keys": [
"workcenter",
"workorder",
"lotid",
"package",
"type",
"status"
],
"notes": "workcenter deep-link and back-link query continuity"
},
"/hold-detail": {
"query_keys": [
"reason"
],
"notes": "reason required for normal access flow"
},
"/resource-history": {
"query_keys": [
"start_date",
"end_date",
"granularity",
"workcenter_groups",
"families",
"resource_ids",
"is_production",
"is_key",
"is_monitor"
],
"notes": "query/export params must remain compatible"
}
}
}

273
docs/oom_agent_work_plan.md Normal file
View File

@@ -0,0 +1,273 @@
# OOM 防護跨工具改善 — Agent 工作架構
> 基於 `oom_cache_cross_apply_analysis.md` 痛點分析 + 4 個研究 agent 的代碼深度掃描結果
---
## Agent 架構總覽
```
┌─────────────────────┐
│ agent-shared (P0) │ ← 必須先完成
│ 共用記憶體守衛抽離 │
└─────────┬───────────┘
┌───────────────┼───────────────┐
▼ ▼ ▼
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ agent-mt (P1) │ │ agent-qt (P1) │ │ agent-md (P1) │ ← 可平行
│ Material Trace│ │ Query Tool │ │ Mid-Defect │
└───────┬───────┘ └───────┬───────┘ └───────┬───────┘
│ │ │
└─────────────────┼─────────────────┘
┌─────────────────────┐
│ agent-qa (P2) │ ← 最後驗證
│ 測試 + 壓力測試 │
└─────────────────────┘
```
---
## Agent 0: agent-shared — 共用記憶體守衛抽離
### 目標
`reject_dataset_cache.py` 的私有 `_enforce_interactive_memory_guard()` 抽離為 `core/` 層可重用元件。
### 發現
- `_enforce_interactive_memory_guard` 目前是 `reject_dataset_cache.py`**module-private 函式**
- 依賴 `_df_memory_mb()``_process_rss_mb()` 兩個 helper也是同檔案 private
- `_process_rss_mb()``worker_memory_guard._current_rss_mb()` **完全重複**
- 4 個常數全部硬編碼為 reject-history 專用(`REJECT_DERIVE_*`
### 任務清單
| # | 任務 | 改動檔案 | 行數估計 |
|---|------|---------|---------|
| S1 | 建立 `core/interactive_memory_guard.py` | 新建 | ~60 行 |
| S2 | 抽離 `df_memory_mb()` + `process_rss_mb()` 為 public | 新檔案 | 含在 S1 |
| S3 | 主函式 `enforce_dataset_memory_guard()` — 參數化閾值(帶預設值) | 新檔案 | 含在 S1 |
| S4 | `reject_dataset_cache.py` 改為 import 新元件 | 修改 | ~15 行 |
| S5 | 合併 `worker_memory_guard._current_rss_mb()` 重複邏輯 | 修改 | ~10 行 |
| S6 | 新增單元測試 | 新建 | ~80 行 |
### 新元件 API 設計(草案)
```python
# core/interactive_memory_guard.py
def df_memory_mb(df: pd.DataFrame) -> float: ...
def process_rss_mb() -> Optional[float]: ...
def enforce_dataset_memory_guard(
df: pd.DataFrame,
*,
operation: str,
query_id: str = "",
max_input_mb: float = 96.0,
max_projected_rss_mb: float = 1100.0,
working_set_factor: float = 1.8,
) -> None:
"""跨工具通用 — DataFrame + RSS 投影守衛。超限 raise MemoryError。"""
```
---
## Agent 1: agent-mt — Material Trace
### 痛點(代碼驗證)
| 痛點 | 嚴重度 | 代碼位置 | 驗證結果 |
|------|:------:|---------|---------|
| 分頁重查 Oracle | 🔴 | `service.py:246` `forward_query()` | 每次翻頁走 `_execute_batched_query()` 完整重查 |
| Export 全量 materialize | 🔴 | `service.py:315` `export_csv()` | `read_sql_df_slow` + `to_csv().encode()` 全在記憶體 |
| 正向查詢無行數上限 | 🟡 | `forward_by_lot.sql` / `forward_by_workorder.sql` | 確認無 FETCH FIRST |
| 記憶體守衛不投影 RSS | 🟡 | `service.py:156` `_check_memory_guard()` | 只檢 DataFrame 大小,不看 RSS |
| 完全零快取 | 🔴 | service.py 全域 import | 無任何 Redis/LRU/DuckDB import |
### 任務清單
| # | 任務 | 改動檔案 | 行數估計 | 優先序 |
|---|------|---------|---------|:---:|
| MT1 | Redis 查詢結果快取 — key=(mode,values,wc_groups) hash, TTL=5min | `service.py` | ~80 行 | P0 |
| MT2 | 分頁/匯出讀快取 — `_paginate()` 從 Redis 讀取而非重查 | `service.py` | ~40 行 | P0 |
| MT3 | 升級記憶體守衛 — `_check_memory_guard` 改用 `enforce_dataset_memory_guard` | `service.py` | ~15 行 | P1 |
| MT4 | 正向 SQL 加 `FETCH FIRST 50001 ROWS ONLY` + truncation 標記 | `forward_by_*.sql` + `service.py` | ~25 行 | P1 |
| MT5 | Export 串流化 — 改用 `read_sql_df_slow_iter` + generator yield | `service.py` | ~40 行 | P2 |
| MT6 | 強制 GC — `_execute_batched_query``gc.collect()` | `service.py` | ~5 行 | P2 |
| MT7 | 新增/更新測試 | `tests/test_material_trace_*.py` | ~100 行 | — |
### 快取架構設計
```
POST /query (page=1)
→ hash = md5(mode + sorted(values) + sorted(wc_groups))
→ cache_key = "mt:result:{hash}"
→ cache MISS → Oracle 查詢 → DataFrame → store Redis (parquet bytes, TTL=5min)
→ _paginate(df, page=1, per_page=50)
POST /query (page=2) ← 翻頁
→ same cache_key
→ cache HIT → Redis load DataFrame
→ _paginate(df, page=2, per_page=50) ← 不打 Oracle
POST /export
→ same cache_key
→ cache HIT → Redis load DataFrame → streaming CSV
```
---
## Agent 2: agent-qt — Query Tool
### 痛點(代碼驗證)
| 痛點 | 嚴重度 | 代碼位置 | 驗證結果 |
|------|:------:|---------|---------|
| 明細全量回傳 | 🔴 | `lot_history.sql` 無 LIMIT | lot-history/associations/equipment-lots 全無 server-side 分頁 |
| EventFetcher 無累積上限 | 🔴 | `event_fetcher.py:251` `_fetch_and_group_batch` | `grouped` dict 無限累積fetchmany 只節省 Oracle 端 |
| equipment-lots 無快取+全量 | 🔴 | `service.py:1604` | 365天×20台→10萬+行全量 DataFrame零快取 |
| CSV export 全量 materialize | 🟡 | `routes.py:816` | 先 `get_lot_history_batch()` 全量,再 yield CSV |
| 無互動式 RSS 守衛 | 🟡 | 全部服務函數 | 完全依賴被動式 worker_memory_guard |
| 48M 無索引表 | 🟡 | `lot_split_merge_history.sql` | fast mode 有 6月+500行限制部分緩解 |
### 任務清單
| # | 任務 | 改動檔案 | 行數估計 | 優先序 |
|---|------|---------|---------|:---:|
| QT1 | EventFetcher 加 total-result 守衛 — 累積 > N 行截斷+標記 | `event_fetcher.py` | ~25 行 | P0 |
| QT2 | 重型端點前加 RSS 投影守衛 | `service.py` (5 處) | ~30 行 | P0 |
| QT3 | lot-history SQL 加 OFFSET/FETCH 分頁 | `lot_history.sql` + `service.py` + `routes.py` | ~60 行 | P1 |
| QT4 | equipment-lots SQL 加 OFFSET/FETCH 分頁 | `equipment_lots.sql` + `service.py` + `routes.py` | ~60 行 | P1 |
| QT5 | equipment-lots 加 Redis 快取(同 equipment_status_hours 模式) | `service.py` | ~30 行 | P1 |
| QT6 | split_merge_history full mode 預設 fast | `service.py:1128` | ~10 行 | P2 |
| QT7 | 重型端點 response 後 gc.collect() | `routes.py` | ~10 行 | P2 |
| QT8 | 新增/更新測試 | `tests/test_query_tool_*.py` | ~150 行 | — |
### EventFetcher 守衛設計
```python
# event_fetcher.py — _fetch_and_group_batch 內
_TOTAL_RESULT_MAX_ROWS = int(os.getenv("EVENT_FETCHER_MAX_TOTAL_ROWS", "500000"))
total_row_count += 1
if total_row_count > _TOTAL_RESULT_MAX_ROWS:
logger.warning("EventFetcher total rows %d exceeds limit %d, truncating",
total_row_count, _TOTAL_RESULT_MAX_ROWS)
meta["truncated"] = True
break # 停止累積,回傳已有資料
```
---
## Agent 3: agent-md — Mid-Section Defect
### 痛點(代碼驗證)
| 痛點 | 嚴重度 | 代碼位置 | 驗證結果 |
|------|:------:|---------|---------|
| RQ 健康檢查只看 import | 🔴 | `trace_job_service.py:51` | `_check_rq_available()` 只做 `import rq`,不 ping Redis不檢 worker |
| Sync fallback 無 RSS 守衛 | 🔴 | `trace_routes.py:662` | enqueue 失敗→直接走 syncMSD 甚至不拒絕 >50K CIDs |
| Stampede lock 只在 legacy path | 🟡 | `service.py:169` | 三階段 events 端點完全無 stampede 防護 |
| Lock wait 90s fail-open | 🟡 | `service.py:195` | 等 90s 後直接執行,可能 stampede |
| Aggregation 全量在記憶體 | 🟡 | `service.py:292` | `build_trace_aggregation_from_events` 需完整 events dict |
| Export 全量 materialize | 🟡 | `service.py:644` | `query_analysis()` 全量 → yield CSV |
### 任務清單
| # | 任務 | 改動檔案 | 行數估計 | 優先序 |
|---|------|---------|---------|:---:|
| MD1 | RQ 健康監控升級 — 加 `conn.ping()` + worker 存活檢查 + TTL cache | `trace_job_service.py` | ~40 行 | P0 |
| MD2 | Sync 路徑 RSS 投影守衛 — events 入口處檢查,超限返回 503 | `trace_routes.py` | ~35 行 | P0 |
| MD3 | RQ 不可用時前端降級提示 — 健康端點 + 前端提示 | `health_routes.py` + Vue | ~30 行 | P1 |
| MD4 | Stampede lock timeout 延長 — 90s → 180s | `service.py` 常數 | ~5 行 | P1 |
| MD5 | Events 端點加 stampede lock | `trace_routes.py` | ~20 行 | P1 |
| MD6 | Aggregation 前 RSS checkpoint | `service.py:292` | ~15 行 | P2 |
| MD7 | 新增/更新測試 | `tests/test_mid_section_defect_*.py` | ~120 行 | — |
### RQ 健康檢查升級設計
```python
# trace_job_service.py
_RQ_HEALTH_TTL = 60 # 秒
_rq_health_cache = {"available": None, "checked_at": 0}
def is_async_available() -> bool:
now = time.monotonic()
if now - _rq_health_cache["checked_at"] < _RQ_HEALTH_TTL:
return _rq_health_cache["available"]
if not _check_rq_available():
_update_cache(False, now)
return False
conn = get_redis_client()
if conn is None:
_update_cache(False, now)
return False
try:
conn.ping() # ← 新增:實際 ping Redis
except Exception:
_update_cache(False, now)
return False
# 新增:檢查 worker 存活
try:
from rq import Queue
q = Queue("trace", connection=conn)
if q.count > 100: # queue 堵塞
logger.warning("RQ queue backed up: %d jobs", q.count)
workers = rq.Worker.all(queue=q)
if not workers:
_update_cache(False, now)
return False
except Exception:
pass # fail-open for worker check
_update_cache(True, now)
return True
```
---
## Agent 4: agent-qa — 測試驗證
### 任務清單
| # | 任務 | 範圍 |
|---|------|------|
| QA1 | 跑既有測試套件確認無回歸 | `pytest tests/` |
| QA2 | 驗證新 `core/interactive_memory_guard.py` 單元測試 | 新測試 |
| QA3 | 驗證 Material Trace 快取行為hit/miss/TTL | `test_material_trace_*.py` |
| QA4 | 驗證 EventFetcher 截斷邏輯 | `test_event_fetcher.py` |
| QA5 | 驗證 RQ 健康檢查各場景worker 存活/掉線/Redis 斷線) | `test_trace_job_service.py` |
| QA6 | 壓力測試:模擬高 CID 數量的記憶體用量 | `tests/stress/` |
---
## 執行順序與依賴
```
Week 1:
Day 1-2: agent-shared (S1-S6) ← 阻塞後續
Day 2: 開始 agent-mt, agent-qt, agent-md (平行)
Week 1-2:
agent-mt: MT1→MT2→MT3→MT4→MT5→MT6→MT7
agent-qt: QT1→QT2→QT3→QT4→QT5→QT6→QT7→QT8
agent-md: MD1→MD2→MD3→MD4→MD5→MD6→MD7
Week 2:
agent-qa: QA1→QA6 (全部完成後)
```
---
## 風險與注意事項
1. **Material Trace Redis 快取需要 `core/redis_df_store.py`** — 已被 equipment_status_hours 使用API 可直接沿用
2. **EventFetcher 截斷會影響中段缺陷** — mid-section defect 的 events 也走 EventFetcher需確保截斷標記正確傳遞
3. **SQL OFFSET/FETCH 需要 Oracle 12c+** — 確認生產環境版本支援
4. **RQ worker 檢查有效能開銷** — 使用 60s TTL cache 避免每次請求都查

View File

@@ -0,0 +1,243 @@
# 防 OOM 與快取機制跨工具套用分析
> 基於報廢歷史 (reject-history) 已建立的防護體系,評估物料追溯、查詢工具、中段缺陷三個工具的現況缺口與改善建議。
## 標竿:報廢歷史已具備的機制
### 防 OOM6 層縱深防禦)
| 層級 | 機制 | 檔案 | 作用 |
|------|------|------|------|
| A | Worker RSS 記憶體守衛 | `core/worker_memory_guard.py` | daemon 每 15s 檢查 RSS70% 告警 → 85% 清快取+GC → 95% SIGTERM 重啟 |
| B | 互動式記憶體守衛 | `services/reject_dataset_cache.py` | 每次快取派生前DataFrame > 96MB 拒絕;投影 RSS > 1100MB 拒絕 |
| C | 強制 GC | `services/reject_dataset_cache.py` | 每次互動計算後 `gc.collect()` |
| D | Chunk 記憶體限制 | `services/batch_query_engine.py` | 每個 Oracle 查詢分塊 > 192MB 丟棄 |
| E | 大結果溢出至 Parquet | `core/query_spool_store.py` | > 20 萬行或 48MB 寫磁碟Redis 僅存指標2GB 總量上限 |
| F | Gunicorn Worker 回收 | `gunicorn.conf.py` | 每 1200±300 request 自動回收 worker |
### 快取4 層 + 2 加速器)
| 層級 | 機制 | 作用 |
|------|------|------|
| L1 | ProcessLevelCache進程內 LRU+TTL | dataset TTL=15min/8 entries |
| L2 | Redis跨 worker | DataFrame 存為 parquet bytes |
| L3 | Parquet Spool Store磁碟溢出 | 大型結果寫磁碟,背景每 5 分鐘清理 |
| 加速器 1 | DuckDB SQL Runtime | 對 Parquet 跑 SQL 算 batch-pareto/view/export |
| 加速器 2 | Materialized Pareto 聚合 | 預計算 6 維指標 cube |
| 批次引擎 | Batch Query Engine | 長日期範圍分月拆解 + Redis 分塊快取 |
---
## 現況對照矩陣
| 防護機制 | 報廢歷史 | 物料追溯 | 查詢工具 | 中段缺陷 |
|----------|:---:|:---:|:---:|:---:|
| 互動式記憶體守衛 (RSS 投影) | ✅ | ⚠️ 僅 DataFrame 大小 | ❌ | ❌ |
| Parquet 磁碟溢出 | ✅ | ❌ | ❌ | ❌ |
| DuckDB SQL Runtime | ✅ | ❌ | ❌ | ❌ |
| 物化聚合快照 | ✅ | ❌ | ❌ | ❌ |
| 批次引擎 (時間分片) | ✅ | ❌ | ❌ | ✅ 偵測查詢 |
| fetchmany 迭代器 | ❌ | ❌ | ✅ EventFetcher | ✅ EventFetcher |
| 非同步 Job Queue (RQ) | ❌ | ❌ | ❌ | ✅ >20K CIDs |
| NDJSON 串流 | ❌ | ❌ | ❌ | ✅ |
| 分散式鎖防 stampede | ❌ | ❌ | ❌ | ✅ |
| 強制 GC | ✅ | ❌ | ❌ | ⚠️ 僅 >10K CIDs |
| Redis 快取 | ✅ 三層 | ❌ 完全無 | ✅ resolve+events | ✅ 多層 |
| SQL 行數上限 | ✅ | ⚠️ 僅反向 | ❌ | ❌ |
| 分頁/匯出重查 Oracle | ❌ cache-based | ✅ 每次重查 | 部分 | ❌ cache-based |
---
## 工具一:物料追溯 (Material Trace)
### 架構概述
- 路由:`routes/material_trace_routes.py`3 端點query / export / filter-options
- 服務:`services/material_trace_service.py`(正向/反向查詢、CSV 匯出)
- 主表:`DWH.DW_MES_LOTMATERIALSHISTORY`18M 行)
- 查詢模式:同步、無快取、每次分頁/匯出都完整重查 Oracle
### 已有防護
- DataFrame 記憶體守衛 `_check_memory_guard()` — 256MB 上限(僅 DataFrame 大小,不投影 RSS
- IN 子句批次拆分 `_IN_BATCH_SIZE=1000`
- 輸入數量限制:正向 200 筆、反向 50 筆
- 反向查詢 SQL 行數上限 `FETCH FIRST 10001 ROWS ONLY`
- 匯出行數上限 50,000
- Wildcard 前綴安全 `CONTAINER_RESOLVE_PATTERN_MIN_PREFIX_LEN=2`
- 限流query 30/min、export 10/min
### 痛點
| 風險 | 嚴重度 | 說明 |
|------|:------:|------|
| 分頁重查 Oracle | 🔴 高 | 每翻一頁都重新跑完整 Oracle 查詢 + DataFrame 全量建構再切片 |
| Export 重查 Oracle | 🔴 高 | 獨立重查一次5 萬行上限但整個 DataFrame 一次載入 |
| 正向查詢無行數上限 | 🟡 中 | 反向有 FETCH FIRST 但正向無 SQL 級行數限制 |
| 記憶體守衛不投影 RSS | 🟡 中 | 不考慮當前進程 RSS 狀態 |
| 無 GC | 🟢 低 | 靠 Worker RSS 守衛兜底 |
### 建議
| 優先序 | 措施 | 改動量 | 效益 |
|:---:|------|:---:|------|
| P0 | 加入查詢結果快取 — 首次結果存 Redis (key=mode+values hash, TTL=5min),分頁/匯出讀快取 | ~80 行 | 消除 N+1 次 Oracle 查詢 |
| P1 | 升級記憶體守衛 — 加上 RSS 投影檢查 | ~30 行 | 高壓下拒絕大查詢 |
| P1 | 正向查詢加 SQL 行數上限 `FETCH FIRST 50001 ROWS ONLY` + truncation 標記 | ~25 行 | 防意外全表掃描 |
| P2 | Export 串流化 — 改為 generator yield rows | ~40 行 | 降低 export 瞬時記憶體 |
| P2 | 加強制 GC | ~5 行 | 及時釋放臨時 DataFrame |
---
## 工具二:查詢工具 (Query Tool)
### 架構概述
- 路由:`routes/query_tool_routes.py`10 端點)
- 服務:`services/query_tool_service.py`1868 行)+ `services/event_fetcher.py`
- 三大功能:批次追蹤(正向)、流水批反查(反向)、設備生產批次追蹤
- 主表:`DW_MES_LOTWIPHISTORY`53M`DW_MES_LOTMATERIALSHISTORY``DW_MES_HM_LOTMOVEOUT`48M, 無索引)
- 已有 EventFetcher fetchmany 迭代器 + Redis 快取resolve 60s / events 180-300s
### 已有防護
- Slow-query semaphore `DB_SLOW_MAX_CONCURRENT=5`
- fetchmany 迭代器避免 cursor.fetchall() 三重 materialize
- 輸入限制LOT 100 / Serial 100 / WO 50 / Equipment 20 / 日期 365 天
- Container IDs 批次上限 `QUERY_TOOL_MAX_CONTAINER_IDS=200`
- Wildcard 展開限制:每 token 2000 / 總計 30,000
- EventFetcher 大量 CIDs 跳過快取寫入 (>10K)
- 限流resolve 10/min、equipment 5/min、export 3/min
### 痛點
| 風險 | 嚴重度 | 說明 |
|------|:------:|------|
| 明細全量回傳 | 🔴 高 | lot-history / associations / equipment-lots 全部一次回傳,無 server-side 分頁 |
| 48M 行無索引表 | 🔴 高 | `DW_MES_HM_LOTMOVEOUT` split_merge_history 走全表掃描30-120s |
| CSV export 全量 materialize | 🟡 中 | 先完整查詢建 DataFrame 再轉 CSV stream |
| 無互動式記憶體守衛 | 🟡 中 | EventFetcher 逐批但無整體 RSS 投影 |
| 無 Parquet 溢出 | 🟡 中 | 大結果只能在 Redis (512MB) 或記憶體中 |
### 建議
| 優先序 | 措施 | 改動量 | 效益 |
|:---:|------|:---:|------|
| P0 | detail 端點加 server-side 分頁 — SQL 加 OFFSET/FETCH | ~60 行/端點 | 防大 LOT 數千筆 history 一次灌入 |
| P0 | EventFetcher 加 total-result 記憶體守衛 — 累積結果超限截斷+標記 | ~25 行 | 防累積結果超安全線 |
| P1 | 拓展 Parquet 溢出到 EventFetcher — 大結果寫 spool + DuckDB 讀取 | ~120 行 | equipment 365 天查詢安全運作 |
| P1 | 加 RSS 投影守衛 — 重型端點前檢查 projected RSS | ~30 行 | 跨端點整體防護 |
| P2 | split_merge_history 預設 fast 模式 — full 需手動啟用 | ~10 行 | 降低 48M 表衝擊 |
| P2 | 加強制 GC — heavy 端點 response 後 gc.collect() | ~10 行 | 配合 Worker RSS 守衛 |
---
## 工具三:中段缺陷 (Mid-Section Defect)
### 架構概述
- 路由:`routes/mid_section_defect_routes.py`5 端點)+ `routes/trace_routes.py`(三階段管線)
- 服務:`services/mid_section_defect_service.py`1350 行)
- 三階段漸進式管線seed-resolve → lineage → events含歸因統計
- 主表:`DW_MES_LOTWIPHISTORY`53M`DW_MES_LOTREJECTHISTORY``DW_MES_CONTAINER`5.2M
- 已有RQ async job queue、NDJSON 串流、fetchmany 迭代器、Redis 分散式鎖、BatchQueryEngine
### 已有防護
- Worker RSS 記憶體守衛(全域)
- fetchmany 迭代器避免三重 materialize2026-02-25 OOM 事件後加入)
- 非同步 RQ Job Queue — >20K CIDs 導向獨立 worker process
- NDJSON 串流 — 大結果漸進式傳送
- 分散式鎖防 cache stampede120s lock, 90s wait
- BatchQueryEngine — 偵測查詢 >10 天分月拆解
- EventFetcher 大量 CIDs 跳過快取
- 顯式 gc.collect() — >10K CIDs 後觸發
- systemd MemoryMax=6G 硬保護
- 限流analysis 6/min、detail 15/min、export 3/min
### 痛點
| 風險 | 嚴重度 | 說明 |
|------|:------:|------|
| 無 CID 硬拒絕 | 🔴 高 | 設計上刻意不拒資料完整性需求114K CIDs 是真實場景sync 路徑峰值 4-6GB |
| RQ 不可用時 fallback 到 sync | 🔴 高 | RQ worker 掛掉時 >20K CIDs 仍由 gunicorn worker 同步處理 |
| Aggregation 全量在記憶體 | 🟡 中 | `build_trace_aggregation_from_events()` 需完整 events dict 做歸因 |
| Cache stampede fail-open 90s | 🟡 中 | 管線 > 90s 時第二個 request 也開始跑,雙重 Oracle 負載 |
| Export 全量 materialize | 🟡 中 | 從快取讀全部 detail list 再 stream |
| SQL/Python workcenter 分類漂移 | 🟢 低 | CASE WHEN 與 Python dict 需手動同步 |
### 建議
| 優先序 | 措施 | 改動量 | 效益 |
|:---:|------|:---:|------|
| P0 | RQ 健康監控 + 降級提示 — 定期驗證 RQ worker 存活,不可用時前端顯示警告 | ~50 行 | 防靜默 fallback 到 sync 撐爆 worker |
| P0 | Sync 路徑加 RSS 投影守衛 — 超限返回 503 + retry-after | ~35 行 | 保護 gunicorn worker |
| P1 | Parquet 溢出 for events — 大結果寫 spoolaggregation 改用 DuckDB GROUP BY | ~200 行 | 114K CIDs 場景記憶體從 ~4GB 降到 ~500MB |
| P1 | 延長 stampede lock timeout — 從 90s 提高到 180s 或改 pub/sub 通知 | ~20 行 | 減少雙重查詢 |
| P2 | Workcenter 分類統一源 — 從 Python dict 自動生成 SQL CASE WHEN | ~60 行 | 消除隱性資料遺漏 |
| P2 | Export 改 DuckDB 串流 — spool 存在時用 fetchmany generator | ~40 行 | 降低 export 峰值記憶體 |
---
## 跨工具共用方案
### 從報廢歷史推廣的通用元件
| 元件 | 推廣對象 | 理由 |
|------|---------|------|
| `_enforce_interactive_memory_guard()` | 全部三個 | 零改架構,插入式防護,~30 行 |
| Parquet spool + DuckDB SQL | 查詢工具、中段缺陷 | EventFetcher 大結果目前僅 in-memory |
| 查詢結果 Redis 快取 | 物料追溯 | 唯一完全零快取的工具 |
### 從中段缺陷推廣的元件
| 元件 | 推廣對象 | 理由 |
|------|---------|------|
| RQ async job queue | 查詢工具 (equipment 長查詢) | 365 天設備查詢可能跑 2-3 分鐘 |
| 分散式鎖防 stampede | 物料追溯(若加快取) | 避免快取冷啟動時多 request 同時打 Oracle |
| sessionStorage 前端快取 | 物料追溯、查詢工具 | 頁面切換回頭不需重查 |
---
## 建議執行順序
```
Phase 1快速加固, 1-2 天):
├─ 全部三個: 加 _enforce_interactive_memory_guard + 強制 GC
├─ 物料追溯: 加 Redis 查詢結果快取(消除分頁/匯出重查)
└─ 物料追溯: 正向查詢加 SQL 行數上限
Phase 2結構性改善, 3-5 天):
├─ 查詢工具: detail 端點加 server-side 分頁
├─ 中段缺陷: RQ 健康監控 + sync 路徑 RSS 守衛
└─ 中段缺陷: stampede lock timeout 延長
Phase 3進階優化, 5-7 天):
├─ 共用: EventFetcher 拓展 Parquet spool 支援
├─ 中段缺陷: aggregation 改用 DuckDB SQL
└─ 查詢工具: 大結果走 spool + DuckDB
```
---
## 相關檔案索引
| 類別 | 關鍵檔案路徑 |
|------|-------------|
| 標竿:報廢歷史快取 | `services/reject_dataset_cache.py` |
| 標竿DuckDB runtime | `services/reject_cache_sql_runtime.py` |
| 標竿Parquet spool | `core/query_spool_store.py` |
| 標竿:記憶體守衛 | `core/worker_memory_guard.py` |
| 標竿:批次引擎 | `services/batch_query_engine.py` |
| 物料追溯 service | `services/material_trace_service.py` |
| 物料追溯 routes | `routes/material_trace_routes.py` |
| 查詢工具 service | `services/query_tool_service.py` |
| 查詢工具 routes | `routes/query_tool_routes.py` |
| 查詢工具 EventFetcher | `services/event_fetcher.py` |
| 中段缺陷 service | `services/mid_section_defect_service.py` |
| 中段缺陷 routes | `routes/mid_section_defect_routes.py` |
| 中段缺陷 trace 管線 | `routes/trace_routes.py` |
| 中段缺陷 async job | `services/trace_job_service.py` |
| 中段缺陷 lineage 引擎 | `services/lineage_engine.py` |
| 共用快取層 | `core/cache.py` |
| 共用 database | `core/database.py` |

View File

@@ -1,125 +0,0 @@
# Reject 歷史績效表設計說明
## 目標
使用 `DW_MES_LOTREJECTHISTORY` 為主,輔以其他維度表,建立可直接用於報表的 `reject` 歷史績效表(按日彙總),解決原始資料直接查詢時的績效與一致性問題。
## 使用資料表
- `DWH.DW_MES_LOTREJECTHISTORY`: 不良/報廢事實表(主來源)
- `DWH.DW_MES_CONTAINER`: 補齊 `PJ_TYPE``PRODUCTLINENAME``MFGORDERNAME`
- `DWH.DW_MES_SPEC_WORKCENTER_V`: 對應 `WORKCENTER_GROUP` 與排序欄位
- `DWH.ERP_PJ_WIP_SCRAP_REASONS_EXCLUDE`: 良率排除政策表(`ENABLE_FLAG='Y'` 代表不納入良率計算)
## 資料評估重點2026-02-13近 30 天樣本)
- `DW_MES_LOTREJECTHISTORY``230,074` 筆;`HISTORYMAINLINEID``75,683` 個。
- `HISTORYMAINLINEID` 多筆情況明顯(`30,784` 個主事件,平均每主事件 `6.02` 筆),代表同主事件會拆成多個 `LOSSREASONNAME`
- 若直接加總 `MOVEINQTY`,分母會被重複計算。近 30 天樣本中:
- `NAIVE_MOVEIN = 44,836,693,831`
- `DEDUP_MOVEIN = 35,658,750,247`
- 膨脹比 `1.2574`(約高估 25.74%
- 指標定義依業務規則分開處理:
- `REJECT_TOTAL_QTY = REJECTQTY + STANDBYQTY + QTYTOPROCESS + INPROCESSQTY + PROCESSEDQTY`(扣帳報廢)
- `DEFECT_QTY = DEFECTQTY`(不扣帳報廢)
- `DW_MES_SPEC_WORKCENTER_V` 若直接以 `WORK_CENTER` join 會放大筆數;需先彙整為唯一 `WORK_CENTER -> GROUP/SEQUENCE` 對照表再 join。
## 績效表欄位與計算邏輯
- 粒度:`日 + 工站群組 + 工站 + 站點規格 + 設備 + 產品維度 + 不良原因`
- 核心指標:
- `REJECT_EVENT_ROWS`: 原始 reject 紀錄筆數
- `AFFECTED_LOT_COUNT`: 受影響 lot 數distinct `CONTAINERID`
- `MOVEIN_QTY`: 以 `HISTORYMAINLINEID` 去重後的投入量
- `REJECT_QTY`: 原始 `REJECTQTY` 加總(五欄之一)
- `REJECT_TOTAL_QTY`: 五個 reject 相關欄位加總(扣帳報廢)
- `DEFECT_QTY`: `DEFECTQTY` 加總(不扣帳報廢)
- `REJECT_RATE_PCT = REJECT_TOTAL_QTY / MOVEIN_QTY * 100`
- `DEFECT_RATE_PCT = DEFECT_QTY / MOVEIN_QTY * 100`
- `REJECT_SHARE_PCT = REJECT_TOTAL_QTY / (REJECT_TOTAL_QTY + DEFECT_QTY) * 100`
## 排除政策與前端開關
- 預設模式:排除 `ERP_PJ_WIP_SCRAP_REASONS_EXCLUDE``ENABLE_FLAG='Y'` 的報廢原因。
- 可切換模式:提供 `include_excluded_scrap=true|false` 讓使用者決定是否納入。
- 前端頁面提供「納入不計良率報廢」開關,並同步影響 summary/trend/pareto/list/export。
- 排除原因清單採全表快取預設每日刷新一次Redis 優先、記憶體 fallback
## API 與欄位契約
- `GET /api/reject-history/options`
- 回傳 `workcenter_groups``reasons` 與政策 `meta`
- `GET /api/reject-history/summary`
- 回傳 `MOVEIN_QTY``REJECT_TOTAL_QTY``DEFECT_QTY``REJECT_RATE_PCT``DEFECT_RATE_PCT``REJECT_SHARE_PCT``AFFECTED_LOT_COUNT``AFFECTED_WORKORDER_COUNT`
- `GET /api/reject-history/trend`
- 回傳趨勢 `items[]`,每筆含 `bucket_date``REJECT_TOTAL_QTY``DEFECT_QTY``REJECT_RATE_PCT``DEFECT_RATE_PCT`
- `GET /api/reject-history/reason-pareto`
- 支援 `metric_mode=reject_total|defect`
- 支援 `pareto_scope=top80|all`(預設 `top80`
- `GET /api/reject-history/list`
- 分頁回傳 `items[]``pagination`
- 明細保留五個 reject 欄位(`REJECT_QTY``STANDBY_QTY``QTYTOPROCESS_QTY``INPROCESS_QTY``PROCESSED_QTY`)與 `DEFECT_QTY`
- `GET /api/reject-history/export`
- CSV 欄位與 list 語義一致,含 `REJECT_TOTAL_QTY``DEFECT_QTY`
## 前端視覺與互動
- 主要區塊:
- Header語義 badge + 更新時間)
- 篩選區(時間、原因、`WORKCENTER_GROUP`、政策開關、Pareto 前 80% 開關)
- KPI8 張卡Reject 暖色語義 / Defect 冷色語義)
- 趨勢圖(報廢量與報廢率分圖)
- Pareto柱狀 + 累積線)與明細表
- 互動規則:
- Pareto 點選原因後,會套用為 active filter chip 並重查
- 再次點選同原因會取消篩選
- 預設僅顯示累計前 80%,可切換顯示完整 Pareto
- 匯出 CSV 使用目前畫面相同篩選條件
## 交付檔案
- 建表 + 刷新 SQL`docs/reject_history_performance.sql`
- 可被應用層直接載入的查詢 SQL`src/mes_dashboard/sql/reject_history/performance_daily.sql`
## 上線與回滾策略
- 上線策略:
- 先維持 `data/page_status.json``/reject-history``dev`
- 完成 UAT 後再改為 `released`
- 回滾策略:
-`/reject-history` 狀態切回 `dev` 或移除導航入口
- 保留 API 與既有頁面,不影響既有報表
- 快取策略:
- 排除政策表每日全表刷新(預設 86400 秒)
- Redis 異常時退回記憶體快取,不阻斷查詢
## 驗證紀錄2026-02-13
- 後端/整合測試:
- `pytest -q tests/test_reject_history_service.py tests/test_scrap_reason_exclusion_cache.py tests/test_reject_history_routes.py tests/test_reject_history_shell_coverage.py tests/test_portal_shell_wave_b_native_smoke.py::test_reject_history_native_smoke_query_sections_and_export tests/test_app_factory.py::AppFactoryTests::test_routes_registered`
- 結果:`22 passed`
- 前端建置:
- `cd frontend && npm run build`
- 結果:成功產出 `reject-history.html/js/css`,並完成 dist 複製流程
## 建議排程
- 每日跑前一日增量:
- `:start_date = TRUNC(SYSDATE - 1)`
- `:end_date = TRUNC(SYSDATE - 1)`
- 每月第一天補跑前 31 天,避免補數漏失。
## Cache-SQL RolloutDuckDB
- 階段順序:`batch-pareto -> view -> export-cached`
- 預設全開:
- `REJECT_CACHE_SQL_ENABLED=true`
- `REJECT_CACHE_SQL_BATCH_PARETO_ENABLED=true`
- `REJECT_CACHE_SQL_VIEW_ENABLED=true`
- `REJECT_CACHE_SQL_EXPORT_ENABLED=true`
- 回退策略(預設回退 legacy
- `REJECT_CACHE_SQL_FALLBACK_LEGACY_ENABLED=true`
- 可用 endpoint 級旗標單獨控制 `*_FALLBACK_LEGACY_ENABLED`
- 灰度建議:
- 先只開 `batch-pareto`,觀察 `pareto_source` 與 fallback reason
- 再開 `view`,確認 `detail.pagination` 與舊版一致
- 最後開 `export-cached`,確認 CSV 欄位/筆數與 view detail 篩選範圍一致
## 驗證清單(前端提示與匯出一致性)
- 前端「柏拉圖顯示限制80%/Top20」僅影響畫面顯示不影響明細與匯出範圍。
- 使用相同 `query_id + filters` 比對:
- `GET /api/reject-history/view``detail.pagination.total`
- `GET /api/reject-history/export-cached` 的匯出總筆數
- 兩者應一致display-only truncation 不得裁掉 export row
- 若觸發 cache-SQL fail-fast`*_FALLBACK_LEGACY_ENABLED=false`),前端需提示使用者重試或縮小條件。
## 已知環境備註
- `tests/test_navigation_contract.py` 需要 `docs/migration/portal-no-iframe/baseline_drawer_visibility.json`。目前工作區缺少此 baseline 檔案,屬既有環境缺口,與本次 reject-history 開發內容無直接耦合。

View File

@@ -1,242 +0,0 @@
/*
Reject 歷史績效表建置腳本
目的:
- 以 DW_MES_LOTREJECTHISTORY 為主,建立可直接報表化的日彙總績效表
- 補齊產品/工站維度,並區分扣帳報廢與不扣帳報廢
*/
/* ============================================================
1) 建表 (執行一次)
============================================================ */
CREATE TABLE DWH.DW_PJ_REJECT_HISTORY_PERF_D (
TXN_DAY DATE NOT NULL,
TXN_MONTH VARCHAR2(7) NOT NULL,
WORKCENTER_GROUP VARCHAR2(40) NOT NULL,
WORKCENTERSEQUENCE_GROUP NUMBER(10) NOT NULL,
WORKCENTERNAME VARCHAR2(40) NOT NULL,
SPECNAME VARCHAR2(40) NOT NULL,
EQUIPMENTNAME VARCHAR2(255) NOT NULL,
PRIMARY_EQUIPMENTNAME VARCHAR2(40) NOT NULL,
PRODUCTLINENAME VARCHAR2(40) NOT NULL,
PJ_TYPE VARCHAR2(40) NOT NULL,
LOSSREASONNAME VARCHAR2(40) NOT NULL,
REJECTCATEGORYNAME VARCHAR2(40) NOT NULL,
REJECT_EVENT_ROWS NUMBER NOT NULL,
AFFECTED_LOT_COUNT NUMBER NOT NULL,
AFFECTED_WORKORDER_COUNT NUMBER NOT NULL,
MOVEIN_QTY NUMBER NOT NULL,
REJECT_QTY NUMBER NOT NULL,
REJECT_TOTAL_QTY NUMBER NOT NULL,
DEFECT_QTY NUMBER NOT NULL,
STANDBY_QTY NUMBER NOT NULL,
QTYTOPROCESS_QTY NUMBER NOT NULL,
INPROCESS_QTY NUMBER NOT NULL,
PROCESSED_QTY NUMBER NOT NULL,
REJECT_RATE_PCT NUMBER(18, 4) NOT NULL,
DEFECT_RATE_PCT NUMBER(18, 4) NOT NULL,
REJECT_SHARE_PCT NUMBER(18, 4) NOT NULL,
LAST_REFRESH_TS DATE NOT NULL
);
CREATE INDEX DWH.IDX_RJH_PERF_D_01 ON DWH.DW_PJ_REJECT_HISTORY_PERF_D (TXN_DAY);
CREATE INDEX DWH.IDX_RJH_PERF_D_02 ON DWH.DW_PJ_REJECT_HISTORY_PERF_D (WORKCENTER_GROUP, TXN_DAY);
CREATE INDEX DWH.IDX_RJH_PERF_D_03 ON DWH.DW_PJ_REJECT_HISTORY_PERF_D (PRIMARY_EQUIPMENTNAME, TXN_DAY);
CREATE INDEX DWH.IDX_RJH_PERF_D_04 ON DWH.DW_PJ_REJECT_HISTORY_PERF_D (LOSSREASONNAME, TXN_DAY);
/* ============================================================
2) 區間刷新 (可每日排程)
綁定參數:
:start_date (YYYY-MM-DD)
:end_date (YYYY-MM-DD)
============================================================ */
DELETE FROM DWH.DW_PJ_REJECT_HISTORY_PERF_D
WHERE TXN_DAY >= TO_DATE(:start_date, 'YYYY-MM-DD')
AND TXN_DAY < TO_DATE(:end_date, 'YYYY-MM-DD') + 1;
INSERT /*+ APPEND */ INTO DWH.DW_PJ_REJECT_HISTORY_PERF_D (
TXN_DAY,
TXN_MONTH,
WORKCENTER_GROUP,
WORKCENTERSEQUENCE_GROUP,
WORKCENTERNAME,
SPECNAME,
EQUIPMENTNAME,
PRIMARY_EQUIPMENTNAME,
PRODUCTLINENAME,
PJ_TYPE,
LOSSREASONNAME,
REJECTCATEGORYNAME,
REJECT_EVENT_ROWS,
AFFECTED_LOT_COUNT,
AFFECTED_WORKORDER_COUNT,
MOVEIN_QTY,
REJECT_QTY,
REJECT_TOTAL_QTY,
DEFECT_QTY,
STANDBY_QTY,
QTYTOPROCESS_QTY,
INPROCESS_QTY,
PROCESSED_QTY,
REJECT_RATE_PCT,
DEFECT_RATE_PCT,
REJECT_SHARE_PCT,
LAST_REFRESH_TS
)
WITH workcenter_map AS (
SELECT
WORK_CENTER,
MIN(WORK_CENTER_GROUP) KEEP (
DENSE_RANK FIRST ORDER BY WORKCENTERSEQUENCE_GROUP
) AS WORKCENTER_GROUP,
MIN(WORKCENTERSEQUENCE_GROUP) AS WORKCENTERSEQUENCE_GROUP
FROM DWH.DW_MES_SPEC_WORKCENTER_V
WHERE WORK_CENTER IS NOT NULL
GROUP BY WORK_CENTER
),
reject_raw AS (
SELECT
TRUNC(r.TXNDATE) AS TXN_DAY,
TO_CHAR(TRUNC(r.TXNDATE), 'YYYY-MM') AS TXN_MONTH,
r.CONTAINERID,
NVL(TRIM(r.PJ_WORKORDER), TRIM(c.MFGORDERNAME)) AS PJ_WORKORDER,
NVL(TRIM(c.PJ_TYPE), '(NA)') AS PJ_TYPE,
NVL(TRIM(c.PRODUCTLINENAME), '(NA)') AS PRODUCTLINENAME,
NVL(TRIM(r.WORKCENTERNAME), '(NA)') AS WORKCENTERNAME,
NVL(TRIM(wm.WORKCENTER_GROUP), NVL(TRIM(r.WORKCENTERNAME), '(NA)')) AS WORKCENTER_GROUP,
NVL(wm.WORKCENTERSEQUENCE_GROUP, 999) AS WORKCENTERSEQUENCE_GROUP,
NVL(TRIM(r.SPECNAME), '(NA)') AS SPECNAME,
NVL(TRIM(r.EQUIPMENTNAME), '(NA)') AS EQUIPMENTNAME,
NVL(
TRIM(REGEXP_SUBSTR(r.EQUIPMENTNAME, '[^,]+', 1, 1)),
NVL(TRIM(r.EQUIPMENTNAME), '(NA)')
) AS PRIMARY_EQUIPMENTNAME,
NVL(TRIM(r.LOSSREASONNAME), '(未填寫)') AS LOSSREASONNAME,
NVL(TRIM(r.REJECTCATEGORYNAME), '(未填寫)') AS REJECTCATEGORYNAME,
NVL(r.MOVEINQTY, 0) AS MOVEINQTY,
NVL(r.REJECTQTY, 0) AS REJECT_QTY,
NVL(r.STANDBYQTY, 0) AS STANDBY_QTY,
NVL(r.QTYTOPROCESS, 0) AS QTYTOPROCESS_QTY,
NVL(r.INPROCESSQTY, 0) AS INPROCESS_QTY,
NVL(r.PROCESSEDQTY, 0) AS PROCESSED_QTY,
NVL(r.REJECTQTY, 0)
+ NVL(r.STANDBYQTY, 0)
+ NVL(r.QTYTOPROCESS, 0)
+ NVL(r.INPROCESSQTY, 0)
+ NVL(r.PROCESSEDQTY, 0) AS REJECT_TOTAL_QTY,
NVL(r.DEFECTQTY, 0) AS DEFECT_QTY,
ROW_NUMBER() OVER (
PARTITION BY NVL(
TRIM(r.HISTORYMAINLINEID),
TRIM(r.CONTAINERID) || ':' || TO_CHAR(r.TXNDATE, 'YYYYMMDDHH24MISS') || ':' || NVL(TRIM(r.SPECID), '-')
)
ORDER BY NVL(TRIM(r.LOSSREASONNAME), ' ')
) AS EVENT_RN
FROM DWH.DW_MES_LOTREJECTHISTORY r
LEFT JOIN DWH.DW_MES_CONTAINER c
ON c.CONTAINERID = r.CONTAINERID
LEFT JOIN workcenter_map wm
ON wm.WORK_CENTER = r.WORKCENTERNAME
WHERE r.TXNDATE >= TO_DATE(:start_date, 'YYYY-MM-DD')
AND r.TXNDATE < TO_DATE(:end_date, 'YYYY-MM-DD') + 1
),
daily_agg AS (
SELECT
TXN_DAY,
TXN_MONTH,
WORKCENTER_GROUP,
WORKCENTERSEQUENCE_GROUP,
WORKCENTERNAME,
SPECNAME,
EQUIPMENTNAME,
PRIMARY_EQUIPMENTNAME,
PRODUCTLINENAME,
PJ_TYPE,
LOSSREASONNAME,
REJECTCATEGORYNAME,
COUNT(*) AS REJECT_EVENT_ROWS,
COUNT(DISTINCT CONTAINERID) AS AFFECTED_LOT_COUNT,
COUNT(DISTINCT PJ_WORKORDER) AS AFFECTED_WORKORDER_COUNT,
SUM(CASE WHEN EVENT_RN = 1 THEN MOVEINQTY ELSE 0 END) AS MOVEIN_QTY,
SUM(REJECT_QTY) AS REJECT_QTY,
SUM(REJECT_TOTAL_QTY) AS REJECT_TOTAL_QTY,
SUM(DEFECT_QTY) AS DEFECT_QTY,
SUM(STANDBY_QTY) AS STANDBY_QTY,
SUM(QTYTOPROCESS_QTY) AS QTYTOPROCESS_QTY,
SUM(INPROCESS_QTY) AS INPROCESS_QTY,
SUM(PROCESSED_QTY) AS PROCESSED_QTY
FROM reject_raw
GROUP BY
TXN_DAY,
TXN_MONTH,
WORKCENTER_GROUP,
WORKCENTERSEQUENCE_GROUP,
WORKCENTERNAME,
SPECNAME,
EQUIPMENTNAME,
PRIMARY_EQUIPMENTNAME,
PRODUCTLINENAME,
PJ_TYPE,
LOSSREASONNAME,
REJECTCATEGORYNAME
)
SELECT
TXN_DAY,
TXN_MONTH,
WORKCENTER_GROUP,
WORKCENTERSEQUENCE_GROUP,
WORKCENTERNAME,
SPECNAME,
EQUIPMENTNAME,
PRIMARY_EQUIPMENTNAME,
PRODUCTLINENAME,
PJ_TYPE,
LOSSREASONNAME,
REJECTCATEGORYNAME,
REJECT_EVENT_ROWS,
AFFECTED_LOT_COUNT,
AFFECTED_WORKORDER_COUNT,
MOVEIN_QTY,
REJECT_QTY,
REJECT_TOTAL_QTY,
DEFECT_QTY,
STANDBY_QTY,
QTYTOPROCESS_QTY,
INPROCESS_QTY,
PROCESSED_QTY,
CASE
WHEN MOVEIN_QTY = 0 THEN 0
ELSE ROUND(REJECT_TOTAL_QTY * 100 / MOVEIN_QTY, 4)
END AS REJECT_RATE_PCT,
CASE
WHEN MOVEIN_QTY = 0 THEN 0
ELSE ROUND(DEFECT_QTY * 100 / MOVEIN_QTY, 4)
END AS DEFECT_RATE_PCT,
CASE
WHEN (REJECT_TOTAL_QTY + DEFECT_QTY) = 0 THEN 0
ELSE ROUND(REJECT_TOTAL_QTY * 100 / (REJECT_TOTAL_QTY + DEFECT_QTY), 4)
END AS REJECT_SHARE_PCT,
SYSDATE AS LAST_REFRESH_TS
FROM daily_agg;
COMMIT;
/* ============================================================
3) 快速驗證查詢
============================================================ */
SELECT
TXN_DAY,
WORKCENTER_GROUP,
SUM(MOVEIN_QTY) AS MOVEIN_QTY,
SUM(REJECT_QTY) AS REJECT_QTY,
SUM(REJECT_TOTAL_QTY) AS REJECT_TOTAL_QTY,
SUM(DEFECT_QTY) AS DEFECT_QTY,
ROUND(SUM(REJECT_TOTAL_QTY) * 100 / NULLIF(SUM(MOVEIN_QTY), 0), 4) AS REJECT_RATE_PCT
FROM DWH.DW_PJ_REJECT_HISTORY_PERF_D
WHERE TXN_DAY BETWEEN TO_DATE(:start_date, 'YYYY-MM-DD') AND TO_DATE(:end_date, 'YYYY-MM-DD')
GROUP BY TXN_DAY, WORKCENTER_GROUP
ORDER BY TXN_DAY DESC, WORKCENTER_GROUP;