chore: reinitialize project with vite architecture
This commit is contained in:
181
.env.example
Normal file
181
.env.example
Normal file
@@ -0,0 +1,181 @@
|
||||
# ============================================================
|
||||
# MES Dashboard Environment Configuration
|
||||
# ============================================================
|
||||
# Copy this file to .env and fill in your actual values:
|
||||
# cp .env.example .env
|
||||
# nano .env
|
||||
# ============================================================
|
||||
|
||||
# ============================================================
|
||||
# Database Configuration (REQUIRED)
|
||||
# ============================================================
|
||||
# Oracle Database connection settings
|
||||
DB_HOST=your_database_host
|
||||
DB_PORT=1521
|
||||
DB_SERVICE=your_service_name
|
||||
DB_USER=your_username
|
||||
DB_PASSWORD=your_password
|
||||
|
||||
# Database Pool Settings (optional, has defaults)
|
||||
# Adjust based on expected load
|
||||
DB_POOL_SIZE=5 # Default: 5 (dev: 2, prod: 10)
|
||||
DB_MAX_OVERFLOW=10 # Default: 10 (dev: 3, prod: 20)
|
||||
DB_POOL_TIMEOUT=30 # Seconds to wait when pool is exhausted
|
||||
DB_POOL_RECYCLE=1800 # Recycle connection after N seconds
|
||||
DB_TCP_CONNECT_TIMEOUT=10
|
||||
DB_CONNECT_RETRY_COUNT=1
|
||||
DB_CONNECT_RETRY_DELAY=1.0
|
||||
DB_CALL_TIMEOUT_MS=55000 # Must stay below worker timeout
|
||||
|
||||
# ============================================================
|
||||
# Flask Configuration
|
||||
# ============================================================
|
||||
# Environment mode: development | production | testing
|
||||
FLASK_ENV=development
|
||||
|
||||
# Debug mode: 0 for production, 1 for development
|
||||
FLASK_DEBUG=0
|
||||
|
||||
# Session Security (REQUIRED for production!)
|
||||
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
|
||||
SECRET_KEY=your-secret-key-change-in-production
|
||||
|
||||
# Session timeout in seconds (default: 28800 = 8 hours)
|
||||
SESSION_LIFETIME=28800
|
||||
|
||||
# ============================================================
|
||||
# Authentication Configuration
|
||||
# ============================================================
|
||||
# LDAP API endpoint for user authentication
|
||||
LDAP_API_URL=https://your-ldap-api-endpoint.example.com
|
||||
|
||||
# Admin email addresses (comma-separated for multiple)
|
||||
ADMIN_EMAILS=admin@example.com
|
||||
|
||||
# Local Authentication (for development/testing)
|
||||
# When enabled, uses local credentials instead of LDAP
|
||||
# Set LOCAL_AUTH_ENABLED=true to bypass LDAP authentication
|
||||
LOCAL_AUTH_ENABLED=false
|
||||
LOCAL_AUTH_USERNAME=
|
||||
LOCAL_AUTH_PASSWORD=
|
||||
|
||||
# ============================================================
|
||||
# Gunicorn Configuration
|
||||
# ============================================================
|
||||
# Server bind address and port
|
||||
GUNICORN_BIND=0.0.0.0:8080
|
||||
|
||||
# Number of worker processes (recommend: 2 * CPU cores + 1)
|
||||
GUNICORN_WORKERS=2
|
||||
|
||||
# Threads per worker
|
||||
GUNICORN_THREADS=4
|
||||
|
||||
# ============================================================
|
||||
# Redis Configuration (for WIP cache)
|
||||
# ============================================================
|
||||
# Redis connection URL
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
|
||||
# Enable/disable Redis cache (set to false to fallback to Oracle)
|
||||
REDIS_ENABLED=true
|
||||
|
||||
# Redis key prefix (to separate from other applications)
|
||||
REDIS_KEY_PREFIX=mes_wip
|
||||
|
||||
# Cache check interval in seconds (default: 600 = 10 minutes)
|
||||
CACHE_CHECK_INTERVAL=600
|
||||
|
||||
# ============================================================
|
||||
# Resource Cache Configuration
|
||||
# ============================================================
|
||||
# Enable/disable Resource cache (DW_MES_RESOURCE)
|
||||
# When disabled, queries will fallback to Oracle directly
|
||||
RESOURCE_CACHE_ENABLED=true
|
||||
|
||||
# Resource cache sync interval in seconds (default: 14400 = 4 hours)
|
||||
# The cache will check for updates at this interval using MAX(LASTCHANGEDATE)
|
||||
RESOURCE_SYNC_INTERVAL=14400
|
||||
|
||||
# ============================================================
|
||||
# Circuit Breaker Configuration
|
||||
# ============================================================
|
||||
# Enable/disable circuit breaker for database protection
|
||||
CIRCUIT_BREAKER_ENABLED=true
|
||||
|
||||
# Minimum failures before circuit can open
|
||||
CIRCUIT_BREAKER_FAILURE_THRESHOLD=5
|
||||
|
||||
# Failure rate threshold (0.0 - 1.0)
|
||||
CIRCUIT_BREAKER_FAILURE_RATE=0.5
|
||||
|
||||
# Seconds to wait in OPEN state before trying HALF_OPEN
|
||||
CIRCUIT_BREAKER_RECOVERY_TIMEOUT=30
|
||||
|
||||
# Sliding window size for counting successes/failures
|
||||
CIRCUIT_BREAKER_WINDOW_SIZE=10
|
||||
|
||||
# ============================================================
|
||||
# Performance Metrics Configuration
|
||||
# ============================================================
|
||||
# Slow query threshold in seconds (default: 5.0)
|
||||
# Note: Real-time Oracle views may take 2-5s per query, set threshold accordingly
|
||||
SLOW_QUERY_THRESHOLD=5.0
|
||||
|
||||
# ============================================================
|
||||
# SQLite Log Store Configuration
|
||||
# ============================================================
|
||||
# Enable/disable SQLite log store for admin dashboard
|
||||
LOG_STORE_ENABLED=true
|
||||
|
||||
# SQLite database path
|
||||
LOG_SQLITE_PATH=logs/admin_logs.sqlite
|
||||
|
||||
# Log retention period in days (default: 7)
|
||||
LOG_SQLITE_RETENTION_DAYS=7
|
||||
|
||||
# Maximum log rows (default: 100000)
|
||||
LOG_SQLITE_MAX_ROWS=100000
|
||||
|
||||
# ============================================================
|
||||
# Worker Watchdog Configuration
|
||||
# ============================================================
|
||||
# Runtime directory for restart flag/pid/state files
|
||||
WATCHDOG_RUNTIME_DIR=./tmp
|
||||
|
||||
# Path to restart flag file (watchdog monitors this file)
|
||||
WATCHDOG_RESTART_FLAG=./tmp/mes_dashboard_restart.flag
|
||||
|
||||
# Gunicorn PID file path (must match start script / systemd config)
|
||||
WATCHDOG_PID_FILE=./tmp/gunicorn.pid
|
||||
|
||||
# Path to restart state file (stores last restart info)
|
||||
WATCHDOG_STATE_FILE=./tmp/mes_dashboard_restart_state.json
|
||||
|
||||
# Max entries persisted in restart history (bounded to avoid state growth)
|
||||
WATCHDOG_RESTART_HISTORY_MAX=50
|
||||
|
||||
# Cooldown period between restart requests in seconds (default: 60)
|
||||
WORKER_RESTART_COOLDOWN=60
|
||||
|
||||
# ============================================================
|
||||
# Runtime Resilience Diagnostics Thresholds
|
||||
# ============================================================
|
||||
# Alert window for sustained degraded state (seconds)
|
||||
RESILIENCE_DEGRADED_ALERT_SECONDS=300
|
||||
|
||||
# Pool saturation warning / critical levels
|
||||
RESILIENCE_POOL_SATURATION_WARNING=0.90
|
||||
RESILIENCE_POOL_SATURATION_CRITICAL=1.0
|
||||
|
||||
# Restart churn threshold: N restarts within window triggers churn warning
|
||||
RESILIENCE_RESTART_CHURN_WINDOW_SECONDS=600
|
||||
RESILIENCE_RESTART_CHURN_THRESHOLD=3
|
||||
|
||||
# ============================================================
|
||||
# CORS Configuration
|
||||
# ============================================================
|
||||
# Comma-separated list of allowed origins for CORS
|
||||
# Example: https://example.com,https://app.example.com
|
||||
# Set to * for development (not recommended for production)
|
||||
CORS_ALLOWED_ORIGINS=
|
||||
56
.gitignore
vendored
Normal file
56
.gitignore
vendored
Normal file
@@ -0,0 +1,56 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
|
||||
# Virtual environments
|
||||
venv/
|
||||
ENV/
|
||||
env/
|
||||
.venv/
|
||||
|
||||
# Package build artifacts
|
||||
*.egg-info/
|
||||
*.egg
|
||||
dist/
|
||||
build/
|
||||
*.whl
|
||||
frontend/node_modules/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*.sublime-*
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
nul
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Local config (credentials)
|
||||
.env
|
||||
|
||||
# AI/LLM tools
|
||||
.claude/
|
||||
.codex/
|
||||
CLAUDE.md
|
||||
|
||||
# Test artifacts
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.tox/
|
||||
|
||||
# Jupyter
|
||||
.ipynb_checkpoints/
|
||||
|
||||
# Note: openspec/ is tracked (not ignored)
|
||||
tmp/
|
||||
658
README.md
Normal file
658
README.md
Normal file
@@ -0,0 +1,658 @@
|
||||
# MES Dashboard 報表系統
|
||||
|
||||
基於 Flask + Gunicorn + Redis + Vite 的 MES 數據報表查詢與可視化系統
|
||||
|
||||
> 專案主執行根目錄:`DashBoard_vite/`
|
||||
> 目前已移除舊版 `DashBoard/` 代碼,僅保留新架構。
|
||||
|
||||
---
|
||||
|
||||
## 專案狀態
|
||||
|
||||
| 功能 | 狀態 |
|
||||
|------|------|
|
||||
| WIP 即時概況 | ✅ 已完成 |
|
||||
| WIP 明細查詢 | ✅ 已完成 |
|
||||
| Hold 狀態分析 | ✅ 已完成 |
|
||||
| 數據表查詢工具 | ✅ 已完成 |
|
||||
| 設備狀態監控 | ✅ 已完成 |
|
||||
| 設備歷史查詢 | ✅ 已完成 |
|
||||
| 管理員認證系統 | ✅ 已完成 |
|
||||
| 頁面狀態管理 | ✅ 已完成 |
|
||||
| Redis 快取系統 | ✅ 已完成 |
|
||||
| SQL 查詢安全架構 | ✅ 已完成 |
|
||||
| 效能監控儀表板 | ✅ 已完成 |
|
||||
| 熔斷器保護機制 | ✅ 已完成 |
|
||||
| Worker 重啟控制 | ✅ 已完成 |
|
||||
| Runtime 韌性診斷(threshold/churn/recommendation) | ✅ 已完成 |
|
||||
| WIP 共用 autocomplete core 模組 | ✅ 已完成 |
|
||||
| 前端核心模組測試(Node test) | ✅ 已完成 |
|
||||
| 部署自動化 | ✅ 已完成 |
|
||||
|
||||
---
|
||||
|
||||
## 遷移與驗收文件
|
||||
|
||||
- Root cutover 盤點:`docs/root_cutover_inventory.md`
|
||||
- 頁面架構與抽屜分類:`docs/page_architecture_map.md`
|
||||
- 前端計算前移與 parity 規則:`docs/frontend_compute_shift_plan.md`
|
||||
- Cutover gates / rollout / rollback:`docs/migration_gates_and_runbook.md`
|
||||
- 環境依賴缺口與對策:`docs/environment_gaps_and_mitigation.md`
|
||||
|
||||
---
|
||||
|
||||
## 最新架構重點
|
||||
|
||||
1. 單一 port 契約維持不變
|
||||
- Flask + Gunicorn + Vite dist 由同一服務提供(`GUNICORN_BIND`),前後端同源。
|
||||
|
||||
2. Runtime 韌性採「降級 + 可操作建議」
|
||||
- `/health`、`/health/deep`、`/admin/api/system-status`、`/admin/api/worker/status` 皆提供:
|
||||
- 門檻(thresholds)
|
||||
- 重啟 churn 摘要
|
||||
- recovery recommendation(值班建議動作)
|
||||
|
||||
3. Watchdog 維持手動觸發重啟模型
|
||||
- 仍以 admin API 觸發 reload,不預設啟用自動重啟風暴風險。
|
||||
- state 檔新增 bounded restart history,方便追蹤 churn。
|
||||
|
||||
4. 前端治理:WIP autocomplete/filter 共用化
|
||||
- `frontend/src/core/autocomplete.js` 作為 WIP overview/detail 共用邏輯來源。
|
||||
- 維持既有頁面流程與 drill-down 語意,不變更操作習慣。
|
||||
|
||||
---
|
||||
|
||||
## 快速開始
|
||||
|
||||
### 首次部署
|
||||
|
||||
```bash
|
||||
# 1. 執行部署腳本
|
||||
./scripts/deploy.sh
|
||||
|
||||
# 2. 編輯環境設定
|
||||
nano .env
|
||||
|
||||
# 3. 啟動服務
|
||||
./scripts/start_server.sh start
|
||||
```
|
||||
|
||||
### 日常操作
|
||||
|
||||
```bash
|
||||
# 啟動服務(背景執行)
|
||||
./scripts/start_server.sh start
|
||||
|
||||
# 停止服務
|
||||
./scripts/start_server.sh stop
|
||||
|
||||
# 重啟服務
|
||||
./scripts/start_server.sh restart
|
||||
|
||||
# 查看狀態
|
||||
./scripts/start_server.sh status
|
||||
|
||||
# 查看日誌
|
||||
./scripts/start_server.sh logs follow
|
||||
```
|
||||
|
||||
訪問網址: **http://localhost:8080** (可在 .env 中配置)
|
||||
|
||||
---
|
||||
|
||||
## 部署指南
|
||||
|
||||
### 環境需求
|
||||
|
||||
- Python 3.11+
|
||||
- Conda (Miniconda/Anaconda)
|
||||
- Node.js 22+(建議由 Conda `environment.yml` 管理)
|
||||
- Oracle Database 連線
|
||||
- Redis Server 7.x+ (設備狀態快取)
|
||||
|
||||
### 部署步驟
|
||||
|
||||
#### 1. 自動部署(推薦)
|
||||
|
||||
```bash
|
||||
./scripts/deploy.sh
|
||||
```
|
||||
|
||||
此腳本會自動:
|
||||
- 檢查 Conda 環境
|
||||
- 建立 `mes-dashboard` 虛擬環境
|
||||
- 安裝依賴套件
|
||||
- 複製 `.env.example` 到 `.env`
|
||||
- 驗證資料庫連線
|
||||
|
||||
#### 2. 手動部署
|
||||
|
||||
```bash
|
||||
# 建立 Conda 環境
|
||||
conda create -n mes-dashboard python=3.11 -y
|
||||
conda activate mes-dashboard
|
||||
|
||||
# 安裝依賴
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 安裝前端依賴並建置(Vite)
|
||||
npm --prefix frontend install
|
||||
npm --prefix frontend test
|
||||
npm --prefix frontend run build
|
||||
|
||||
# 設定環境變數
|
||||
cp .env.example .env
|
||||
nano .env # 編輯資料庫連線等設定
|
||||
|
||||
# 啟動服務
|
||||
./scripts/start_server.sh start
|
||||
```
|
||||
|
||||
### 環境變數設定
|
||||
|
||||
編輯 `.env` 檔案:
|
||||
|
||||
```bash
|
||||
# 資料庫設定(必填)
|
||||
DB_HOST=your_database_host
|
||||
DB_PORT=1521
|
||||
DB_SERVICE=your_service_name
|
||||
DB_USER=your_username
|
||||
DB_PASSWORD=your_password
|
||||
|
||||
# Flask 設定
|
||||
FLASK_ENV=production # production | development
|
||||
SECRET_KEY=your-secret-key # 生產環境請更換
|
||||
|
||||
# Gunicorn 設定
|
||||
GUNICORN_BIND=0.0.0.0:8080 # 服務監聽位址
|
||||
GUNICORN_WORKERS=2 # Worker 數量
|
||||
GUNICORN_THREADS=4 # 每個 Worker 的執行緒數
|
||||
|
||||
# DB 韌性設定
|
||||
DB_POOL_SIZE=10
|
||||
DB_MAX_OVERFLOW=20
|
||||
DB_POOL_TIMEOUT=30
|
||||
DB_POOL_RECYCLE=1800
|
||||
DB_CALL_TIMEOUT_MS=55000
|
||||
|
||||
# Circuit Breaker
|
||||
CIRCUIT_BREAKER_ENABLED=true
|
||||
CIRCUIT_BREAKER_FAILURE_THRESHOLD=5
|
||||
CIRCUIT_BREAKER_FAILURE_RATE=0.5
|
||||
CIRCUIT_BREAKER_RECOVERY_TIMEOUT=30
|
||||
|
||||
# Redis 設定
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
REDIS_ENABLED=true
|
||||
|
||||
# Watchdog runtime contract
|
||||
WATCHDOG_RUNTIME_DIR=./tmp
|
||||
WATCHDOG_RESTART_FLAG=./tmp/mes_dashboard_restart.flag
|
||||
WATCHDOG_PID_FILE=./tmp/gunicorn.pid
|
||||
WATCHDOG_STATE_FILE=./tmp/mes_dashboard_restart_state.json
|
||||
WATCHDOG_RESTART_HISTORY_MAX=50
|
||||
|
||||
# Runtime resilience thresholds
|
||||
RESILIENCE_DEGRADED_ALERT_SECONDS=300
|
||||
RESILIENCE_POOL_SATURATION_WARNING=0.90
|
||||
RESILIENCE_POOL_SATURATION_CRITICAL=1.0
|
||||
RESILIENCE_RESTART_CHURN_WINDOW_SECONDS=600
|
||||
RESILIENCE_RESTART_CHURN_THRESHOLD=3
|
||||
|
||||
# 管理員設定
|
||||
ADMIN_EMAILS=admin@example.com # 管理員郵件(逗號分隔)
|
||||
```
|
||||
|
||||
### 生產環境注意事項
|
||||
|
||||
1. **SECRET_KEY**: 必須設定為隨機字串
|
||||
```bash
|
||||
python -c "import secrets; print(secrets.token_hex(32))"
|
||||
```
|
||||
|
||||
2. **FLASK_ENV**: 設定為 `production`
|
||||
|
||||
3. **防火牆**: 開放服務端口(預設 8080)
|
||||
|
||||
### Conda + systemd 服務配置
|
||||
|
||||
建議在生產環境使用同一份 conda runtime contract 啟動 App 與 Watchdog:
|
||||
|
||||
```bash
|
||||
# 1. 複製 systemd 服務檔案
|
||||
sudo cp deploy/mes-dashboard.service /etc/systemd/system/
|
||||
sudo cp deploy/mes-dashboard-watchdog.service /etc/systemd/system/
|
||||
|
||||
# 2. 準備環境設定檔
|
||||
sudo mkdir -p /etc/mes-dashboard
|
||||
sudo cp .env /etc/mes-dashboard/mes-dashboard.env
|
||||
|
||||
# 3. 重新載入 systemd
|
||||
sudo systemctl daemon-reload
|
||||
|
||||
# 4. 啟動並設定開機自動啟動
|
||||
sudo systemctl enable --now mes-dashboard mes-dashboard-watchdog
|
||||
|
||||
# 5. 查看狀態
|
||||
sudo systemctl status mes-dashboard
|
||||
sudo systemctl status mes-dashboard-watchdog
|
||||
```
|
||||
|
||||
### Rollback 步驟
|
||||
|
||||
如需回滾到先前版本:
|
||||
|
||||
```bash
|
||||
# 1. 停止服務
|
||||
./scripts/start_server.sh stop
|
||||
sudo systemctl stop mes-dashboard mes-dashboard-watchdog
|
||||
|
||||
# 2. 回滾程式碼
|
||||
git checkout <previous-commit>
|
||||
|
||||
# 3. 重新安裝依賴(如有變更)
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 4. 清理新版本資料(可選)
|
||||
rm -f logs/admin_logs.sqlite # 清理 SQLite 日誌
|
||||
|
||||
# 5. 重啟服務
|
||||
./scripts/start_server.sh start
|
||||
sudo systemctl start mes-dashboard mes-dashboard-watchdog
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 使用者操作指南
|
||||
|
||||
本節提供一般使用者的操作說明。
|
||||
|
||||
### 存取系統
|
||||
|
||||
1. 開啟瀏覽器,輸入系統網址(預設為 `http://localhost:8080`)
|
||||
2. 進入 Portal 首頁,可透過上方 Tab 切換各功能模組
|
||||
|
||||
### 基本操作
|
||||
|
||||
#### WIP 即時概況
|
||||
- 顯示生產線 WIP(在製品)的即時統計
|
||||
- 可透過下拉選單篩選特定工作中心或產品線
|
||||
- 點擊統計卡片可展開查看詳細明細
|
||||
- 支援匯出 Excel 報表
|
||||
|
||||
#### WIP 明細查詢
|
||||
1. 選擇篩選條件(工作中心、Package、Hold 狀態、製程站點)
|
||||
2. 點擊「查詢」按鈕執行查詢
|
||||
3. 查詢結果顯示於下方表格
|
||||
4. 點擊「匯出 Excel」下載報表
|
||||
|
||||
#### 設備狀態監控
|
||||
- 顯示所有設備的即時狀態(PRD/SBY/UDT/SDT/EGT/NST)
|
||||
- 使用階層篩選功能:
|
||||
- **生產設備**:僅顯示列入生產統計的設備
|
||||
- **重點設備**:僅顯示標記為重點監控的設備
|
||||
- **監控設備**:僅顯示需特別監控的設備
|
||||
- 設備狀態每 30 秒自動更新
|
||||
|
||||
#### 設備歷史查詢
|
||||
1. 選擇查詢日期範圍
|
||||
2. 可選擇特定設備或工作中心
|
||||
3. 查看歷史趨勢圖表和稼動率熱力圖
|
||||
4. 支援 CSV 匯出
|
||||
|
||||
### 管理員登入
|
||||
|
||||
1. 點擊右上角「登入」按鈕
|
||||
2. 輸入工號和密碼(使用 LDAP 認證)
|
||||
3. 登入後可存取開發中功能頁面
|
||||
4. 管理員可使用效能監控儀表板(`/admin/performance`)
|
||||
|
||||
### 常見問題
|
||||
|
||||
**Q: 頁面顯示「資料載入中」很久沒反應?**
|
||||
A: 請檢查網路連線,或重新整理頁面。如持續發生請通知系統管理員。
|
||||
|
||||
**Q: 查詢結果與預期不符?**
|
||||
A: 請確認篩選條件是否正確設定。資料來源為 MES 系統,約有 30 秒延遲。
|
||||
|
||||
**Q: 無法匯出 Excel?**
|
||||
A: 請確認瀏覽器允許下載檔案,並檢查查詢結果是否有資料。
|
||||
|
||||
---
|
||||
|
||||
## 功能說明
|
||||
|
||||
### Portal 入口頁面
|
||||
|
||||
透過 Tab 切換各功能模組:
|
||||
- WIP 即時概況
|
||||
- WIP 明細查詢
|
||||
- Hold 狀態分析
|
||||
- 設備狀態監控
|
||||
- 設備歷史查詢
|
||||
- 數據表查詢工具
|
||||
- 抽屜分組導覽(報表類/查詢類/開發工具類)
|
||||
|
||||
### WIP 即時概況
|
||||
|
||||
- 總覽統計(總 LOT 數、總數量、總片數)
|
||||
- 按 SPEC 和 WORKCENTER 統計
|
||||
- 按產品線統計(匯總 + 明細)
|
||||
- Hold 狀態分類(品質異常/非品質異常)
|
||||
- 柏拉圖視覺化圖表
|
||||
|
||||
### WIP 明細查詢
|
||||
|
||||
- 依工作中心篩選
|
||||
- 依 Package 篩選
|
||||
- 依 Hold 狀態篩選
|
||||
- 依製程站點篩選
|
||||
- 支援 Excel 匯出
|
||||
|
||||
### Hold 狀態分析
|
||||
|
||||
- Hold 批次總覽
|
||||
- 按 Hold 原因分類
|
||||
- Hold 明細查詢
|
||||
- 品質異常分類統計
|
||||
|
||||
### 設備狀態監控
|
||||
|
||||
- 即時設備狀態總覽(PRD/SBY/UDT/SDT/EGT/NST)
|
||||
- 按工作中心群組統計
|
||||
- 設備稼動率(OU%)與運轉率(RUN%)
|
||||
- 階層篩選(廠區/產線/重點設備/監控設備)
|
||||
- Redis 快取自動更新(30 秒間隔)
|
||||
|
||||
### 設備歷史查詢
|
||||
|
||||
- 歷史狀態趨勢分析
|
||||
- 稼動率熱力圖視覺化
|
||||
- 設備狀態明細查詢
|
||||
- 支援 CSV 匯出
|
||||
|
||||
### 管理員功能
|
||||
|
||||
- LDAP 認證登入(支援本地測試模式)
|
||||
- 頁面狀態管理(released/dev)
|
||||
- Dev 頁面僅管理員可見
|
||||
|
||||
### 效能監控儀表板
|
||||
|
||||
管理員專用的系統監控介面(`/admin/performance`):
|
||||
|
||||
- **系統狀態總覽**:Database、Redis、Circuit Breaker、Worker 狀態
|
||||
- **查詢效能指標**:P50/P95/P99 延遲、慢查詢統計、延遲分布圖
|
||||
- **系統日誌檢視**:即時日誌查詢、等級篩選、關鍵字搜尋
|
||||
- **日誌管理**:儲存統計、手動清理功能
|
||||
- **Worker 控制**:優雅重啟(透過 Watchdog 機制)
|
||||
- 自動更新(30 秒間隔)
|
||||
|
||||
### 熔斷器保護機制
|
||||
|
||||
Circuit Breaker 模式保護資料庫免於雪崩效應:
|
||||
|
||||
- **CLOSED**:正常運作,請求通過
|
||||
- **OPEN**:失敗過多,請求立即拒絕
|
||||
- **HALF_OPEN**:測試恢復,允許有限請求
|
||||
|
||||
配置方式:
|
||||
```bash
|
||||
CIRCUIT_BREAKER_ENABLED=true
|
||||
CIRCUIT_BREAKER_FAILURE_THRESHOLD=5
|
||||
CIRCUIT_BREAKER_FAILURE_RATE=0.5
|
||||
CIRCUIT_BREAKER_RECOVERY_TIMEOUT=30
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 技術架構
|
||||
|
||||
### 後端技術棧
|
||||
|
||||
| 技術 | 版本 | 用途 |
|
||||
|------|------|------|
|
||||
| Python | 3.11+ | 程式語言 |
|
||||
| Flask | 3.x | Web 框架 |
|
||||
| Gunicorn | 23.x | WSGI 伺服器 |
|
||||
| SQLAlchemy | 2.x | ORM |
|
||||
| oracledb | 2.x | Oracle 驅動 |
|
||||
| Redis | 7.x | 快取伺服器 |
|
||||
| Pandas | 2.x | 資料處理 |
|
||||
|
||||
### 前端技術棧
|
||||
|
||||
| 技術 | 用途 |
|
||||
|------|------|
|
||||
| Jinja2 | 模板引擎 |
|
||||
| Vite 6 | 前端多頁模組打包 |
|
||||
| ECharts | 圖表庫 |
|
||||
| Vanilla JS Modules | 互動功能與頁面邏輯 |
|
||||
|
||||
### 資料庫
|
||||
|
||||
- Oracle Database 19c Enterprise Edition
|
||||
- 主機: 詳見 .env 檔案 (DB_HOST:DB_PORT)
|
||||
- 服務名: 詳見 .env 檔案 (DB_SERVICE)
|
||||
|
||||
---
|
||||
|
||||
## 專案結構
|
||||
|
||||
```
|
||||
DashBoard_vite/
|
||||
├── src/mes_dashboard/ # 主程式
|
||||
│ ├── app.py # Flask 應用
|
||||
│ ├── config/ # 設定
|
||||
│ │ ├── settings.py # 環境設定
|
||||
│ │ ├── constants.py # 常數定義
|
||||
│ │ ├── field_contracts.py # UI/API/Export 欄位契約
|
||||
│ │ └── workcenter_groups.py # 工作中心群組設定
|
||||
│ ├── core/ # 核心模組
|
||||
│ │ ├── database.py # 資料庫連線
|
||||
│ │ ├── redis_client.py # Redis 客戶端
|
||||
│ │ ├── cache.py # 快取管理
|
||||
│ │ ├── cache_updater.py # 快取自動更新
|
||||
│ │ ├── circuit_breaker.py # 熔斷器
|
||||
│ │ ├── metrics.py # 效能指標收集
|
||||
│ │ ├── log_store.py # SQLite 日誌儲存
|
||||
│ │ ├── response.py # API 回應格式
|
||||
│ │ └── permissions.py # 權限管理
|
||||
│ ├── routes/ # 路由
|
||||
│ │ ├── wip_routes.py # WIP 相關 API
|
||||
│ │ ├── resource_routes.py # 設備狀態 API
|
||||
│ │ ├── dashboard_routes.py # 儀表板 API
|
||||
│ │ └── ... # 其他路由
|
||||
│ ├── services/ # 服務層
|
||||
│ │ ├── wip_service.py # WIP 業務邏輯
|
||||
│ │ ├── resource_service.py # 設備狀態邏輯
|
||||
│ │ ├── resource_cache.py # 設備快取服務
|
||||
│ │ └── ... # 其他服務
|
||||
│ ├── sql/ # SQL 查詢管理
|
||||
│ │ ├── loader.py # SQLLoader (LRU 快取)
|
||||
│ │ ├── builder.py # QueryBuilder (參數化)
|
||||
│ │ ├── filters.py # CommonFilters
|
||||
│ │ ├── dashboard/ # 儀表板查詢
|
||||
│ │ ├── resource/ # 設備查詢
|
||||
│ │ ├── wip/ # WIP 查詢
|
||||
│ │ └── resource_history/ # 設備歷史查詢
|
||||
│ └── templates/ # HTML 模板
|
||||
├── frontend/ # Vite 前端專案
|
||||
│ ├── src/core/ # 共用 API/欄位契約/計算 helper
|
||||
│ ├── src/portal/ # Portal entry
|
||||
│ ├── src/resource-status/ # 設備即時概況 entry
|
||||
│ ├── src/resource-history/ # 設備歷史績效 entry
|
||||
│ ├── src/job-query/ # 設備維修查詢 entry
|
||||
│ ├── src/excel-query/ # Excel 批次查詢 entry
|
||||
│ └── src/tables/ # 數據表查詢 entry
|
||||
├── shared/
|
||||
│ └── field_contracts.json # 前後端共用欄位契約
|
||||
├── scripts/ # 腳本
|
||||
│ ├── deploy.sh # 部署腳本
|
||||
│ ├── start_server.sh # 服務管理腳本
|
||||
│ └── worker_watchdog.py # Worker 監控程式
|
||||
├── deploy/ # 部署設定
|
||||
│ ├── mes-dashboard.service # Gunicorn systemd 服務 (Conda)
|
||||
│ └── mes-dashboard-watchdog.service # Watchdog systemd 服務 (Conda)
|
||||
├── tests/ # 測試
|
||||
├── data/ # 資料檔案
|
||||
├── logs/ # 日誌
|
||||
├── docs/ # 文檔
|
||||
├── openspec/ # 變更管理
|
||||
├── .env.example # 環境變數範例
|
||||
├── requirements.txt # Python 依賴
|
||||
└── gunicorn.conf.py # Gunicorn 設定
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 測試
|
||||
|
||||
```bash
|
||||
# 執行所有測試
|
||||
pytest tests/ -v
|
||||
|
||||
# 執行單元測試
|
||||
pytest tests/test_*.py -v --ignore=tests/e2e --ignore=tests/stress
|
||||
|
||||
# 執行整合測試
|
||||
pytest tests/test_*_integration.py -v
|
||||
|
||||
# 執行 E2E 測試
|
||||
pytest tests/e2e/ -v
|
||||
|
||||
# 執行壓力測試
|
||||
pytest tests/stress/ -v
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 服務無法啟動
|
||||
|
||||
1. 檢查 Conda 環境:
|
||||
```bash
|
||||
conda activate mes-dashboard
|
||||
```
|
||||
|
||||
2. 檢查依賴:
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
3. 檢查日誌:
|
||||
```bash
|
||||
./scripts/start_server.sh logs error
|
||||
```
|
||||
|
||||
### 資料庫連線失敗
|
||||
|
||||
1. 確認 `.env` 中的資料庫設定正確
|
||||
2. 確認網路可連線到資料庫伺服器
|
||||
3. 確認資料庫帳號密碼正確
|
||||
|
||||
### Port 被占用
|
||||
|
||||
1. 檢查 port 使用狀況:
|
||||
```bash
|
||||
lsof -i :8080
|
||||
```
|
||||
|
||||
2. 修改 `.env` 中的 `GUNICORN_BIND` 設定
|
||||
|
||||
---
|
||||
|
||||
## 變更日誌
|
||||
|
||||
### 2026-02-08
|
||||
|
||||
- 完成並封存提案 `post-migration-resilience-governance`
|
||||
- 新增 runtime 韌性診斷核心(thresholds / restart churn / recovery recommendation)
|
||||
- health 與 admin API 新增可操作韌性欄位:
|
||||
- `/health`、`/health/deep`
|
||||
- `/admin/api/system-status`、`/admin/api/worker/status`
|
||||
- watchdog restart state 支援 bounded history(`WATCHDOG_RESTART_HISTORY_MAX`)
|
||||
- WIP overview/detail 抽離共用 autocomplete/filter 模組(`frontend/src/core/autocomplete.js`)
|
||||
- 新增前端 Node 測試流程(`npm --prefix frontend test`)
|
||||
- 更新 `README.mdj` 與 migration runbook 文件對齊 gate
|
||||
|
||||
### 2026-02-07
|
||||
|
||||
- 完成並封存提案 `dashboard-vite-root-refactor`
|
||||
- 完成並封存提案 `dashboard-vite-complete-migration`
|
||||
- 完成並封存提案 `vite-jinja-report-parity-hardening`
|
||||
- 完成並封存提案 `hold-detail-vite-hardening`
|
||||
- 完成單一 port Vite 架構切換,根目錄成為唯一執行與部署主體
|
||||
- 完成 portal 抽屜分類導航、獨立頁與 drill-down 路徑對齊
|
||||
- 完成欄位契約治理與下載欄位一致性驗證
|
||||
- 完成 runtime resilience(pool/circuit/degraded contract)與 migration gates/runbook 建立
|
||||
|
||||
### 2026-02-04
|
||||
|
||||
- 新增效能監控儀表板(`/admin/performance`)
|
||||
- 新增熔斷器保護機制(Circuit Breaker)
|
||||
- 新增效能指標收集(P50/P95/P99 延遲、慢查詢統計)
|
||||
- 新增 SQLite 日誌儲存與管理功能
|
||||
- 新增 Worker Watchdog 重啟機制
|
||||
- 新增統一 API 回應格式(success_response/error_response)
|
||||
- 新增 404/500 錯誤頁面模板
|
||||
- 修復熔斷器 get_status() 死鎖問題
|
||||
- 修復 health_routes.py 模組匯入錯誤
|
||||
- 新增 psutil 依賴用於 Worker 狀態監控
|
||||
- 新增完整測試套件(59 個效能相關測試)
|
||||
|
||||
### 2026-02-03
|
||||
|
||||
- 重構 SQL 查詢管理架構,提升安全性與效能
|
||||
- 新增 SQLLoader (LRU 快取)、QueryBuilder (參數化)、CommonFilters 模組
|
||||
- 抽取 20 個 SQL 檔案至 `src/mes_dashboard/sql/` 目錄
|
||||
- 修復所有 SQL 注入風險(LIKE 萬用字元跳脫、IN 條件參數化)
|
||||
- 優化 workcenter_cards API 回應時間(55s → 0.1s)
|
||||
|
||||
### 2026-02-02
|
||||
|
||||
- 新增 Hold Summary 柏拉圖視覺化圖表
|
||||
- 設備頁面統一排序、階層篩選與標籤優化
|
||||
|
||||
### 2026-01-30
|
||||
|
||||
- 新增本地認證模式支援開發測試環境
|
||||
|
||||
### 2026-01-29
|
||||
|
||||
- 新增設備狀態監控頁面
|
||||
- 新增設備歷史查詢頁面
|
||||
- 整合 Redis 快取系統(30 秒自動更新)
|
||||
|
||||
### 2026-01-28
|
||||
|
||||
- 新增管理員認證系統(LDAP 整合)
|
||||
- 新增頁面狀態管理(released/dev)
|
||||
- 新增部署腳本 `deploy.sh`
|
||||
- 更新啟動腳本自動載入 `.env`
|
||||
- 新增完整測試套件(57 個測試)
|
||||
|
||||
### 2026-01-27
|
||||
|
||||
- 新增 Hold Detail 頁面
|
||||
- WIP 查詢排除原物料
|
||||
- Hold 狀態分類(品質異常/非品質異常)
|
||||
|
||||
### 2026-01-26
|
||||
|
||||
- 重構為 Flask App Factory 模式
|
||||
- 新增全域連線管理
|
||||
- 新增 WIP 篩選增強功能
|
||||
|
||||
---
|
||||
|
||||
## 聯絡方式
|
||||
|
||||
如有技術問題或需求變更,請聯繫系統管理員。
|
||||
|
||||
---
|
||||
|
||||
**文檔版本**: 4.1
|
||||
**最後更新**: 2026-02-08
|
||||
61
README.mdj
Normal file
61
README.mdj
Normal file
@@ -0,0 +1,61 @@
|
||||
# MES Dashboard Architecture Snapshot (README.mdj)
|
||||
|
||||
本檔案為 `README.md` 的架構摘要鏡像,重點反映目前已完成的 Vite + 單一 port 運行契約與韌性治理策略。
|
||||
|
||||
## Runtime Contract
|
||||
|
||||
- 單一服務單一 port:`GUNICORN_BIND`(預設 `0.0.0.0:8080`)
|
||||
- 前端資產由 Vite build 到 `src/mes_dashboard/static/dist/`,由 Flask/Gunicorn 同源提供
|
||||
- Watchdog 透過 restart flag + `SIGHUP` 進行 graceful worker reload
|
||||
|
||||
## Resilience Contract
|
||||
|
||||
- 降級回應:`DB_POOL_EXHAUSTED`、`CIRCUIT_BREAKER_OPEN` + `Retry-After`
|
||||
- health/admin 診斷輸出包含:
|
||||
- thresholds
|
||||
- restart churn summary
|
||||
- recovery recommendation
|
||||
- 不預設啟用自動重啟;維持受控人工觸發,避免重啟風暴
|
||||
|
||||
## Frontend Governance
|
||||
|
||||
- WIP overview/detail 的 autocomplete/filter 查詢邏輯共用 `frontend/src/core/autocomplete.js`
|
||||
- 目標:維持既有操作語意,同時降低重複邏輯與維護成本
|
||||
- 前端核心模組測試:`npm --prefix frontend test`
|
||||
|
||||
## 開發歷史(摘要)
|
||||
|
||||
### 2026-02-08
|
||||
- 封存 `post-migration-resilience-governance`
|
||||
- 新增韌性診斷欄位(thresholds/churn/recommendation)
|
||||
- 完成 WIP autocomplete 共用模組化與前端測試腳本
|
||||
|
||||
### 2026-02-07
|
||||
- 封存完整 Vite 遷移相關提案群組
|
||||
- 單一 port 架構、抽屜導航、欄位契約治理與 migration gates 就位
|
||||
|
||||
## Key Configs
|
||||
|
||||
```bash
|
||||
WATCHDOG_RUNTIME_DIR=./tmp
|
||||
WATCHDOG_RESTART_FLAG=./tmp/mes_dashboard_restart.flag
|
||||
WATCHDOG_PID_FILE=./tmp/gunicorn.pid
|
||||
WATCHDOG_STATE_FILE=./tmp/mes_dashboard_restart_state.json
|
||||
WATCHDOG_RESTART_HISTORY_MAX=50
|
||||
|
||||
RESILIENCE_DEGRADED_ALERT_SECONDS=300
|
||||
RESILIENCE_POOL_SATURATION_WARNING=0.90
|
||||
RESILIENCE_POOL_SATURATION_CRITICAL=1.0
|
||||
RESILIENCE_RESTART_CHURN_WINDOW_SECONDS=600
|
||||
RESILIENCE_RESTART_CHURN_THRESHOLD=3
|
||||
```
|
||||
|
||||
## Validation Quick Commands
|
||||
|
||||
```bash
|
||||
npm --prefix frontend test
|
||||
npm --prefix frontend run build
|
||||
python -m pytest -q tests/test_resilience.py tests/test_health_routes.py tests/test_performance_integration.py
|
||||
```
|
||||
|
||||
> 詳細部署、使用說明與完整環境配置請參考 `README.md`。
|
||||
56
data/page_status.json
Normal file
56
data/page_status.json
Normal file
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"pages": [
|
||||
{
|
||||
"route": "/",
|
||||
"name": "首頁",
|
||||
"status": "released"
|
||||
},
|
||||
{
|
||||
"route": "/wip-overview",
|
||||
"name": "WIP 即時概況",
|
||||
"status": "released"
|
||||
},
|
||||
{
|
||||
"route": "/wip-detail",
|
||||
"name": "WIP 明細",
|
||||
"status": "released"
|
||||
},
|
||||
{
|
||||
"route": "/hold-detail",
|
||||
"name": "Hold 明細",
|
||||
"status": "released"
|
||||
},
|
||||
{
|
||||
"route": "/resource-history",
|
||||
"name": "設備歷史績效",
|
||||
"status": "released"
|
||||
},
|
||||
{
|
||||
"route": "/tables",
|
||||
"name": "表格總覽",
|
||||
"status": "dev"
|
||||
},
|
||||
{
|
||||
"route": "/resource",
|
||||
"name": "機台狀態",
|
||||
"status": "released"
|
||||
},
|
||||
{
|
||||
"route": "/excel-query",
|
||||
"name": "Excel 批次查詢",
|
||||
"status": "dev"
|
||||
},
|
||||
{
|
||||
"route": "/job-query",
|
||||
"name": "設備維修查詢",
|
||||
"status": "released"
|
||||
}
|
||||
],
|
||||
"api_public": true,
|
||||
"db_scan": {
|
||||
"schema": "DWH",
|
||||
"updated_at": "2026-01-29 13:49:59",
|
||||
"object_count": 19,
|
||||
"source": "tools/query_table_schema.py"
|
||||
}
|
||||
}
|
||||
12093
data/table_schema_info.json
Normal file
12093
data/table_schema_info.json
Normal file
File diff suppressed because it is too large
Load Diff
40
deploy/mes-dashboard-watchdog.service
Normal file
40
deploy/mes-dashboard-watchdog.service
Normal file
@@ -0,0 +1,40 @@
|
||||
[Unit]
|
||||
Description=MES Dashboard Worker Watchdog (Conda Runtime)
|
||||
Documentation=https://github.com/your-org/mes-dashboard
|
||||
After=network.target mes-dashboard.service
|
||||
Requires=mes-dashboard.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=www-data
|
||||
Group=www-data
|
||||
WorkingDirectory=/opt/mes-dashboard
|
||||
EnvironmentFile=-/etc/mes-dashboard/mes-dashboard.env
|
||||
Environment="PYTHONPATH=/opt/mes-dashboard/src"
|
||||
Environment="CONDA_BIN=/opt/miniconda3/bin/conda"
|
||||
Environment="CONDA_ENV_NAME=mes-dashboard"
|
||||
Environment="WATCHDOG_RUNTIME_DIR=/run/mes-dashboard"
|
||||
Environment="WATCHDOG_RESTART_FLAG=/run/mes-dashboard/mes_dashboard_restart.flag"
|
||||
Environment="WATCHDOG_PID_FILE=/run/mes-dashboard/gunicorn.pid"
|
||||
Environment="WATCHDOG_STATE_FILE=/var/lib/mes-dashboard/restart_state.json"
|
||||
Environment="WATCHDOG_CHECK_INTERVAL=5"
|
||||
|
||||
RuntimeDirectory=mes-dashboard
|
||||
StateDirectory=mes-dashboard
|
||||
|
||||
ExecStart=/usr/bin/env bash -lc 'exec "${CONDA_BIN}" run --no-capture-output -n "${CONDA_ENV_NAME}" python scripts/worker_watchdog.py'
|
||||
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=mes-watchdog
|
||||
|
||||
NoNewPrivileges=yes
|
||||
PrivateTmp=yes
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=/run/mes-dashboard /var/lib/mes-dashboard
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
43
deploy/mes-dashboard.service
Normal file
43
deploy/mes-dashboard.service
Normal file
@@ -0,0 +1,43 @@
|
||||
[Unit]
|
||||
Description=MES Dashboard Gunicorn Service (Conda Runtime)
|
||||
Documentation=https://github.com/your-org/mes-dashboard
|
||||
After=network.target redis-server.service
|
||||
Wants=redis-server.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=www-data
|
||||
Group=www-data
|
||||
WorkingDirectory=/opt/mes-dashboard
|
||||
EnvironmentFile=-/etc/mes-dashboard/mes-dashboard.env
|
||||
Environment="PYTHONPATH=/opt/mes-dashboard/src"
|
||||
Environment="CONDA_BIN=/opt/miniconda3/bin/conda"
|
||||
Environment="CONDA_ENV_NAME=mes-dashboard"
|
||||
Environment="GUNICORN_BIND=0.0.0.0:8080"
|
||||
Environment="WATCHDOG_RUNTIME_DIR=/run/mes-dashboard"
|
||||
Environment="WATCHDOG_RESTART_FLAG=/run/mes-dashboard/mes_dashboard_restart.flag"
|
||||
Environment="WATCHDOG_PID_FILE=/run/mes-dashboard/gunicorn.pid"
|
||||
Environment="WATCHDOG_STATE_FILE=/var/lib/mes-dashboard/restart_state.json"
|
||||
|
||||
RuntimeDirectory=mes-dashboard
|
||||
StateDirectory=mes-dashboard
|
||||
PIDFile=/run/mes-dashboard/gunicorn.pid
|
||||
|
||||
ExecStart=/usr/bin/env bash -lc 'exec "${CONDA_BIN}" run --no-capture-output -n "${CONDA_ENV_NAME}" gunicorn --config gunicorn.conf.py --pid "${WATCHDOG_PID_FILE}" --capture-output "mes_dashboard:create_app()"'
|
||||
|
||||
KillSignal=SIGTERM
|
||||
TimeoutStopSec=30
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=mes-dashboard
|
||||
|
||||
NoNewPrivileges=yes
|
||||
PrivateTmp=yes
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=/run/mes-dashboard /var/lib/mes-dashboard /opt/mes-dashboard/logs
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
60
docs/DW_PJ_LOT_V_POWERBI_SQL.txt
Normal file
60
docs/DW_PJ_LOT_V_POWERBI_SQL.txt
Normal file
@@ -0,0 +1,60 @@
|
||||
SELECT L.LOTID AS ""Run Card Lot ID"",
|
||||
L.Workorder AS ""Work Order ID"",
|
||||
L.Qty AS ""Lot Qty(pcs)"",
|
||||
L.Qty2 AS ""Lot Qty(Wafer pcs)"",
|
||||
L.Status AS ""Run Card Status"",
|
||||
L.HOLDREASONNAME AS ""Hold Reason"",
|
||||
L.CurrentHoldCount AS ""Hold Count"",
|
||||
L.Owner AS ""Work Order Owner"",
|
||||
L.StartDate AS ""Run Card Start Date"",
|
||||
L.UTS,
|
||||
L.Product AS ""Product P/N"",
|
||||
L.Productlinename AS ""Package"",
|
||||
L.Package_LEF as ""Package(LF)"",
|
||||
L.PJ_FUNCTION AS ""Product Function"",
|
||||
L.Pj_Type AS ""Product Type"",
|
||||
L.BOP,
|
||||
L.FirstName AS ""Wafer Lot ID"",
|
||||
L.WAFERNAME AS ""Wafer P/N"",
|
||||
L.WaferLot ""Wafer Lot ID(Prefix)"",
|
||||
L.SpecName AS ""Spec"",
|
||||
L.SPECSEQUENCE AS ""Spec Sequence"",
|
||||
L.SPECSEQUENCE || '_' || L.SpecName AS ""Spec(Order)"",
|
||||
L.Workcentername AS ""Work Center"",
|
||||
L.WorkCenterSequence AS ""Work Center Sequence"",
|
||||
L.WorkCenter_Group AS ""Work Center(Group)"",
|
||||
L.WorkCenter_Short AS ""Work Center(Short)"",
|
||||
L.WorkCenterSequence_Group AS ""Work Center Sequence(Group)"",
|
||||
L.WorkCenterSequence_Group || '_' || L.WorkCenter_Group AS ""Work Center Group(Order)"",
|
||||
L.AgeByDays AS ""Age By Days"",
|
||||
L.Equipments AS ""Equipment ID"",
|
||||
L.EquipmentCount AS ""Equipment Count"",
|
||||
L.Workflowname AS ""Work Flow Name"",
|
||||
L.Datecode AS ""Product Date Code"",
|
||||
L.LEADFRAMENAME AS ""LF Material Part"",
|
||||
L.LEADFRAMEOPTION AS ""LF Option ID"",
|
||||
L.COMNAME AS ""Compound Material Part"",
|
||||
L.LOCATIONNAME AS ""Run Card Location"",
|
||||
L.Eventname AS ""NCR ID"",
|
||||
L.Occurrencedate AS ""NCR-issued Time"",
|
||||
L.ReleaseTime AS ""Release Time"",
|
||||
L.ReleaseEmp AS ""Release Employee"",
|
||||
L.ReleaseReason AS ""Release Comment"",
|
||||
L.COMMENT_HOLD AS ""Hold Comment"",
|
||||
L.CONTAINERCOMMENTS AS ""Comment"",
|
||||
L.COMMENT_DATE AS ""Run Card Comment"",
|
||||
L.COMMENT_EMP AS ""Run Card Comment Employee"",
|
||||
L.COMMENT_FUTURE AS ""Future Hold Comment"",
|
||||
L.HOLDEMP AS ""Hold Employee"",
|
||||
L.DEPTNAME AS ""Hold Employee Dept"",
|
||||
L.PJ_PRODUCEREGION AS ""Produce Region"",
|
||||
L.Prioritycodename AS ""Work Order Priority"",
|
||||
L.TMTT_R AS ""TMTT Remaining"",
|
||||
L.wafer_factor AS ""Die Consumption Qty"",
|
||||
Case When (L.EquipmentCount>0) Then 'RUN'
|
||||
When (L.CurrentHoldCount>0) Then 'HOLD'
|
||||
ELSE 'QUENE' End AS ""WIP Status"",
|
||||
Case When (L.EquipmentCount>0) Then 1
|
||||
When (L.CurrentHoldCount>0) Then 3
|
||||
ELSE 2 End AS ""WIP Status Sequence"",
|
||||
sys_date AS ""Data Update Date""
|
||||
2334
docs/MES_Core_Tables_Analysis_Report.md
Normal file
2334
docs/MES_Core_Tables_Analysis_Report.md
Normal file
File diff suppressed because it is too large
Load Diff
1379
docs/MES_Database_Reference.md
Normal file
1379
docs/MES_Database_Reference.md
Normal file
File diff suppressed because it is too large
Load Diff
36
docs/Oracle_Authorized_Objects.md
Normal file
36
docs/Oracle_Authorized_Objects.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# Oracle 可使用 TABLE/VIEW 清單(DWH)
|
||||
|
||||
**產生時間**: 2026-01-29 13:34:22
|
||||
**使用者**: (詳見 .env 中的 DB_USER)
|
||||
**Schema**: DWH
|
||||
|
||||
## 摘要
|
||||
|
||||
- 可使用物件總數: 19
|
||||
- TABLE: 16
|
||||
- VIEW: 3
|
||||
- 來源 (去重後物件數): DIRECT 19, PUBLIC 0, ROLE 0, SYSTEM 0
|
||||
|
||||
## 物件清單
|
||||
|
||||
| 物件 | 類型 | 權限 | 授權來源 |
|
||||
|------|------|------|----------|
|
||||
| `DWH.DW_MES_CONTAINER` | TABLE | SELECT | DIRECT |
|
||||
| `DWH.DW_MES_EQUIPMENTSTATUS_WIP_V` | VIEW | SELECT | DIRECT |
|
||||
| `DWH.DW_MES_HM_LOTMOVEOUT` | TABLE | SELECT | DIRECT |
|
||||
| `DWH.DW_MES_HOLDRELEASEHISTORY` | TABLE | SELECT | DIRECT |
|
||||
| `DWH.DW_MES_JOB` | TABLE | SELECT | DIRECT |
|
||||
| `DWH.DW_MES_JOBTXNHISTORY` | TABLE | SELECT | DIRECT |
|
||||
| `DWH.DW_MES_LOTMATERIALSHISTORY` | TABLE | SELECT | DIRECT |
|
||||
| `DWH.DW_MES_LOTREJECTHISTORY` | TABLE | SELECT | DIRECT |
|
||||
| `DWH.DW_MES_LOTWIPDATAHISTORY` | TABLE | SELECT | DIRECT |
|
||||
| `DWH.DW_MES_LOTWIPHISTORY` | TABLE | SELECT | DIRECT |
|
||||
| `DWH.DW_MES_LOT_V` | VIEW | SELECT | DIRECT |
|
||||
| `DWH.DW_MES_MAINTENANCE` | TABLE | SELECT | DIRECT |
|
||||
| `DWH.DW_MES_PARTREQUESTORDER` | TABLE | SELECT | DIRECT |
|
||||
| `DWH.DW_MES_PJ_COMBINEDASSYLOTS` | TABLE | SELECT | DIRECT |
|
||||
| `DWH.DW_MES_RESOURCE` | TABLE | SELECT | DIRECT |
|
||||
| `DWH.DW_MES_RESOURCESTATUS` | TABLE | SELECT | DIRECT |
|
||||
| `DWH.DW_MES_RESOURCESTATUS_SHIFT` | TABLE | SELECT | DIRECT |
|
||||
| `DWH.DW_MES_SPEC_WORKCENTER_V` | VIEW | SELECT | DIRECT |
|
||||
| `DWH.DW_MES_WIP` | TABLE | SELECT | DIRECT |
|
||||
936
docs/architecture_findings.md
Normal file
936
docs/architecture_findings.md
Normal file
@@ -0,0 +1,936 @@
|
||||
# MES Dashboard - Architecture Findings
|
||||
|
||||
本文件記錄專案開發過程中確立的架構設計、全局規範與資料處理規則。
|
||||
|
||||
---
|
||||
|
||||
## 1. 資料庫連線管理
|
||||
|
||||
### 連線池統一使用
|
||||
所有資料庫操作必須透過 `mes_dashboard.core.database` 模組:
|
||||
|
||||
```python
|
||||
from mes_dashboard.core.database import read_sql_df, get_engine
|
||||
|
||||
# 讀取資料 (推薦方式)
|
||||
df = read_sql_df(sql, params)
|
||||
|
||||
# 取得 engine(若需要直接操作)
|
||||
engine = get_engine()
|
||||
```
|
||||
|
||||
### 連線池配置 (位置: `core/database.py`)
|
||||
|
||||
| 參數 | 開發環境 | 生產環境 | 說明 |
|
||||
|------|---------|---------|------|
|
||||
| pool_size | 2 | 10 | 基礎連線數 |
|
||||
| max_overflow | 3 | 20 | 額外連線數 |
|
||||
| pool_timeout | 30 | 30 | 等待超時 (秒) |
|
||||
| pool_recycle | 1800 | 1800 | 回收週期 (30分鐘) |
|
||||
| pool_pre_ping | True | True | 使用前驗證連線 |
|
||||
|
||||
### Keep-Alive 機制
|
||||
- 背景執行緒每 5 分鐘執行 `SELECT 1 FROM DUAL`
|
||||
- 防止 NAT/防火牆斷開閒置連線
|
||||
- 啟動: `start_keepalive()`,停止: `stop_keepalive()`
|
||||
|
||||
### 注意事項
|
||||
- **禁止**在各 service 中自行建立連線
|
||||
- **禁止**直接使用 `oracledb.connect()`
|
||||
- 連線池由 `database.py` 統一管理,避免連線洩漏
|
||||
- 測試環境需在 setUp 中重置:`db._ENGINE = None`
|
||||
|
||||
---
|
||||
|
||||
## 2. SQL 集中管理
|
||||
|
||||
### 目錄結構
|
||||
所有 SQL 查詢放在 `src/mes_dashboard/sql/` 目錄:
|
||||
|
||||
```
|
||||
sql/
|
||||
├── loader.py # SQL 檔案載入器 (LRU 快取)
|
||||
├── builder.py # 參數化查詢構建器
|
||||
├── filters.py # 通用篩選條件
|
||||
├── dashboard/ # 儀表板 SQL
|
||||
│ ├── kpi.sql
|
||||
│ ├── heatmap.sql
|
||||
│ └── workcenter_cards.sql
|
||||
├── wip/ # WIP SQL
|
||||
│ ├── summary.sql
|
||||
│ └── detail.sql
|
||||
├── resource/ # 設備 SQL
|
||||
│ ├── by_status.sql
|
||||
│ └── detail.sql
|
||||
├── resource_history/ # 歷史 SQL
|
||||
└── job_query/ # 維修工單 SQL
|
||||
```
|
||||
|
||||
### SQLLoader 使用方式
|
||||
|
||||
```python
|
||||
from mes_dashboard.sql.loader import SQLLoader
|
||||
|
||||
# 載入 SQL 檔案 (自動 LRU 快取,最多 100 個)
|
||||
sql = SQLLoader.load("wip/summary")
|
||||
|
||||
# 結構性參數替換 (用於 SQL 片段)
|
||||
sql = SQLLoader.load_with_params("dashboard/kpi",
|
||||
LATEST_STATUS_SUBQUERY="...",
|
||||
WHERE_CLAUSE="...")
|
||||
|
||||
# 清除快取
|
||||
SQLLoader.clear_cache()
|
||||
```
|
||||
|
||||
### QueryBuilder 使用方式
|
||||
|
||||
```python
|
||||
from mes_dashboard.sql.builder import QueryBuilder
|
||||
|
||||
builder = QueryBuilder()
|
||||
|
||||
# 添加條件 (自動參數化,防 SQL 注入)
|
||||
builder.add_param_condition("STATUS", "PRD")
|
||||
builder.add_in_condition("STATUS", ["PRD", "SBY"])
|
||||
builder.add_not_in_condition("HOLD_REASON", exclude_list)
|
||||
builder.add_like_condition("LOTID", user_input, position="both")
|
||||
builder.add_or_like_conditions(["COL1", "COL2"], [val1, val2])
|
||||
builder.add_is_null("COLUMN")
|
||||
builder.add_is_not_null("COLUMN")
|
||||
builder.add_condition("FIXED_CONDITION = 1") # 固定條件
|
||||
|
||||
# 構建 WHERE 子句
|
||||
where_clause, params = builder.build_where_only()
|
||||
|
||||
# 替換佔位符並執行
|
||||
sql = sql.replace("{{ WHERE_CLAUSE }}", where_clause)
|
||||
df = read_sql_df(sql, params)
|
||||
```
|
||||
|
||||
### 佔位符規範
|
||||
|
||||
| 類型 | 語法 | 用途 | 安全性 |
|
||||
|------|------|------|--------|
|
||||
| 結構性 | `{{ PLACEHOLDER }}` | 靜態 SQL 片段 | 僅限預定義值 |
|
||||
| 參數 | `:param_name` | 動態用戶輸入 | Oracle bind variables |
|
||||
|
||||
### Oracle IN 子句限制
|
||||
Oracle IN 子句上限 1000 個值,需分批處理:
|
||||
|
||||
```python
|
||||
BATCH_SIZE = 1000
|
||||
|
||||
# 參考 job_query_service.py 的 _build_resource_filter()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 快取機制
|
||||
|
||||
### 多層快取架構
|
||||
|
||||
```
|
||||
請求 → 進程級快取 (30 秒 TTL)
|
||||
→ Redis 快取 (可配置 TTL)
|
||||
→ Oracle 資料庫
|
||||
```
|
||||
|
||||
### 全局快取 API
|
||||
使用 `mes_dashboard.core.cache` 模組:
|
||||
|
||||
```python
|
||||
from mes_dashboard.core.cache import cache_get, cache_set, make_cache_key
|
||||
|
||||
# 建立快取 key(支援 filters dict)
|
||||
cache_key = make_cache_key("resource_history_summary", filters={
|
||||
'start_date': start_date,
|
||||
'workcenter_groups': sorted(groups) if groups else None,
|
||||
})
|
||||
|
||||
# 讀取/寫入快取
|
||||
result = cache_get(cache_key)
|
||||
if result is None:
|
||||
result = query_data()
|
||||
cache_set(cache_key, result, ttl=CACHE_TTL_TREND)
|
||||
```
|
||||
|
||||
### 快取 TTL 常數
|
||||
定義於 `mes_dashboard.config.constants`:
|
||||
|
||||
```python
|
||||
CACHE_TTL_DEFAULT = 60 # 1 分鐘
|
||||
CACHE_TTL_FILTER_OPTIONS = 600 # 10 分鐘
|
||||
CACHE_TTL_PIVOT_COLUMNS = 300 # 5 分鐘
|
||||
CACHE_TTL_KPI = 60 # 1 分鐘
|
||||
CACHE_TTL_TREND = 300 # 5 分鐘
|
||||
```
|
||||
|
||||
### Redis 快取配置
|
||||
環境變數:
|
||||
```
|
||||
REDIS_ENABLED=true
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
REDIS_KEY_PREFIX=mes_wip
|
||||
```
|
||||
|
||||
### 專用快取服務
|
||||
|
||||
| 服務 | 位置 | 用途 |
|
||||
|------|------|------|
|
||||
| WIP 快取更新器 | `core/cache_updater.py` | 背景線程自動更新 WIP 數據 |
|
||||
| 資源快取 | `services/resource_cache.py` | DW_MES_RESOURCE 表快取 (4 小時同步) |
|
||||
| 設備狀態快取 | `services/realtime_equipment_cache.py` | 設備實時狀態 (5 分鐘同步) |
|
||||
| Filter 快取 | `services/filter_cache.py` | 篩選選項快取 |
|
||||
|
||||
---
|
||||
|
||||
## 4. Filter Cache(篩選選項快取)
|
||||
|
||||
### 位置
|
||||
`mes_dashboard.services.filter_cache`
|
||||
|
||||
### 用途
|
||||
快取全站共用的篩選選項,避免重複查詢資料庫:
|
||||
|
||||
```python
|
||||
from mes_dashboard.services.filter_cache import (
|
||||
get_workcenter_groups, # 取得 workcenter group 列表
|
||||
get_workcenter_mapping, # 取得 workcentername → group 對應
|
||||
get_workcenters_for_groups, # 根據 group 取得 workcentername 列表
|
||||
get_resource_families, # 取得 resource family 列表
|
||||
)
|
||||
```
|
||||
|
||||
### Workcenter 對應關係
|
||||
```
|
||||
WORKCENTERNAME (資料庫) → WORKCENTER_GROUP (顯示)
|
||||
焊接_DB_1 → 焊接_DB
|
||||
焊接_DB_2 → 焊接_DB
|
||||
成型_1 → 成型
|
||||
```
|
||||
|
||||
### 資料來源
|
||||
- Workcenter Groups: `DW_PJ_LOT_V` (WORKCENTER_GROUP, WORKCENTERSEQUENCE_GROUP)
|
||||
- Resource Families: `DW_MES_RESOURCE` (RESOURCEFAMILYNAME)
|
||||
|
||||
---
|
||||
|
||||
## 5. 熔斷器 (Circuit Breaker)
|
||||
|
||||
### 位置
|
||||
`mes_dashboard.core.circuit_breaker`
|
||||
|
||||
### 狀態機制
|
||||
|
||||
```
|
||||
CLOSED (正常)
|
||||
↓ 失敗達到閾值
|
||||
OPEN (故障,拒絕請求)
|
||||
↓ 等待 recovery_timeout
|
||||
HALF_OPEN (測試恢復)
|
||||
↓ 成功 → CLOSED / 失敗 → OPEN
|
||||
```
|
||||
|
||||
### 配置 (環境變數)
|
||||
|
||||
```
|
||||
CIRCUIT_BREAKER_ENABLED=true
|
||||
CIRCUIT_BREAKER_FAILURE_THRESHOLD=5 # 最少失敗次數
|
||||
CIRCUIT_BREAKER_FAILURE_RATE=0.5 # 失敗率閾值 (0.0-1.0)
|
||||
CIRCUIT_BREAKER_RECOVERY_TIMEOUT=30 # OPEN 狀態等待秒數
|
||||
CIRCUIT_BREAKER_WINDOW_SIZE=10 # 滑動窗口大小
|
||||
```
|
||||
|
||||
### 使用方式
|
||||
熔斷器已整合在 `read_sql_df()` 中,自動:
|
||||
- 檢查是否允許請求
|
||||
- 記錄成功/失敗
|
||||
- 狀態轉移
|
||||
|
||||
### 狀態查詢
|
||||
```python
|
||||
from mes_dashboard.core.circuit_breaker import get_database_circuit_breaker
|
||||
|
||||
cb = get_database_circuit_breaker()
|
||||
status = cb.get_status()
|
||||
# status.state, status.failure_count, status.success_count, status.failure_rate
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 統一 API 響應格式
|
||||
|
||||
### 位置
|
||||
`mes_dashboard.core.response`
|
||||
|
||||
### 響應格式
|
||||
|
||||
```python
|
||||
# 成功響應
|
||||
{
|
||||
"success": True,
|
||||
"data": {...},
|
||||
"meta": {"timestamp": "2024-02-04T10:30:45.123456"}
|
||||
}
|
||||
|
||||
# 錯誤響應
|
||||
{
|
||||
"success": False,
|
||||
"error": {
|
||||
"code": "DB_CONNECTION_FAILED",
|
||||
"message": "資料庫連線失敗,請稍後再試",
|
||||
"details": "ORA-12541" # 僅開發模式
|
||||
},
|
||||
"meta": {"timestamp": "..."}
|
||||
}
|
||||
```
|
||||
|
||||
### 錯誤代碼
|
||||
|
||||
| 代碼 | HTTP | 說明 |
|
||||
|------|------|------|
|
||||
| DB_CONNECTION_FAILED | 503 | 資料庫連線失敗 |
|
||||
| DB_QUERY_TIMEOUT | 504 | 查詢逾時 |
|
||||
| DB_QUERY_ERROR | 500 | 查詢執行錯誤 |
|
||||
| SERVICE_UNAVAILABLE | 503 | 服務不可用 |
|
||||
| CIRCUIT_BREAKER_OPEN | 503 | 熔斷器開啟 |
|
||||
| VALIDATION_ERROR | 400 | 驗證失敗 |
|
||||
| UNAUTHORIZED | 401 | 未授權 |
|
||||
| FORBIDDEN | 403 | 禁止訪問 |
|
||||
| NOT_FOUND | 404 | 不存在 |
|
||||
| TOO_MANY_REQUESTS | 429 | 過多請求 |
|
||||
| INTERNAL_ERROR | 500 | 內部錯誤 |
|
||||
|
||||
### 便利函數
|
||||
|
||||
```python
|
||||
from mes_dashboard.core.response import (
|
||||
success_response,
|
||||
validation_error, # 400
|
||||
unauthorized_error, # 401
|
||||
forbidden_error, # 403
|
||||
not_found_error, # 404
|
||||
db_connection_error, # 503
|
||||
internal_error, # 500
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 認證與授權機制
|
||||
|
||||
### 認證服務
|
||||
位置: `mes_dashboard.services.auth_service`
|
||||
|
||||
#### LDAP 認證 (生產環境)
|
||||
```python
|
||||
from mes_dashboard.services.auth_service import authenticate
|
||||
|
||||
user = authenticate(username, password)
|
||||
# 返回: {username, displayName, mail, department}
|
||||
```
|
||||
|
||||
#### 本地認證 (開發環境)
|
||||
```
|
||||
LOCAL_AUTH_ENABLED=true
|
||||
LOCAL_AUTH_USERNAME=admin
|
||||
LOCAL_AUTH_PASSWORD=password
|
||||
```
|
||||
|
||||
### Session 管理
|
||||
```python
|
||||
# 登入後存入 session
|
||||
session["admin"] = {
|
||||
"username": user.get("username"),
|
||||
"displayName": user.get("displayName"),
|
||||
"mail": user.get("mail"),
|
||||
"department": user.get("department"),
|
||||
"login_time": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
# Session 配置
|
||||
SESSION_COOKIE_SECURE = True # HTTPS only (生產)
|
||||
SESSION_COOKIE_HTTPONLY = True # 防止 JS 訪問
|
||||
SESSION_COOKIE_SAMESITE = 'Lax' # CSRF 防護
|
||||
PERMANENT_SESSION_LIFETIME = 28800 # 8 小時
|
||||
```
|
||||
|
||||
### 權限檢查
|
||||
位置: `mes_dashboard.core.permissions`
|
||||
|
||||
```python
|
||||
from mes_dashboard.core.permissions import is_admin_logged_in, admin_required
|
||||
|
||||
# 檢查登入狀態
|
||||
if is_admin_logged_in():
|
||||
...
|
||||
|
||||
# 裝飾器保護路由
|
||||
@admin_required
|
||||
def admin_only_view():
|
||||
...
|
||||
```
|
||||
|
||||
### 登入速率限制
|
||||
- 單 IP 每 5 分鐘最多 5 次嘗試
|
||||
- 位置: `routes/auth_routes.py`
|
||||
|
||||
---
|
||||
|
||||
## 8. 頁面狀態管理
|
||||
|
||||
### 位置
|
||||
- 服務: `mes_dashboard.services.page_registry`
|
||||
- 數據: `data/page_status.json`
|
||||
|
||||
### 狀態定義
|
||||
|
||||
| 狀態 | 說明 |
|
||||
|------|------|
|
||||
| `released` | 所有用戶可訪問 |
|
||||
| `dev` | 僅管理員可訪問 |
|
||||
| `None` | 未註冊,由 Flask 路由控制 |
|
||||
|
||||
### 數據格式
|
||||
```json
|
||||
{
|
||||
"pages": [
|
||||
{"route": "/wip-overview", "name": "WIP 即時概況", "status": "released"},
|
||||
{"route": "/tables", "name": "表格總覽", "status": "dev"}
|
||||
],
|
||||
"api_public": true
|
||||
}
|
||||
```
|
||||
|
||||
### API
|
||||
|
||||
```python
|
||||
from mes_dashboard.services.page_registry import (
|
||||
get_page_status, # 取得頁面狀態
|
||||
set_page_status, # 設定頁面狀態
|
||||
is_api_public, # API 是否公開
|
||||
get_all_pages, # 取得所有頁面
|
||||
)
|
||||
```
|
||||
|
||||
### 權限檢查 (自動)
|
||||
在 `app.py` 的 `@app.before_request` 中自動執行:
|
||||
- dev 頁面 + 非管理員 → 403
|
||||
|
||||
---
|
||||
|
||||
## 9. 日誌系統
|
||||
|
||||
### 雙層日誌架構
|
||||
|
||||
| 層級 | 目標 | 用途 |
|
||||
|------|------|------|
|
||||
| 控制台 (stderr) | Gunicorn 捕獲 | 即時監控 |
|
||||
| SQLite | 管理員儀表板 | 歷史查詢 |
|
||||
|
||||
### 配置 (環境變數)
|
||||
```
|
||||
LOG_STORE_ENABLED=true
|
||||
LOG_SQLITE_PATH=logs/admin_logs.sqlite
|
||||
LOG_SQLITE_RETENTION_DAYS=7
|
||||
LOG_SQLITE_MAX_ROWS=100000
|
||||
```
|
||||
|
||||
### 日誌記錄規範
|
||||
|
||||
```python
|
||||
import logging
|
||||
logger = logging.getLogger('mes_dashboard')
|
||||
|
||||
logger.debug("詳細調試資訊")
|
||||
logger.info("一般操作記錄")
|
||||
logger.warning("警告但可繼續")
|
||||
logger.error("錯誤需要關注", exc_info=True) # 包含堆棧
|
||||
```
|
||||
|
||||
### SQLite 日誌查詢
|
||||
位置: `mes_dashboard.core.log_store`
|
||||
|
||||
```python
|
||||
from mes_dashboard.core.log_store import get_log_store
|
||||
|
||||
store = get_log_store()
|
||||
logs = store.query_logs(
|
||||
level="ERROR",
|
||||
limit=100,
|
||||
offset=0,
|
||||
search="keyword"
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 健康檢查
|
||||
|
||||
### 端點
|
||||
|
||||
| 端點 | 認證 | 說明 |
|
||||
|------|------|------|
|
||||
| `/health` | 無需 | 基本健康檢查 |
|
||||
| `/health/deep` | 需管理員 | 詳細指標 |
|
||||
|
||||
### 基本檢查項目
|
||||
- 資料庫連線 (`SELECT 1 FROM DUAL`)
|
||||
- Redis 連線 (`PING`)
|
||||
- 各快取狀態
|
||||
|
||||
### 詳細檢查項目 (deep)
|
||||
- 資料庫延遲 (毫秒)
|
||||
- 連線池狀態 (size, checked_out, overflow)
|
||||
- 快取新鮮度
|
||||
- 熔斷器狀態
|
||||
- 查詢性能指標 (P50/P95/P99)
|
||||
|
||||
### 狀態判定
|
||||
- `200 OK` (healthy/degraded): DB 正常
|
||||
- `503 Unavailable` (unhealthy): DB 故障
|
||||
|
||||
---
|
||||
|
||||
## 11. API 路由結構 (Blueprint)
|
||||
|
||||
### Blueprint 列表
|
||||
|
||||
| Blueprint | URL 前綴 | 檔案 |
|
||||
|-----------|---------|------|
|
||||
| wip | `/api/wip` | `wip_routes.py` |
|
||||
| resource | `/api/resource` | `resource_routes.py` |
|
||||
| dashboard | `/api/dashboard` | `dashboard_routes.py` |
|
||||
| excel_query | `/api/excel-query` | `excel_query_routes.py` |
|
||||
| hold | `/api/hold` | `hold_routes.py` |
|
||||
| resource_history | `/api/resource-history` | `resource_history_routes.py` |
|
||||
| job_query | `/api/job-query` | `job_query_routes.py` |
|
||||
| admin | `/admin` | `admin_routes.py` |
|
||||
| auth | `/admin` | `auth_routes.py` |
|
||||
| health | `/` | `health_routes.py` |
|
||||
|
||||
### 路由註冊
|
||||
位置: `routes/__init__.py` 的 `register_routes(app)`
|
||||
|
||||
---
|
||||
|
||||
## 12. 前端全局組件
|
||||
|
||||
### Toast 通知
|
||||
定義於 `static/js/toast.js`,透過 `_base.html` 載入:
|
||||
|
||||
```javascript
|
||||
// 正確用法
|
||||
Toast.info('訊息');
|
||||
Toast.success('成功');
|
||||
Toast.warning('警告');
|
||||
Toast.error('錯誤', { retry: () => loadData() });
|
||||
|
||||
const id = Toast.loading('載入中...');
|
||||
Toast.update(id, { message: '完成!' });
|
||||
Toast.dismiss(id);
|
||||
|
||||
// 錯誤用法(不存在)
|
||||
MESToast.warning('...'); // ❌ 錯誤
|
||||
```
|
||||
|
||||
### 自動消失時間
|
||||
- info: 3000ms
|
||||
- success: 2000ms
|
||||
- warning: 5000ms
|
||||
- error: 永久(需手動關閉)
|
||||
- loading: 永久
|
||||
|
||||
### MesApi(HTTP 請求)
|
||||
定義於 `static/js/mes-api.js`:
|
||||
|
||||
```javascript
|
||||
// GET 請求
|
||||
const data = await MesApi.get('/api/wip/summary', {
|
||||
params: { page: 1 },
|
||||
timeout: 60000,
|
||||
retries: 5,
|
||||
signal: abortController.signal,
|
||||
silent: true // 禁用 toast 通知
|
||||
});
|
||||
|
||||
// POST 請求
|
||||
const data = await MesApi.post('/api/query_table', {
|
||||
table_name: 'TABLE_A',
|
||||
filters: {...}
|
||||
});
|
||||
```
|
||||
|
||||
### MesApi 特性
|
||||
- 自動重試 (3 次,指數退避: 1s, 2s, 4s)
|
||||
- 自動 Toast 通知
|
||||
- 請求 ID 追蹤
|
||||
- AbortSignal 支援
|
||||
- 4xx 不重試,5xx 重試
|
||||
|
||||
---
|
||||
|
||||
## 13. 資料表預篩選規則
|
||||
|
||||
### 設備類型篩選
|
||||
定義於 `mes_dashboard.config.constants.EQUIPMENT_TYPE_FILTER`:
|
||||
|
||||
```sql
|
||||
((OBJECTCATEGORY = 'ASSEMBLY' AND OBJECTTYPE = 'ASSEMBLY')
|
||||
OR (OBJECTCATEGORY = 'WAFERSORT' AND OBJECTTYPE = 'WAFERSORT'))
|
||||
```
|
||||
|
||||
### 排除條件
|
||||
```python
|
||||
# 排除的地點
|
||||
EXCLUDED_LOCATIONS = [
|
||||
'ATEC', 'F區', 'F區焊接站', '報廢', '實驗室',
|
||||
'山東', '成型站_F區', '焊接F區', '無錫', '熒茂'
|
||||
]
|
||||
|
||||
# 排除的資產狀態
|
||||
EXCLUDED_ASSET_STATUSES = ['Disapproved']
|
||||
```
|
||||
|
||||
### CommonFilters 使用
|
||||
位置: `mes_dashboard.sql.filters`
|
||||
|
||||
```python
|
||||
from mes_dashboard.sql.filters import CommonFilters
|
||||
|
||||
# 添加標準篩選
|
||||
CommonFilters.add_location_exclusion(builder, 'r')
|
||||
CommonFilters.add_asset_status_exclusion(builder, 'r')
|
||||
CommonFilters.add_wip_base_filters(builder, filters)
|
||||
CommonFilters.add_equipment_filter(builder, filters)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 14. 資料庫欄位對應
|
||||
|
||||
### DW_MES_RESOURCE
|
||||
| 常見錯誤 | 正確欄位名 |
|
||||
|---------|-----------|
|
||||
| ASSETSTATUS | PJ_ASSETSSTATUS(雙 S)|
|
||||
| LOCATION | LOCATIONNAME |
|
||||
| ISPRODUCTION | PJ_ISPRODUCTION |
|
||||
| ISKEY | PJ_ISKEY |
|
||||
| ISMONITOR | PJ_ISMONITOR |
|
||||
|
||||
### DW_MES_RESOURCESTATUS_SHIFT
|
||||
| 欄位 | 說明 |
|
||||
|-----|------|
|
||||
| HISTORYID | 對應 DW_MES_RESOURCE.RESOURCEID |
|
||||
| TXNDATE | 交易日期 |
|
||||
| OLDSTATUSNAME | E10 狀態 (PRD, SBY, UDT, SDT, EGT, NST) |
|
||||
| HOURS | 該狀態時數 |
|
||||
|
||||
### DW_PJ_LOT_V
|
||||
| 欄位 | 說明 |
|
||||
|-----|------|
|
||||
| WORKCENTERNAME | 站點名稱(細分)|
|
||||
| WORKCENTER_GROUP | 站點群組(顯示用)|
|
||||
| WORKCENTERSEQUENCE_GROUP | 群組排序 |
|
||||
|
||||
---
|
||||
|
||||
## 15. E10 狀態定義
|
||||
|
||||
| 狀態 | 說明 | 計入 OU% |
|
||||
|-----|------|---------|
|
||||
| PRD | Production(生產)| 是(分子)|
|
||||
| SBY | Standby(待機)| 是(分母)|
|
||||
| UDT | Unscheduled Downtime(非計畫停機)| 是(分母)|
|
||||
| SDT | Scheduled Downtime(計畫停機)| 是(分母)|
|
||||
| EGT | Engineering Time(工程時間)| 是(分母)|
|
||||
| NST | Non-Scheduled Time(非排程時間)| 否 |
|
||||
|
||||
### OU% 計算公式
|
||||
```
|
||||
OU% = PRD / (PRD + SBY + UDT + SDT + EGT) × 100
|
||||
```
|
||||
|
||||
### 狀態顯示名稱
|
||||
```python
|
||||
STATUS_DISPLAY_NAMES = {
|
||||
'PRD': '生產中',
|
||||
'SBY': '待機',
|
||||
'UDT': '非計畫停機',
|
||||
'SDT': '計畫停機',
|
||||
'EGT': '工程時間',
|
||||
'NST': '未排單',
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 16. 配置管理
|
||||
|
||||
### 環境變數 (.env)
|
||||
|
||||
#### 資料庫
|
||||
```
|
||||
DB_HOST=<your_database_host>
|
||||
DB_PORT=1521
|
||||
DB_SERVICE=<your_service_name>
|
||||
DB_USER=<your_username>
|
||||
DB_PASSWORD=<your_password>
|
||||
DB_POOL_SIZE=5
|
||||
DB_MAX_OVERFLOW=10
|
||||
```
|
||||
|
||||
> 實際值請參考 `.env` 或 `.env.example`
|
||||
|
||||
#### Flask
|
||||
```
|
||||
FLASK_ENV=production
|
||||
FLASK_DEBUG=0
|
||||
SECRET_KEY=your_secret_key
|
||||
SESSION_LIFETIME=28800
|
||||
```
|
||||
|
||||
#### 認證
|
||||
```
|
||||
LDAP_API_URL=<your_ldap_api_url>
|
||||
ADMIN_EMAILS=<admin_email_list>
|
||||
LOCAL_AUTH_ENABLED=false
|
||||
```
|
||||
|
||||
#### Gunicorn
|
||||
```
|
||||
GUNICORN_BIND=0.0.0.0:8080
|
||||
GUNICORN_WORKERS=4
|
||||
GUNICORN_THREADS=8
|
||||
```
|
||||
|
||||
#### 快取
|
||||
```
|
||||
REDIS_ENABLED=true
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
CACHE_CHECK_INTERVAL=600
|
||||
RESOURCE_CACHE_ENABLED=true
|
||||
RESOURCE_SYNC_INTERVAL=14400
|
||||
```
|
||||
|
||||
#### 熔斷器
|
||||
```
|
||||
CIRCUIT_BREAKER_ENABLED=true
|
||||
CIRCUIT_BREAKER_FAILURE_THRESHOLD=5
|
||||
CIRCUIT_BREAKER_FAILURE_RATE=0.5
|
||||
CIRCUIT_BREAKER_RECOVERY_TIMEOUT=30
|
||||
```
|
||||
|
||||
#### 日誌
|
||||
```
|
||||
LOG_STORE_ENABLED=true
|
||||
LOG_SQLITE_PATH=logs/admin_logs.sqlite
|
||||
LOG_SQLITE_RETENTION_DAYS=7
|
||||
```
|
||||
|
||||
### 環境配置類
|
||||
位置: `mes_dashboard.config.settings`
|
||||
|
||||
```python
|
||||
class DevelopmentConfig(Config):
|
||||
DEBUG = True
|
||||
DB_POOL_SIZE = 2
|
||||
|
||||
class ProductionConfig(Config):
|
||||
DEBUG = False
|
||||
DB_POOL_SIZE = 10
|
||||
|
||||
class TestingConfig(Config):
|
||||
TESTING = True
|
||||
DB_POOL_SIZE = 1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 17. 平行查詢
|
||||
|
||||
### ThreadPoolExecutor
|
||||
對於多個獨立查詢,使用平行執行提升效能:
|
||||
|
||||
```python
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
with ThreadPoolExecutor(max_workers=4) as executor:
|
||||
futures = {
|
||||
executor.submit(read_sql_df, kpi_sql): 'kpi',
|
||||
executor.submit(read_sql_df, trend_sql): 'trend',
|
||||
executor.submit(read_sql_df, heatmap_sql): 'heatmap',
|
||||
}
|
||||
for future in as_completed(futures):
|
||||
query_name = futures[future]
|
||||
results[query_name] = future.result()
|
||||
```
|
||||
|
||||
### 注意事項
|
||||
- Mock 測試時不能使用 `side_effect` 列表(順序不可預測)
|
||||
- 應使用函式判斷 SQL 內容來回傳對應的 mock 資料
|
||||
|
||||
---
|
||||
|
||||
## 18. Oracle SQL 優化
|
||||
|
||||
### CTE MATERIALIZE Hint
|
||||
防止 Oracle 優化器將 CTE inline 多次執行:
|
||||
|
||||
```sql
|
||||
WITH shift_data AS (
|
||||
SELECT /*+ MATERIALIZE */ HISTORYID, TXNDATE, OLDSTATUSNAME, HOURS
|
||||
FROM DW_MES_RESOURCESTATUS_SHIFT
|
||||
WHERE TXNDATE >= TO_DATE('2024-01-01', 'YYYY-MM-DD')
|
||||
AND TXNDATE < TO_DATE('2024-01-07', 'YYYY-MM-DD') + 1
|
||||
)
|
||||
SELECT ...
|
||||
```
|
||||
|
||||
### 日期範圍查詢
|
||||
```sql
|
||||
-- 包含 end_date 當天
|
||||
WHERE TXNDATE >= TO_DATE(:start_date, 'YYYY-MM-DD')
|
||||
AND TXNDATE < TO_DATE(:end_date, 'YYYY-MM-DD') + 1
|
||||
```
|
||||
|
||||
### 慢查詢警告
|
||||
- 閾值: 1 秒 (警告),5 秒 (`SLOW_QUERY_THRESHOLD`)
|
||||
- 自動記錄到日誌
|
||||
|
||||
---
|
||||
|
||||
## 19. 前端資料限制
|
||||
|
||||
### 明細資料上限
|
||||
為避免瀏覽器記憶體問題,明細查詢有筆數限制:
|
||||
|
||||
```python
|
||||
MAX_DETAIL_RECORDS = 5000
|
||||
|
||||
if total > MAX_DETAIL_RECORDS:
|
||||
df = df.head(MAX_DETAIL_RECORDS)
|
||||
truncated = True
|
||||
```
|
||||
|
||||
前端顯示警告:
|
||||
```javascript
|
||||
if (result.truncated) {
|
||||
Toast.warning(`資料超過 ${result.max_records} 筆,請使用篩選條件縮小範圍。`);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 20. JavaScript 注意事項
|
||||
|
||||
### Array.reverse() 原地修改
|
||||
```javascript
|
||||
// 錯誤 - 原地修改陣列
|
||||
const arr = [1, 2, 3];
|
||||
arr.reverse(); // arr 被修改為 [3, 2, 1]
|
||||
|
||||
// 正確 - 建立新陣列
|
||||
const reversed = arr.slice().reverse(); // arr 不變
|
||||
// 或
|
||||
const reversed = [...arr].reverse();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 21. 測試規範
|
||||
|
||||
### 測試檔案結構
|
||||
```
|
||||
tests/
|
||||
├── conftest.py # pytest fixtures
|
||||
├── test_*_service.py # 單元測試(service layer)
|
||||
├── test_*_routes.py # 整合測試(API endpoints)
|
||||
├── e2e/
|
||||
│ └── test_*_e2e.py # 端對端測試(完整流程)
|
||||
└── stress/
|
||||
└── test_*.py # 壓力測試
|
||||
```
|
||||
|
||||
### 測試前重置
|
||||
```python
|
||||
def setUp(self):
|
||||
db._ENGINE = None # 重置連線池
|
||||
self.app = create_app('testing')
|
||||
```
|
||||
|
||||
### 執行測試
|
||||
```bash
|
||||
# 單一模組
|
||||
pytest tests/test_resource_history_service.py -v
|
||||
|
||||
# 全部相關測試
|
||||
pytest tests/test_resource_history_*.py tests/e2e/test_resource_history_e2e.py -v
|
||||
|
||||
# 覆蓋率報告
|
||||
pytest tests/ --cov=mes_dashboard
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 22. 錯誤處理模式
|
||||
|
||||
### 三層錯誤處理
|
||||
|
||||
```python
|
||||
# 1. 路由層 - 驗證錯誤
|
||||
@bp.route('/api/query')
|
||||
def query():
|
||||
if not request.json.get('table_name'):
|
||||
return validation_error("table_name 為必填")
|
||||
|
||||
# 2. 服務層 - 業務錯誤 (優雅降級)
|
||||
def get_wip_summary(filters):
|
||||
try:
|
||||
df = query_wip(filters)
|
||||
if df.empty:
|
||||
return None
|
||||
return process_data(df)
|
||||
except Exception as exc:
|
||||
logger.error(f"WIP query failed: {exc}")
|
||||
return None
|
||||
|
||||
# 3. 核心層 - 基礎設施錯誤
|
||||
def read_sql_df(sql, params):
|
||||
if not circuit_breaker.allow_request():
|
||||
raise RuntimeError("Circuit breaker open")
|
||||
```
|
||||
|
||||
### 全局錯誤處理
|
||||
位置: `app.py` 的 `_register_error_handlers()`
|
||||
|
||||
- 401 → `unauthorized_error()`
|
||||
- 403 → `forbidden_error()`
|
||||
- 404 → JSON (API) 或 HTML (頁面)
|
||||
- 500 → `internal_error()`
|
||||
- Exception → 通用處理
|
||||
|
||||
---
|
||||
|
||||
## 參考檔案索引
|
||||
|
||||
| 功能 | 檔案位置 |
|
||||
|------|---------|
|
||||
| SQL 載入 | `src/mes_dashboard/sql/loader.py` |
|
||||
| 查詢構建 | `src/mes_dashboard/sql/builder.py` |
|
||||
| 通用篩選 | `src/mes_dashboard/sql/filters.py` |
|
||||
| 資料庫操作 | `src/mes_dashboard/core/database.py` |
|
||||
| 快取 | `src/mes_dashboard/core/cache.py` |
|
||||
| 熔斷器 | `src/mes_dashboard/core/circuit_breaker.py` |
|
||||
| API 響應 | `src/mes_dashboard/core/response.py` |
|
||||
| 權限檢查 | `src/mes_dashboard/core/permissions.py` |
|
||||
| 日誌存儲 | `src/mes_dashboard/core/log_store.py` |
|
||||
| 配置類 | `src/mes_dashboard/config/settings.py` |
|
||||
| 常量定義 | `src/mes_dashboard/config/constants.py` |
|
||||
| 認證服務 | `src/mes_dashboard/services/auth_service.py` |
|
||||
| 頁面狀態 | `src/mes_dashboard/services/page_registry.py` |
|
||||
| Filter 快取 | `src/mes_dashboard/services/filter_cache.py` |
|
||||
| 資源快取 | `src/mes_dashboard/services/resource_cache.py` |
|
||||
| API 客戶端 | `src/mes_dashboard/static/js/mes-api.js` |
|
||||
| Toast 系統 | `src/mes_dashboard/static/js/toast.js` |
|
||||
34
docs/environment_gaps_and_mitigation.md
Normal file
34
docs/environment_gaps_and_mitigation.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Environment-dependent Gaps and Mitigation
|
||||
|
||||
## Oracle-dependent checks
|
||||
|
||||
### Gap
|
||||
- Service/integration paths that execute Oracle SQL require live DB credentials and network reachability.
|
||||
- Local CI-like runs may not have Oracle connectivity.
|
||||
- In this environment, `tests/test_cache_integration.py` has Oracle-dependent fallback failures when cache fixtures are insufficient.
|
||||
|
||||
### Mitigation
|
||||
- Keep unit tests isolated with mocks for SQL entry points.
|
||||
- Reserve Oracle-connected tests for gated environments.
|
||||
- Use `testing` config for app factory tests where possible.
|
||||
|
||||
## Redis-dependent checks
|
||||
|
||||
### Gap
|
||||
- Redis availability differs across environments.
|
||||
- Health/caching behavior differs between `L1+L2` and `L1-only degraded` modes.
|
||||
|
||||
### Mitigation
|
||||
- Expose route-cache telemetry in `/health` and `/health/deep`.
|
||||
- Keep degraded mode visible and non-fatal where DB remains healthy.
|
||||
- Validate both modes in unit tests (`tests/test_cache.py`, `tests/test_health_routes.py`).
|
||||
|
||||
## Frontend build availability
|
||||
|
||||
### Gap
|
||||
- Node/npm may be absent on constrained runtime nodes.
|
||||
|
||||
### Mitigation
|
||||
- Keep inline script fallback in templates when dist assets are missing.
|
||||
- Build artifacts in deployment pipeline where Node is available.
|
||||
- Startup script logs fallback mode explicitly on build failure.
|
||||
42
docs/frontend_compute_shift_plan.md
Normal file
42
docs/frontend_compute_shift_plan.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# Frontend Compute Shift Plan
|
||||
|
||||
## Targeted Calculations
|
||||
|
||||
## Resource History (migrated to frontend helpers)
|
||||
- `ou_pct`
|
||||
- `availability_pct`
|
||||
- status percentages:
|
||||
- `prd_pct`
|
||||
- `sby_pct`
|
||||
- `udt_pct`
|
||||
- `sdt_pct`
|
||||
- `egt_pct`
|
||||
- `nst_pct`
|
||||
|
||||
These are now computed by `frontend/src/core/compute.js` via:
|
||||
- `buildResourceKpiFromHours`
|
||||
- `calcOuPct`
|
||||
- `calcAvailabilityPct`
|
||||
- `calcStatusPct`
|
||||
|
||||
## Parity Rules
|
||||
|
||||
1. Rounding rule
|
||||
- one decimal place, identical to backend (`round(..., 1)`)
|
||||
|
||||
2. Formula rule
|
||||
- OU%: `PRD / (PRD + SBY + UDT + SDT + EGT)`
|
||||
- Availability%: `(PRD + SBY + EGT) / (PRD + SBY + EGT + SDT + UDT + NST)`
|
||||
- Status%: `status_hours / total_hours`
|
||||
|
||||
3. Zero denominator rule
|
||||
- all percentages return `0`
|
||||
|
||||
4. Data compatibility rule
|
||||
- backend keeps existing fields to preserve API compatibility
|
||||
- frontend recomputes display values from hours for deterministic parity
|
||||
|
||||
## Validation
|
||||
|
||||
- Python backend formula baseline: `mes_dashboard.services.resource_history_service`
|
||||
- Frontend parity check: `tests/test_frontend_compute_parity.py`
|
||||
113
docs/migration_gates_and_runbook.md
Normal file
113
docs/migration_gates_and_runbook.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# Migration Gates and Runbook
|
||||
|
||||
## Gate Checklist (Cutover Readiness)
|
||||
|
||||
A release is cutover-ready only when all gates pass:
|
||||
|
||||
1. Frontend build gate
|
||||
- `npm --prefix frontend run build` succeeds
|
||||
- expected artifacts exist in `src/mes_dashboard/static/dist/`
|
||||
|
||||
2. Root execution gate
|
||||
- startup and deploy scripts run from repository root only
|
||||
- no runtime dependency on any legacy subtree path
|
||||
|
||||
3. Functional parity gate
|
||||
- resource-history frontend compute parity checks pass
|
||||
- job-query/resource-history export headers match shared field contracts
|
||||
|
||||
4. Cache observability gate
|
||||
- `/health` returns route cache telemetry and degraded flags
|
||||
- `/health/deep` returns route cache telemetry for diagnostics
|
||||
- `/health` includes `database_pool.runtime/state`, `degraded_reason`
|
||||
- resource/wip derived index telemetry is visible (`resource_cache.derived_index`, `cache.derived_search_index`)
|
||||
|
||||
5. Runtime resilience gate
|
||||
- pool exhaustion path returns `503` + `DB_POOL_EXHAUSTED` and `Retry-After`
|
||||
- circuit-open path returns `503` + `CIRCUIT_BREAKER_OPEN` and fail-fast semantics
|
||||
- frontend client does not aggressively retry on degraded pool exhaustion responses
|
||||
|
||||
6. Conda-systemd contract gate
|
||||
- `deploy/mes-dashboard.service` and `deploy/mes-dashboard-watchdog.service` both run in the same conda runtime contract
|
||||
- `WATCHDOG_RESTART_FLAG`, `WATCHDOG_PID_FILE`, `WATCHDOG_STATE_FILE` paths are consistent across app/admin/watchdog
|
||||
- single-port bind (`GUNICORN_BIND`) remains stable during restart workflow
|
||||
|
||||
7. Regression gate
|
||||
- focused unit/integration test subset passes (see validation evidence)
|
||||
|
||||
8. Documentation alignment gate
|
||||
- `README.md` (and project-required mirror docs such as `README.mdj`) reflect current runtime architecture contract
|
||||
- resilience diagnostics fields (thresholds/churn/recommendation) are documented for operators
|
||||
- frontend shared-core governance updates are reflected in architecture notes
|
||||
|
||||
## Rollout Procedure
|
||||
|
||||
1. Prepare environment
|
||||
- Activate conda env (`mes-dashboard`)
|
||||
- install Python deps: `pip install -r requirements.txt`
|
||||
- install frontend deps: `npm --prefix frontend install`
|
||||
|
||||
2. Build frontend artifacts
|
||||
- `npm --prefix frontend run build`
|
||||
|
||||
3. Run migration gate tests
|
||||
- execute focused pytest set covering templates/cache/contracts/health
|
||||
|
||||
4. Deploy with single-port mode
|
||||
- start app with root `scripts/start_server.sh`
|
||||
- verify portal and module pages render on same origin/port
|
||||
|
||||
5. Conda + systemd rehearsal (recommended before production cutover)
|
||||
- `sudo cp deploy/mes-dashboard.service /etc/systemd/system/`
|
||||
- `sudo cp deploy/mes-dashboard-watchdog.service /etc/systemd/system/`
|
||||
- `sudo mkdir -p /etc/mes-dashboard && sudo cp .env /etc/mes-dashboard/mes-dashboard.env`
|
||||
- `sudo systemctl daemon-reload`
|
||||
- `sudo systemctl enable --now mes-dashboard mes-dashboard-watchdog`
|
||||
- call `/admin/api/worker/status` and verify runtime contract paths exist
|
||||
|
||||
6. Post-deploy checks
|
||||
- call `/health` and `/health/deep`
|
||||
- confirm route cache mode, degraded flags, and pool/runtime diagnostics align with environment (Redis on/off)
|
||||
- trigger one controlled worker restart from admin API and verify single-port continuity
|
||||
- verify README architecture section matches deployed runtime contract
|
||||
|
||||
## Rollback Procedure
|
||||
|
||||
1. Trigger rollback criteria
|
||||
- any critical gate failure after deployment (page unusable, export mismatch, health degradation beyond acceptable limits)
|
||||
|
||||
2. Operational rollback steps
|
||||
- stop service: `scripts/start_server.sh stop`
|
||||
- restore previously known-good build artifacts (or prior release package)
|
||||
- restart service: `scripts/start_server.sh start`
|
||||
- if using systemd: `sudo systemctl restart mes-dashboard mes-dashboard-watchdog`
|
||||
|
||||
3. Validation after rollback
|
||||
- verify `/health` status is at least expected baseline
|
||||
- re-run focused smoke tests for portal + key pages
|
||||
- confirm CSV export downloads and headers
|
||||
- verify degraded reason is cleared or matches expected dependency outage only
|
||||
|
||||
## Rollback Rehearsal Checklist
|
||||
|
||||
1. Simulate failure condition (e.g. invalid dist artifact deployment)
|
||||
2. Execute stop/restore/start sequence
|
||||
3. Verify health and page smoke checks
|
||||
4. Capture timings and any manual intervention points
|
||||
5. Update this runbook if any step was unclear or missing
|
||||
|
||||
## Alert Thresholds (Operational Contract)
|
||||
|
||||
Use these initial thresholds for alerting/escalation:
|
||||
|
||||
1. Sustained degraded state
|
||||
- `degraded_reason` non-empty for >= 5 minutes
|
||||
|
||||
2. Worker restart churn
|
||||
- >= 3 watchdog-triggered restarts within 10 minutes
|
||||
|
||||
3. Pool saturation pressure
|
||||
- `database_pool.state.saturation >= 0.90` for >= 3 consecutive health probes
|
||||
|
||||
4. Frontend/API retry pressure
|
||||
- significant increase of client retries for `DB_POOL_EXHAUSTED` or `CIRCUIT_BREAKER_OPEN` responses over baseline
|
||||
60
docs/migration_validation_evidence.md
Normal file
60
docs/migration_validation_evidence.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# Migration Validation Evidence
|
||||
|
||||
Date: 2026-02-07
|
||||
|
||||
## Build
|
||||
|
||||
Command:
|
||||
- `npm --prefix frontend run build`
|
||||
|
||||
Result:
|
||||
- PASS
|
||||
- Generated page bundles:
|
||||
- `portal.js`
|
||||
- `resource-status.js`
|
||||
- `resource-history.js`
|
||||
- `job-query.js`
|
||||
- `excel-query.js`
|
||||
- `tables.js`
|
||||
|
||||
## Root Startup Smoke
|
||||
|
||||
Command:
|
||||
- `PYTHONPATH=src python -c \"from mes_dashboard.app import create_app; app=create_app('testing'); print('routes', len(list(app.url_map.iter_rules())))\"`
|
||||
|
||||
Result:
|
||||
- PASS
|
||||
- `routes 71`
|
||||
- Redis/Oracle warnings observed in this local environment; app factory and route registration still completed.
|
||||
|
||||
## Focused Test Gate (root project)
|
||||
|
||||
Command:
|
||||
- `python -m pytest -q tests/test_app_factory.py tests/test_template_integration.py tests/test_cache.py tests/test_health_routes.py tests/test_field_contracts.py tests/test_frontend_compute_parity.py tests/test_job_query_service.py tests/test_resource_history_service.py`
|
||||
|
||||
Result:
|
||||
- PASS
|
||||
- `107 passed`
|
||||
|
||||
## Extended Regression Spot-check
|
||||
|
||||
Command:
|
||||
- `python -m pytest -q tests/test_job_query_routes.py tests/test_resource_history_routes.py tests/test_cache_integration.py`
|
||||
|
||||
Result:
|
||||
- PARTIAL
|
||||
- `45 passed, 2 failed`
|
||||
- Failed tests:
|
||||
- `tests/test_cache_integration.py::TestWipApiWithCache::test_wip_matrix_uses_cache`
|
||||
- `tests/test_cache_integration.py::TestWipApiWithCache::test_packages_uses_cache`
|
||||
|
||||
Failure profile:
|
||||
- cache-fallback path hit Oracle in local environment and returned ORA connectivity/thick-mode errors.
|
||||
- categorized as environment-dependent (see `docs/environment_gaps_and_mitigation.md`).
|
||||
|
||||
## Health/Telemetry Coverage
|
||||
|
||||
Validated by tests:
|
||||
- `/health` includes `route_cache` telemetry and degraded warnings
|
||||
- `/health/deep` includes route-cache telemetry block
|
||||
- cache telemetry includes L1/L2 mode, hit/miss counters, degraded state
|
||||
44
docs/page_architecture_map.md
Normal file
44
docs/page_architecture_map.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# Page Architecture Map
|
||||
|
||||
## Portal Navigation Model
|
||||
|
||||
Portal (`/`) uses drawer-based navigation and keeps existing operational flow:
|
||||
|
||||
- 報表類
|
||||
- `/wip-overview`
|
||||
- `/resource`
|
||||
- `/resource-history`
|
||||
- 查詢類
|
||||
- `/tables`
|
||||
- `/excel-query`
|
||||
- `/job-query`
|
||||
- 開發工具
|
||||
- `/admin/pages`
|
||||
- `/admin/performance`
|
||||
|
||||
## Independent Pages
|
||||
|
||||
These pages are independent views (iframe tabs in portal) and can be loaded directly:
|
||||
- `/wip-overview`
|
||||
- `/resource`
|
||||
- `/resource-history`
|
||||
- `/tables`
|
||||
- `/excel-query`
|
||||
- `/job-query`
|
||||
|
||||
## Drill-down Pages
|
||||
|
||||
These pages are drill-down/detail pages, linked from parent views:
|
||||
- `/wip-detail` (from WIP flows)
|
||||
- `/hold-detail` (from hold-related flows)
|
||||
|
||||
## Vite Entry Mapping
|
||||
|
||||
- `portal` -> `frontend/src/portal/main.js`
|
||||
- `resource-status` -> `frontend/src/resource-status/main.js`
|
||||
- `resource-history` -> `frontend/src/resource-history/main.js`
|
||||
- `job-query` -> `frontend/src/job-query/main.js`
|
||||
- `excel-query` -> `frontend/src/excel-query/main.js`
|
||||
- `tables` -> `frontend/src/tables/main.js`
|
||||
|
||||
All pages keep inline fallback scripts in templates when module assets are unavailable.
|
||||
56
docs/root_cutover_inventory.md
Normal file
56
docs/root_cutover_inventory.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# Root Cutover Inventory
|
||||
|
||||
## Scope
|
||||
- Workspace root: `/Users/egg/Projects/DashBoard_vite`
|
||||
- Legacy subtree `DashBoard/`: removed on 2026-02-08
|
||||
- Objective: ensure runtime/test/deploy flows depend only on root architecture.
|
||||
|
||||
## 1. Runtime / Test / Deploy Path Audit
|
||||
|
||||
### Legacy path references
|
||||
- Historical mentions may exist in archived OpenSpec artifacts for traceability.
|
||||
- Active runtime/test/deploy code MUST NOT reference removed legacy subtree paths.
|
||||
|
||||
### Result
|
||||
- Legacy code directory is removed.
|
||||
- No active runtime code in `src/`, `scripts/`, or `tests/` requires legacy subtree paths.
|
||||
- Remaining mentions are documentation-only migration history.
|
||||
|
||||
## 2. Root-only Execution Hardening
|
||||
|
||||
### Updated
|
||||
- `scripts/start_server.sh`
|
||||
- Frontend build readiness now checks all required root dist entries:
|
||||
- `portal.js`
|
||||
- `resource-status.js`
|
||||
- `resource-history.js`
|
||||
- `job-query.js`
|
||||
- `excel-query.js`
|
||||
- `tables.js`
|
||||
|
||||
### Verified behavior target
|
||||
- Startup/build logic remains anchored to root paths:
|
||||
- `frontend/`
|
||||
- `src/mes_dashboard/static/dist/`
|
||||
- `src/`
|
||||
|
||||
## 3. Root-only Smoke Checks (single-port)
|
||||
|
||||
### Build smoke
|
||||
- `npm --prefix frontend run build`
|
||||
|
||||
### App import smoke
|
||||
- `PYTHONPATH=src python -c "from mes_dashboard.app import create_app; app=create_app('testing'); print(app.url_map)"`
|
||||
- Verified route initialization count (`routes 71`) in root-only execution context.
|
||||
|
||||
### HTTP smoke (Flask test client)
|
||||
- Verify page renders and module asset tags resolve/fallback:
|
||||
- `/`
|
||||
- `/resource`
|
||||
- `/resource-history`
|
||||
- `/job-query`
|
||||
- `/excel-query`
|
||||
- `/tables`
|
||||
|
||||
### Test smoke
|
||||
- `python -m pytest -q tests/test_app_factory.py tests/test_template_integration.py tests/test_cache.py`
|
||||
37
docs/root_refactor_validation_notes.md
Normal file
37
docs/root_refactor_validation_notes.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# Root Refactor Validation Notes
|
||||
|
||||
Date: 2026-02-07
|
||||
|
||||
## Focused Validation (Root Project)
|
||||
|
||||
- Frontend build:
|
||||
- `npm --prefix frontend run build` ✅
|
||||
- Python focused tests:
|
||||
- `python -m pytest -q tests/test_app_factory.py tests/test_cache.py tests/test_job_query_service.py` ✅ (46 passed)
|
||||
- Root portal asset integration check:
|
||||
- GET `/` from Flask test client includes `/static/dist/portal.js` and `/static/dist/portal.css` ✅
|
||||
|
||||
## Environment-Dependent Gaps
|
||||
|
||||
The following are known non-functional gaps in local validation due to missing external runtime dependencies:
|
||||
|
||||
1. Oracle-dependent integration tests
|
||||
- Some routes/services start background workers that attempt Oracle queries at app init.
|
||||
- In local environment without valid Oracle connectivity, logs contain `DPY-3001` and related query failures.
|
||||
|
||||
2. Redis-dependent runtime checks
|
||||
- Redis is not reachable in local environment (`localhost:6379` connection refused).
|
||||
- Cache fallback paths continue to run, but Redis health-dependent behavior is not fully exercised.
|
||||
|
||||
3. Dev-page permission tests
|
||||
- Certain template tests expecting `/tables` or `/excel-query` content may fail when page status is `dev` for non-admin sessions.
|
||||
|
||||
## Recommended Next Validation Stage
|
||||
|
||||
- Run full test suite in an environment with:
|
||||
- reachable Oracle test endpoint
|
||||
- reachable Redis endpoint
|
||||
- page status fixtures aligned with expected test roles
|
||||
- Add CI matrix split:
|
||||
- unit/fallback tests (no Oracle/Redis required)
|
||||
- integration tests (Oracle/Redis required)
|
||||
46
environment.yml
Normal file
46
environment.yml
Normal file
@@ -0,0 +1,46 @@
|
||||
# Conda environment for MES Dashboard
|
||||
# Usage: conda env create -f environment.yml
|
||||
# conda activate mes-dashboard
|
||||
#
|
||||
# Note: Most packages use minimum version pins (>=) to allow automatic security updates.
|
||||
# For reproducible builds, generate a lock file: pip freeze > requirements.lock
|
||||
|
||||
name: mes-dashboard
|
||||
channels:
|
||||
- conda-forge
|
||||
- defaults
|
||||
dependencies:
|
||||
# Python version - pinned for consistency across deployments
|
||||
- python=3.11
|
||||
# Frontend build toolchain (Vite)
|
||||
- nodejs>=22
|
||||
|
||||
# Use pip for Python packages (better compatibility with pypi packages)
|
||||
- pip
|
||||
- pip:
|
||||
# Core Framework
|
||||
- flask>=3.0.0
|
||||
|
||||
# Database
|
||||
- oracledb>=2.0.0
|
||||
- sqlalchemy>=2.0.0
|
||||
|
||||
# Data Processing
|
||||
- pandas>=2.0.0
|
||||
- openpyxl>=3.0.0
|
||||
|
||||
# Cache (Redis)
|
||||
- redis>=5.0.0
|
||||
- hiredis>=2.0.0 # C parser for better performance
|
||||
|
||||
# HTTP Client
|
||||
- requests>=2.28.0
|
||||
|
||||
# Configuration
|
||||
- python-dotenv>=1.0.0
|
||||
|
||||
# WSGI Server (Production)
|
||||
- gunicorn>=21.2.0
|
||||
|
||||
# System Monitoring
|
||||
- psutil>=5.9.0
|
||||
2
frontend/.gitignore
vendored
Normal file
2
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
.DS_Store
|
||||
1105
frontend/package-lock.json
generated
Normal file
1105
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
14
frontend/package.json
Normal file
14
frontend/package.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "mes-dashboard-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host",
|
||||
"build": "vite build",
|
||||
"test": "node --test tests/*.test.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^6.3.0"
|
||||
}
|
||||
}
|
||||
82
frontend/src/core/api.js
Normal file
82
frontend/src/core/api.js
Normal file
@@ -0,0 +1,82 @@
|
||||
const DEFAULT_TIMEOUT = 30000;
|
||||
|
||||
function buildApiError(response, payload) {
|
||||
const message =
|
||||
payload?.error?.message ||
|
||||
(typeof payload?.error === 'string' ? payload.error : null) ||
|
||||
payload?.message ||
|
||||
`HTTP ${response.status}`;
|
||||
|
||||
const error = new Error(message);
|
||||
error.status = response.status;
|
||||
error.payload = payload;
|
||||
error.errorCode = payload?.error?.code || payload?.code || null;
|
||||
error.retryAfterSeconds = Number(
|
||||
payload?.meta?.retry_after_seconds || response.headers.get('Retry-After') || 0
|
||||
) || null;
|
||||
return error;
|
||||
}
|
||||
|
||||
async function fetchJson(url, options = {}) {
|
||||
const timeout = options.timeout ?? DEFAULT_TIMEOUT;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeout);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
throw buildApiError(response, data);
|
||||
}
|
||||
return data;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiGet(url, options = {}) {
|
||||
if (window.MesApi?.get) {
|
||||
return window.MesApi.get(url, options);
|
||||
}
|
||||
return fetchJson(url, { ...options, method: 'GET' });
|
||||
}
|
||||
|
||||
export async function apiPost(url, payload, options = {}) {
|
||||
if (window.MesApi?.post) {
|
||||
return window.MesApi.post(url, payload, options);
|
||||
}
|
||||
return fetchJson(url, {
|
||||
...options,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers || {})
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export async function apiUpload(url, formData, options = {}) {
|
||||
return fetchJson(url, {
|
||||
...options,
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
}
|
||||
|
||||
export function ensureMesApiAvailable() {
|
||||
if (window.MesApi) {
|
||||
return window.MesApi;
|
||||
}
|
||||
|
||||
const bridge = {
|
||||
get: (url, options) => apiGet(url, options),
|
||||
post: (url, payload, options) => apiPost(url, payload, options)
|
||||
};
|
||||
window.MesApi = bridge;
|
||||
return bridge;
|
||||
}
|
||||
69
frontend/src/core/autocomplete.js
Normal file
69
frontend/src/core/autocomplete.js
Normal file
@@ -0,0 +1,69 @@
|
||||
const DEFAULT_LIMIT = 20;
|
||||
|
||||
const FIELD_MAP = Object.freeze({
|
||||
workorder: 'workorder',
|
||||
lotid: 'lotid',
|
||||
package: 'package',
|
||||
type: 'pj_type'
|
||||
});
|
||||
|
||||
export function debounce(fn, wait = 300) {
|
||||
let timer = null;
|
||||
return (...args) => {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
timer = setTimeout(() => fn(...args), wait);
|
||||
};
|
||||
}
|
||||
|
||||
export function buildWipAutocompleteParams(searchType, query, filters = {}, limit = DEFAULT_LIMIT) {
|
||||
const keyword = (query || '').trim();
|
||||
if (keyword.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const params = {
|
||||
field: FIELD_MAP[searchType] || searchType,
|
||||
q: keyword,
|
||||
limit
|
||||
};
|
||||
|
||||
const filterKeys = ['workorder', 'lotid', 'package', 'type'];
|
||||
filterKeys.forEach((key) => {
|
||||
const value = (filters[key] || '').trim();
|
||||
if (key !== searchType && value) {
|
||||
params[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
export async function fetchWipAutocompleteItems({
|
||||
searchType,
|
||||
query,
|
||||
filters,
|
||||
request,
|
||||
limit = DEFAULT_LIMIT,
|
||||
}) {
|
||||
const params = buildWipAutocompleteParams(searchType, query, filters, limit);
|
||||
if (!params) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const result = await request('/api/wip/meta/search', {
|
||||
params,
|
||||
silent: true,
|
||||
retries: 0,
|
||||
});
|
||||
if (result?.success) {
|
||||
return result?.data?.items || [];
|
||||
}
|
||||
return [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export { FIELD_MAP as WIP_AUTOCOMPLETE_FIELD_MAP };
|
||||
59
frontend/src/core/compute.js
Normal file
59
frontend/src/core/compute.js
Normal file
@@ -0,0 +1,59 @@
|
||||
function round1(value) {
|
||||
const scaled = Number(value) * 10;
|
||||
const sign = Math.sign(scaled) || 1;
|
||||
const abs = Math.abs(scaled);
|
||||
const floor = Math.floor(abs);
|
||||
const diff = abs - floor;
|
||||
const epsilon = 1e-9;
|
||||
|
||||
let rounded;
|
||||
if (diff > 0.5 + epsilon) {
|
||||
rounded = floor + 1;
|
||||
} else if (diff < 0.5 - epsilon) {
|
||||
rounded = floor;
|
||||
} else {
|
||||
// Match Python round(..., 1): banker's rounding (half to even).
|
||||
rounded = floor % 2 === 0 ? floor : floor + 1;
|
||||
}
|
||||
|
||||
return (sign * rounded) / 10;
|
||||
}
|
||||
|
||||
export function calcOuPct(prd, sby, udt, sdt, egt) {
|
||||
const denominator = Number(prd) + Number(sby) + Number(udt) + Number(sdt) + Number(egt);
|
||||
if (denominator <= 0) return 0;
|
||||
return round1((Number(prd) / denominator) * 100);
|
||||
}
|
||||
|
||||
export function calcAvailabilityPct(prd, sby, udt, sdt, egt, nst) {
|
||||
const numerator = Number(prd) + Number(sby) + Number(egt);
|
||||
const denominator = numerator + Number(sdt) + Number(udt) + Number(nst);
|
||||
if (denominator <= 0) return 0;
|
||||
return round1((numerator / denominator) * 100);
|
||||
}
|
||||
|
||||
export function calcStatusPct(value, total) {
|
||||
if (Number(total) <= 0) return 0;
|
||||
return round1((Number(value) / Number(total)) * 100);
|
||||
}
|
||||
|
||||
export function buildResourceKpiFromHours(hours = {}) {
|
||||
const prd = Number(hours.prd_hours || 0);
|
||||
const sby = Number(hours.sby_hours || 0);
|
||||
const udt = Number(hours.udt_hours || 0);
|
||||
const sdt = Number(hours.sdt_hours || 0);
|
||||
const egt = Number(hours.egt_hours || 0);
|
||||
const nst = Number(hours.nst_hours || 0);
|
||||
const total = prd + sby + udt + sdt + egt + nst;
|
||||
|
||||
return {
|
||||
ou_pct: calcOuPct(prd, sby, udt, sdt, egt),
|
||||
availability_pct: calcAvailabilityPct(prd, sby, udt, sdt, egt, nst),
|
||||
prd_pct: calcStatusPct(prd, total),
|
||||
sby_pct: calcStatusPct(sby, total),
|
||||
udt_pct: calcStatusPct(udt, total),
|
||||
sdt_pct: calcStatusPct(sdt, total),
|
||||
egt_pct: calcStatusPct(egt, total),
|
||||
nst_pct: calcStatusPct(nst, total)
|
||||
};
|
||||
}
|
||||
25
frontend/src/core/field-contracts.js
Normal file
25
frontend/src/core/field-contracts.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import rawContracts from '../../../shared/field_contracts.json';
|
||||
|
||||
const contracts = rawContracts || {};
|
||||
|
||||
export function getPageContract(pageKey, sectionKey) {
|
||||
const page = contracts[pageKey] || {};
|
||||
const section = page[sectionKey] || [];
|
||||
return Array.isArray(section) ? section : [];
|
||||
}
|
||||
|
||||
export function getFieldContractByApiKey(pageKey, sectionKey, apiKey) {
|
||||
return getPageContract(pageKey, sectionKey).find((field) => field.api_key === apiKey) || null;
|
||||
}
|
||||
|
||||
export function getUiHeaders(pageKey, sectionKey) {
|
||||
return getPageContract(pageKey, sectionKey).map((field) => field.ui_label || field.api_key);
|
||||
}
|
||||
|
||||
export function getExportHeaders(pageKey) {
|
||||
return getPageContract(pageKey, 'export').map((field) => field.export_header || field.ui_label || field.api_key);
|
||||
}
|
||||
|
||||
export function getContractRegistry() {
|
||||
return contracts;
|
||||
}
|
||||
44
frontend/src/core/table-tree.js
Normal file
44
frontend/src/core/table-tree.js
Normal file
@@ -0,0 +1,44 @@
|
||||
export function groupBy(items, keySelector) {
|
||||
return items.reduce((acc, item) => {
|
||||
const key = keySelector(item);
|
||||
if (!acc[key]) {
|
||||
acc[key] = [];
|
||||
}
|
||||
acc[key].push(item);
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
export function sortBy(items, keySelector, direction = 'asc') {
|
||||
const sign = direction === 'desc' ? -1 : 1;
|
||||
return [...items].sort((left, right) => {
|
||||
const a = keySelector(left);
|
||||
const b = keySelector(right);
|
||||
if (a === b) return 0;
|
||||
return a > b ? sign : -sign;
|
||||
});
|
||||
}
|
||||
|
||||
export function toggleTreeState(state, key) {
|
||||
state[key] = !state[key];
|
||||
return state[key];
|
||||
}
|
||||
|
||||
export function setTreeStateBulk(state, keys, expanded) {
|
||||
keys.forEach((key) => {
|
||||
state[key] = expanded;
|
||||
});
|
||||
}
|
||||
|
||||
export function escapeHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
export function safeText(value, fallback = '') {
|
||||
return value === null || value === undefined ? fallback : String(value);
|
||||
}
|
||||
624
frontend/src/excel-query/main.js
Normal file
624
frontend/src/excel-query/main.js
Normal file
@@ -0,0 +1,624 @@
|
||||
import { ensureMesApiAvailable } from '../core/api.js';
|
||||
import { getPageContract } from '../core/field-contracts.js';
|
||||
import { buildResourceKpiFromHours } from '../core/compute.js';
|
||||
import { groupBy, sortBy, toggleTreeState, setTreeStateBulk, escapeHtml, safeText } from '../core/table-tree.js';
|
||||
|
||||
ensureMesApiAvailable();
|
||||
window.__MES_FRONTEND_CORE__ = { buildResourceKpiFromHours, groupBy, sortBy, toggleTreeState, setTreeStateBulk, escapeHtml, safeText };
|
||||
window.__FIELD_CONTRACTS__ = window.__FIELD_CONTRACTS__ || {};
|
||||
window.__FIELD_CONTRACTS__['excel_query:result_table'] = getPageContract('excel_query', 'result_table');
|
||||
|
||||
|
||||
// State
|
||||
let excelColumns = [];
|
||||
let excelColumnTypes = {}; // { columnName: { detected_type, type_label } }
|
||||
let searchValues = [];
|
||||
let tableColumns = []; // Array of column names (for backward compat)
|
||||
let tableColumnsMeta = []; // Array of { name, data_type, is_date, is_number }
|
||||
let tableMetadata = null; // Full table metadata including time_field, row_count
|
||||
let queryResult = null;
|
||||
|
||||
// Step 1: Upload Excel
|
||||
async function uploadExcel() {
|
||||
const fileInput = document.getElementById('excelFile');
|
||||
const file = fileInput.files[0];
|
||||
|
||||
if (!file) {
|
||||
alert('請選擇檔案');
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
document.getElementById('uploadInfo').innerHTML = '<div class="loading"><div class="loading-spinner"></div><br>上傳中...</div>';
|
||||
|
||||
try {
|
||||
// Note: File upload uses native fetch since MesApi doesn't support FormData
|
||||
const response = await fetch('/api/excel-query/upload', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
document.getElementById('uploadInfo').innerHTML = `<div class="error">${escapeHtml(data.error)}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
excelColumns = data.columns;
|
||||
document.getElementById('uploadInfo').innerHTML = `
|
||||
<div class="info-box">
|
||||
檔案上傳成功!共 ${data.total_rows} 行,${data.columns.length} 欄
|
||||
</div>
|
||||
`;
|
||||
|
||||
renderPreviewTable(data.columns, data.preview);
|
||||
|
||||
const select = document.getElementById('excelColumn');
|
||||
select.innerHTML = '<option value="">-- 請選擇 --</option>';
|
||||
excelColumns.forEach(col => {
|
||||
select.innerHTML += `<option value="${escapeHtml(col)}">${escapeHtml(col)}</option>`;
|
||||
});
|
||||
|
||||
document.getElementById('step2').classList.remove('disabled');
|
||||
loadTables();
|
||||
|
||||
} catch (error) {
|
||||
document.getElementById('uploadInfo').innerHTML = `<div class="error">上傳失敗: ${escapeHtml(error.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderPreviewTable(columns, data) {
|
||||
if (!data || data.length === 0) return;
|
||||
|
||||
let html = '<table class="preview-table"><thead><tr>';
|
||||
columns.forEach(col => {
|
||||
html += `<th>${escapeHtml(col)}</th>`;
|
||||
});
|
||||
html += '</tr></thead><tbody>';
|
||||
|
||||
data.forEach(row => {
|
||||
html += '<tr>';
|
||||
columns.forEach(col => {
|
||||
const val = row[col] !== null && row[col] !== undefined ? row[col] : '';
|
||||
const textVal = safeText(val);
|
||||
const escaped = escapeHtml(textVal);
|
||||
html += `<td title="${escaped}">${escaped}</td>`;
|
||||
});
|
||||
html += '</tr>';
|
||||
});
|
||||
html += '</tbody></table>';
|
||||
|
||||
document.getElementById('previewTable').innerHTML = html;
|
||||
}
|
||||
|
||||
// Step 2: Load column values
|
||||
async function loadColumnValues() {
|
||||
const column = document.getElementById('excelColumn').value;
|
||||
if (!column) {
|
||||
searchValues = [];
|
||||
document.getElementById('columnInfo').innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('columnInfo').innerHTML = '<div class="loading"><div class="loading-spinner"></div><br>讀取中...</div>';
|
||||
|
||||
try {
|
||||
// Get column values
|
||||
const data = await MesApi.post('/api/excel-query/column-values', { column_name: column });
|
||||
|
||||
if (data.error) {
|
||||
document.getElementById('columnInfo').innerHTML = `<div class="error">${escapeHtml(data.error)}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
searchValues = data.values;
|
||||
|
||||
// Get column type detection
|
||||
try {
|
||||
const typeData = await MesApi.post('/api/excel-query/column-type', { column_name: column });
|
||||
if (!typeData.error) {
|
||||
excelColumnTypes[column] = typeData;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Could not detect column type:', e);
|
||||
}
|
||||
|
||||
// Build info display
|
||||
const typeInfo = excelColumnTypes[column];
|
||||
const typeBadge = typeInfo ? `<span class="type-badge ${typeInfo.detected_type}">${typeInfo.type_label}</span>` : '';
|
||||
const warningClass = data.count > 1000 ? ' warning' : '';
|
||||
|
||||
document.getElementById('columnInfo').innerHTML = `
|
||||
<div class="info-box${warningClass}">
|
||||
共 ${data.count} 個不重複值 ${typeBadge}
|
||||
${data.count > 1000 ? '(將分批查詢,每批 1000 筆)' : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById('step3').classList.remove('disabled');
|
||||
|
||||
} catch (error) {
|
||||
document.getElementById('columnInfo').innerHTML = `<div class="error">讀取失敗: ${escapeHtml(error.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Load available tables
|
||||
async function loadTables() {
|
||||
try {
|
||||
const data = await MesApi.get('/api/excel-query/tables', { silent: true });
|
||||
|
||||
const select = document.getElementById('targetTable');
|
||||
select.innerHTML = '<option value="">-- 請選擇 --</option>';
|
||||
|
||||
data.tables.forEach(table => {
|
||||
select.innerHTML += `<option value="${escapeHtml(table.name)}">${escapeHtml(table.display_name)} (${escapeHtml(table.name)})</option>`;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load tables:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Load table columns (using new table-metadata endpoint)
|
||||
async function loadTableColumns() {
|
||||
const tableName = document.getElementById('targetTable').value;
|
||||
if (!tableName) {
|
||||
tableColumns = [];
|
||||
tableColumnsMeta = [];
|
||||
tableMetadata = null;
|
||||
document.getElementById('tableInfo').innerHTML = '';
|
||||
document.getElementById('dateRangeSection').style.display = 'none';
|
||||
document.getElementById('performanceWarning').style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('tableInfo').innerHTML = '<div class="loading"><div class="loading-spinner"></div><br>讀取欄位...</div>';
|
||||
|
||||
try {
|
||||
const data = await MesApi.post('/api/excel-query/table-metadata', { table_name: tableName });
|
||||
|
||||
if (data.error) {
|
||||
document.getElementById('tableInfo').innerHTML = `<div class="error">${escapeHtml(data.error)}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
tableColumnsMeta = data.columns || [];
|
||||
tableColumns = tableColumnsMeta.map(c => c.name);
|
||||
tableMetadata = data;
|
||||
|
||||
// Show table info
|
||||
let infoHtml = `共 ${tableColumns.length} 個欄位`;
|
||||
if (data.row_count) {
|
||||
infoHtml += ` | 約 ${data.row_count.toLocaleString()} 筆`;
|
||||
}
|
||||
if (data.time_field) {
|
||||
infoHtml += ` | 時間欄位: ${escapeHtml(data.time_field)}`;
|
||||
}
|
||||
document.getElementById('tableInfo').innerHTML = `<div class="info-box">${infoHtml}</div>`;
|
||||
|
||||
// Populate search column dropdown with type badges
|
||||
const searchSelect = document.getElementById('searchColumn');
|
||||
searchSelect.innerHTML = '<option value="">-- 請選擇 --</option>';
|
||||
tableColumnsMeta.forEach(col => {
|
||||
const typeBadge = getTypeBadgeHtml(col.data_type);
|
||||
searchSelect.innerHTML += `<option value="${escapeHtml(col.name)}" data-type="${escapeHtml(col.data_type || '')}">${escapeHtml(col.name)} ${typeBadge}</option>`;
|
||||
});
|
||||
|
||||
// Populate return columns with type badges
|
||||
const container = document.getElementById('returnColumns');
|
||||
container.innerHTML = '';
|
||||
tableColumnsMeta.forEach(col => {
|
||||
const typeBadge = getTypeBadgeHtml(col.data_type);
|
||||
container.innerHTML += `
|
||||
<label class="checkbox-item">
|
||||
<input type="checkbox" value="${escapeHtml(col.name)}" checked>
|
||||
${escapeHtml(col.name)} ${typeBadge}
|
||||
</label>
|
||||
`;
|
||||
});
|
||||
|
||||
// Setup date range section
|
||||
setupDateRangeSection(data);
|
||||
|
||||
// Show performance warning if applicable
|
||||
if (data.performance_warning) {
|
||||
document.getElementById('performanceWarning').textContent = data.performance_warning;
|
||||
document.getElementById('performanceWarning').style.display = 'block';
|
||||
} else {
|
||||
document.getElementById('performanceWarning').style.display = 'none';
|
||||
}
|
||||
|
||||
document.getElementById('step4').classList.remove('disabled');
|
||||
document.getElementById('step5').classList.remove('disabled');
|
||||
|
||||
} catch (error) {
|
||||
document.getElementById('tableInfo').innerHTML = `<div class="error">讀取失敗: ${escapeHtml(error.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: Get type badge HTML
|
||||
function getTypeBadgeHtml(dataType) {
|
||||
if (!dataType || dataType === 'UNKNOWN') return '';
|
||||
|
||||
const typeMap = {
|
||||
'VARCHAR2': { class: 'text', label: '文字' },
|
||||
'CHAR': { class: 'text', label: '文字' },
|
||||
'NVARCHAR2': { class: 'text', label: '文字' },
|
||||
'CLOB': { class: 'text', label: '文字' },
|
||||
'NUMBER': { class: 'number', label: '數值' },
|
||||
'FLOAT': { class: 'number', label: '數值' },
|
||||
'INTEGER': { class: 'number', label: '數值' },
|
||||
'DATE': { class: 'date', label: '日期' },
|
||||
'TIMESTAMP': { class: 'datetime', label: '日期時間' },
|
||||
};
|
||||
|
||||
// Find matching type
|
||||
for (const [key, val] of Object.entries(typeMap)) {
|
||||
if (dataType.toUpperCase().includes(key)) {
|
||||
return `<span class="type-badge ${val.class}">${val.label}</span>`;
|
||||
}
|
||||
}
|
||||
return `<span class="type-badge unknown">${escapeHtml(dataType)}</span>`;
|
||||
}
|
||||
|
||||
// Setup date range section based on table metadata
|
||||
function setupDateRangeSection(metadata) {
|
||||
const section = document.getElementById('dateRangeSection');
|
||||
const dateColumnSelect = document.getElementById('dateColumn');
|
||||
|
||||
// Find date/timestamp columns
|
||||
const dateColumns = tableColumnsMeta.filter(c => c.is_date);
|
||||
|
||||
if (dateColumns.length === 0 && !metadata.time_field) {
|
||||
section.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
section.style.display = 'block';
|
||||
dateColumnSelect.innerHTML = '<option value="">-- 不限時間 --</option>';
|
||||
|
||||
// Add configured time_field first if available
|
||||
if (metadata.time_field) {
|
||||
dateColumnSelect.innerHTML += `<option value="${escapeHtml(metadata.time_field)}" selected>${escapeHtml(metadata.time_field)} (預設)</option>`;
|
||||
}
|
||||
|
||||
// Add other date columns
|
||||
dateColumns.forEach(col => {
|
||||
if (col.name !== metadata.time_field) {
|
||||
dateColumnSelect.innerHTML += `<option value="${escapeHtml(col.name)}">${escapeHtml(col.name)}</option>`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Set default date range (last 90 days)
|
||||
function setDefaultDateRange() {
|
||||
const today = new Date();
|
||||
const past = new Date();
|
||||
past.setDate(today.getDate() - 90);
|
||||
|
||||
document.getElementById('dateFrom').value = past.toISOString().split('T')[0];
|
||||
document.getElementById('dateTo').value = today.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
// Toggle advanced panel
|
||||
function toggleAdvancedPanel() {
|
||||
const panel = document.getElementById('advancedPanel');
|
||||
panel.classList.toggle('collapsed');
|
||||
}
|
||||
|
||||
// Handle query type change
|
||||
function onQueryTypeChange() {
|
||||
const queryType = document.getElementById('queryType').value;
|
||||
const warningDiv = document.getElementById('performanceWarning');
|
||||
|
||||
// Show warning for LIKE contains on large tables
|
||||
if (queryType === 'like_contains' && tableMetadata && tableMetadata.row_count > 10000000) {
|
||||
warningDiv.textContent = '此資料表超過 1000 萬筆,包含查詢可能較慢,建議配合日期範圍縮小查詢範圍';
|
||||
warningDiv.style.display = 'block';
|
||||
} else if (tableMetadata && tableMetadata.performance_warning && queryType === 'like_contains') {
|
||||
warningDiv.textContent = tableMetadata.performance_warning;
|
||||
warningDiv.style.display = 'block';
|
||||
} else {
|
||||
warningDiv.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Check for type mismatch between Excel column and Oracle column
|
||||
function checkTypeMismatch() {
|
||||
const warningDiv = document.getElementById('typeMismatchWarning');
|
||||
const searchCol = document.getElementById('searchColumn').value;
|
||||
const excelCol = document.getElementById('excelColumn').value;
|
||||
|
||||
if (!searchCol || !excelCol) {
|
||||
warningDiv.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Get types
|
||||
const oracleMeta = tableColumnsMeta.find(c => c.name === searchCol);
|
||||
const excelType = excelColumnTypes[excelCol];
|
||||
|
||||
if (!oracleMeta || !excelType) {
|
||||
warningDiv.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for potential mismatches
|
||||
let warning = '';
|
||||
if (oracleMeta.is_number && excelType.detected_type === 'text') {
|
||||
warning = '欄位類型可能不相符:Excel 欄位為文字,Oracle 欄位為數值';
|
||||
} else if (oracleMeta.is_date && excelType.detected_type !== 'date' && excelType.detected_type !== 'datetime') {
|
||||
warning = '欄位類型可能不相符:Oracle 欄位為日期類型';
|
||||
}
|
||||
|
||||
if (warning) {
|
||||
warningDiv.innerHTML = `<div class="type-mismatch-warning">${warning}</div>`;
|
||||
} else {
|
||||
warningDiv.innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
function selectAllColumns() {
|
||||
document.querySelectorAll('#returnColumns input[type="checkbox"]').forEach(cb => cb.checked = true);
|
||||
}
|
||||
|
||||
function deselectAllColumns() {
|
||||
document.querySelectorAll('#returnColumns input[type="checkbox"]').forEach(cb => cb.checked = false);
|
||||
}
|
||||
|
||||
function getSelectedReturnColumns() {
|
||||
const checkboxes = document.querySelectorAll('#returnColumns input[type="checkbox"]:checked');
|
||||
return Array.from(checkboxes).map(cb => cb.value);
|
||||
}
|
||||
|
||||
function getQueryParams() {
|
||||
const params = {
|
||||
table_name: document.getElementById('targetTable').value,
|
||||
search_column: document.getElementById('searchColumn').value,
|
||||
return_columns: getSelectedReturnColumns(),
|
||||
search_values: searchValues,
|
||||
query_type: document.getElementById('queryType').value
|
||||
};
|
||||
|
||||
// Add date range if specified
|
||||
const dateColumn = document.getElementById('dateColumn').value;
|
||||
const dateFrom = document.getElementById('dateFrom').value;
|
||||
const dateTo = document.getElementById('dateTo').value;
|
||||
|
||||
if (dateColumn) {
|
||||
params.date_column = dateColumn;
|
||||
if (dateFrom) params.date_from = dateFrom;
|
||||
if (dateTo) params.date_to = dateTo;
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
function validateQuery() {
|
||||
const params = getQueryParams();
|
||||
|
||||
if (!params.table_name) {
|
||||
alert('請選擇資料表');
|
||||
return false;
|
||||
}
|
||||
if (!params.search_column) {
|
||||
alert('請選擇查詢欄位');
|
||||
return false;
|
||||
}
|
||||
if (params.return_columns.length === 0) {
|
||||
alert('請至少選擇一個回傳欄位');
|
||||
return false;
|
||||
}
|
||||
if (params.search_values.length === 0) {
|
||||
alert('無查詢值,請先選擇 Excel 欄位');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate LIKE keyword limit
|
||||
if (params.query_type.startsWith('like_') && params.search_values.length > 100) {
|
||||
alert('LIKE 查詢最多支援 100 個關鍵字,目前有 ' + params.search_values.length + ' 個');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate date range
|
||||
if (params.date_from && params.date_to) {
|
||||
const from = new Date(params.date_from);
|
||||
const to = new Date(params.date_to);
|
||||
if (from > to) {
|
||||
alert('起始日期不可晚於結束日期');
|
||||
document.getElementById('dateRangeError').textContent = '起始日期不可晚於結束日期';
|
||||
document.getElementById('dateRangeError').style.display = 'block';
|
||||
return false;
|
||||
}
|
||||
const daysDiff = (to - from) / (1000 * 60 * 60 * 24);
|
||||
if (daysDiff > 365) {
|
||||
alert('日期範圍不可超過 365 天');
|
||||
document.getElementById('dateRangeError').textContent = '日期範圍不可超過 365 天';
|
||||
document.getElementById('dateRangeError').style.display = 'block';
|
||||
return false;
|
||||
}
|
||||
document.getElementById('dateRangeError').style.display = 'none';
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Step 5: Execute query
|
||||
async function executeQuery() {
|
||||
if (!validateQuery()) return;
|
||||
|
||||
const params = getQueryParams();
|
||||
const isAdvanced = params.query_type !== 'in' || params.date_column;
|
||||
const batchCount = params.query_type === 'in' ? Math.ceil(params.search_values.length / 1000) : 1;
|
||||
|
||||
// Build loading message
|
||||
let loadingMsg = `查詢中... (${params.search_values.length} 筆`;
|
||||
if (params.query_type !== 'in') {
|
||||
const typeLabels = {
|
||||
'like_contains': '包含查詢',
|
||||
'like_prefix': '前綴查詢',
|
||||
'like_suffix': '後綴查詢'
|
||||
};
|
||||
loadingMsg += `,${typeLabels[params.query_type] || params.query_type}`;
|
||||
} else if (batchCount > 1) {
|
||||
loadingMsg += `,${batchCount} 批次`;
|
||||
}
|
||||
if (params.date_from || params.date_to) {
|
||||
loadingMsg += `,日期篩選`;
|
||||
}
|
||||
loadingMsg += ')';
|
||||
|
||||
document.getElementById('executeInfo').innerHTML = `
|
||||
<div class="loading">
|
||||
<div class="loading-spinner"></div><br>
|
||||
${loadingMsg}
|
||||
</div>
|
||||
`;
|
||||
document.getElementById('resultSection').classList.remove('active');
|
||||
|
||||
try {
|
||||
// Use advanced endpoint if using advanced features
|
||||
const endpoint = isAdvanced ? '/api/excel-query/execute-advanced' : '/api/excel-query/execute';
|
||||
const data = await MesApi.post(endpoint, params);
|
||||
|
||||
if (data.error) {
|
||||
document.getElementById('executeInfo').innerHTML = `<div class="error">${escapeHtml(data.error)}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
queryResult = data;
|
||||
|
||||
// Build result message
|
||||
let resultMsg = `查詢完成!搜尋 ${data.search_count} 筆,找到 ${data.row_count} 筆結果`;
|
||||
if (data.query_type && data.query_type !== 'in') {
|
||||
resultMsg += ` (${data.query_type})`;
|
||||
}
|
||||
|
||||
document.getElementById('executeInfo').innerHTML = `
|
||||
<div class="info-box">${resultMsg}</div>
|
||||
`;
|
||||
|
||||
renderResult(data);
|
||||
|
||||
} catch (error) {
|
||||
document.getElementById('executeInfo').innerHTML = `<div class="error">查詢失敗: ${escapeHtml(error.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderResult(data) {
|
||||
const section = document.getElementById('resultSection');
|
||||
const statsDiv = document.getElementById('resultStats');
|
||||
const tableDiv = document.getElementById('resultTable');
|
||||
|
||||
statsDiv.innerHTML = `
|
||||
<span>搜尋值: ${data.search_count}</span>
|
||||
<span>結果: ${data.row_count} 筆</span>
|
||||
${data.batch_count > 1 ? `<span>批次: ${data.batch_count}</span>` : ''}
|
||||
`;
|
||||
|
||||
if (data.data.length === 0) {
|
||||
tableDiv.innerHTML = '<div style="padding: 40px; text-align: center; color: #999;">查無資料</div>';
|
||||
} else {
|
||||
let html = '<table><thead><tr>';
|
||||
data.columns.forEach(col => {
|
||||
html += `<th>${escapeHtml(col)}</th>`;
|
||||
});
|
||||
html += '</tr></thead><tbody>';
|
||||
|
||||
const previewData = data.data.slice(0, 1000);
|
||||
previewData.forEach(row => {
|
||||
html += '<tr>';
|
||||
data.columns.forEach(col => {
|
||||
if (row[col] === null || row[col] === undefined) {
|
||||
html += '<td><i style="color:#999">NULL</i></td>';
|
||||
} else {
|
||||
html += `<td>${escapeHtml(safeText(row[col]))}</td>`;
|
||||
}
|
||||
});
|
||||
html += '</tr>';
|
||||
});
|
||||
html += '</tbody></table>';
|
||||
|
||||
if (data.data.length > 1000) {
|
||||
html += `<div style="padding: 15px; text-align: center; color: #666; background: #f8f9fa;">
|
||||
顯示前 1000 筆,完整資料請匯出 CSV
|
||||
</div>`;
|
||||
}
|
||||
|
||||
tableDiv.innerHTML = html;
|
||||
}
|
||||
|
||||
section.classList.add('active');
|
||||
section.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
|
||||
// Export CSV
|
||||
async function exportCSV() {
|
||||
if (!validateQuery()) return;
|
||||
|
||||
const params = getQueryParams();
|
||||
const batchCount = Math.ceil(params.search_values.length / 1000);
|
||||
|
||||
document.getElementById('executeInfo').innerHTML = `
|
||||
<div class="loading">
|
||||
<div class="loading-spinner"></div><br>
|
||||
匯出中... (${params.search_values.length} 筆,${batchCount} 批次)
|
||||
</div>
|
||||
`;
|
||||
|
||||
try {
|
||||
// Note: CSV export uses native fetch for blob response
|
||||
const response = await fetch('/api/excel-query/export-csv', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(params)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
document.getElementById('executeInfo').innerHTML = `<div class="error">${escapeHtml(data.error || '匯出失敗')}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'query_result.csv';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
document.getElementById('executeInfo').innerHTML = `
|
||||
<div class="info-box">CSV 匯出完成!</div>
|
||||
`;
|
||||
|
||||
} catch (error) {
|
||||
document.getElementById('executeInfo').innerHTML = `<div class="error">匯出失敗: ${escapeHtml(error.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Object.assign(window, {
|
||||
uploadExcel,
|
||||
renderPreviewTable,
|
||||
loadColumnValues,
|
||||
loadTables,
|
||||
loadTableColumns,
|
||||
getTypeBadgeHtml,
|
||||
setupDateRangeSection,
|
||||
setDefaultDateRange,
|
||||
toggleAdvancedPanel,
|
||||
onQueryTypeChange,
|
||||
checkTypeMismatch,
|
||||
selectAllColumns,
|
||||
deselectAllColumns,
|
||||
getSelectedReturnColumns,
|
||||
getQueryParams,
|
||||
validateQuery,
|
||||
executeQuery,
|
||||
renderResult,
|
||||
exportCSV,
|
||||
});
|
||||
336
frontend/src/hold-detail/main.js
Normal file
336
frontend/src/hold-detail/main.js
Normal file
@@ -0,0 +1,336 @@
|
||||
import { ensureMesApiAvailable } from '../core/api.js';
|
||||
import { escapeHtml, safeText } from '../core/table-tree.js';
|
||||
|
||||
ensureMesApiAvailable();
|
||||
|
||||
(function initHoldDetailPage() {
|
||||
// ============================================================
|
||||
// State
|
||||
// ============================================================
|
||||
const state = {
|
||||
reason: new URLSearchParams(window.location.search).get('reason') || '',
|
||||
summary: null,
|
||||
distribution: null,
|
||||
lots: null,
|
||||
page: 1,
|
||||
perPage: 50,
|
||||
filters: {
|
||||
workcenter: null,
|
||||
package: null,
|
||||
ageRange: null
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Utility
|
||||
// ============================================================
|
||||
function formatNumber(num) {
|
||||
if (num === null || num === undefined || num === '-') return '-';
|
||||
return num.toLocaleString('zh-TW');
|
||||
}
|
||||
|
||||
function jsSingleQuote(value) {
|
||||
return safeText(value, '')
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/'/g, "\\'");
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// API Functions
|
||||
// ============================================================
|
||||
const API_TIMEOUT = 60000;
|
||||
|
||||
async function fetchSummary() {
|
||||
const result = await MesApi.get('/api/wip/hold-detail/summary', {
|
||||
params: { reason: state.reason },
|
||||
timeout: API_TIMEOUT
|
||||
});
|
||||
if (result.success) return result.data;
|
||||
throw new Error(result.error);
|
||||
}
|
||||
|
||||
async function fetchDistribution() {
|
||||
const result = await MesApi.get('/api/wip/hold-detail/distribution', {
|
||||
params: { reason: state.reason },
|
||||
timeout: API_TIMEOUT
|
||||
});
|
||||
if (result.success) return result.data;
|
||||
throw new Error(result.error);
|
||||
}
|
||||
|
||||
async function fetchLots() {
|
||||
const params = {
|
||||
reason: state.reason,
|
||||
page: state.page,
|
||||
per_page: state.perPage
|
||||
};
|
||||
if (state.filters.workcenter) params.workcenter = state.filters.workcenter;
|
||||
if (state.filters.package) params.package = state.filters.package;
|
||||
if (state.filters.ageRange) params.age_range = state.filters.ageRange;
|
||||
|
||||
const result = await MesApi.get('/api/wip/hold-detail/lots', {
|
||||
params,
|
||||
timeout: API_TIMEOUT
|
||||
});
|
||||
if (result.success) return result.data;
|
||||
throw new Error(result.error);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Render Functions
|
||||
// ============================================================
|
||||
function renderSummary(data) {
|
||||
document.getElementById('totalLots').textContent = formatNumber(data.totalLots);
|
||||
document.getElementById('totalQty').textContent = formatNumber(data.totalQty);
|
||||
document.getElementById('avgAge').textContent = data.avgAge ? `${data.avgAge}天` : '-';
|
||||
document.getElementById('maxAge').textContent = data.maxAge ? `${data.maxAge}天` : '-';
|
||||
document.getElementById('workcenterCount').textContent = formatNumber(data.workcenterCount);
|
||||
}
|
||||
|
||||
function renderDistribution(data) {
|
||||
// Age distribution
|
||||
const ageMap = {};
|
||||
data.byAge.forEach(item => { ageMap[item.range] = item; });
|
||||
|
||||
const age01 = ageMap['0-1'] || { lots: 0, qty: 0, percentage: 0 };
|
||||
const age13 = ageMap['1-3'] || { lots: 0, qty: 0, percentage: 0 };
|
||||
const age37 = ageMap['3-7'] || { lots: 0, qty: 0, percentage: 0 };
|
||||
const age7 = ageMap['7+'] || { lots: 0, qty: 0, percentage: 0 };
|
||||
|
||||
document.getElementById('age01Lots').textContent = formatNumber(age01.lots);
|
||||
document.getElementById('age01Qty').textContent = formatNumber(age01.qty);
|
||||
document.getElementById('age01Pct').textContent = `${age01.percentage}%`;
|
||||
|
||||
document.getElementById('age13Lots').textContent = formatNumber(age13.lots);
|
||||
document.getElementById('age13Qty').textContent = formatNumber(age13.qty);
|
||||
document.getElementById('age13Pct').textContent = `${age13.percentage}%`;
|
||||
|
||||
document.getElementById('age37Lots').textContent = formatNumber(age37.lots);
|
||||
document.getElementById('age37Qty').textContent = formatNumber(age37.qty);
|
||||
document.getElementById('age37Pct').textContent = `${age37.percentage}%`;
|
||||
|
||||
document.getElementById('age7Lots').textContent = formatNumber(age7.lots);
|
||||
document.getElementById('age7Qty').textContent = formatNumber(age7.qty);
|
||||
document.getElementById('age7Pct').textContent = `${age7.percentage}%`;
|
||||
|
||||
// Workcenter table
|
||||
const wcBody = document.getElementById('workcenterBody');
|
||||
if (data.byWorkcenter.length === 0) {
|
||||
wcBody.innerHTML = '<tr><td colspan="4" class="placeholder">No data</td></tr>';
|
||||
} else {
|
||||
wcBody.innerHTML = data.byWorkcenter.map(item => `
|
||||
<tr data-workcenter="${escapeHtml(safeText(item.name))}" onclick="toggleWorkcenterFilter('${jsSingleQuote(item.name)}')" class="${state.filters.workcenter === item.name ? 'active' : ''}">
|
||||
<td>${escapeHtml(safeText(item.name))}</td>
|
||||
<td>${escapeHtml(formatNumber(item.lots))}</td>
|
||||
<td>${escapeHtml(formatNumber(item.qty))}</td>
|
||||
<td>${escapeHtml(safeText(item.percentage, 0))}%</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// Package table
|
||||
const pkgBody = document.getElementById('packageBody');
|
||||
if (data.byPackage.length === 0) {
|
||||
pkgBody.innerHTML = '<tr><td colspan="4" class="placeholder">No data</td></tr>';
|
||||
} else {
|
||||
pkgBody.innerHTML = data.byPackage.map(item => `
|
||||
<tr data-package="${escapeHtml(safeText(item.name))}" onclick="togglePackageFilter('${jsSingleQuote(item.name)}')" class="${state.filters.package === item.name ? 'active' : ''}">
|
||||
<td>${escapeHtml(safeText(item.name))}</td>
|
||||
<td>${escapeHtml(formatNumber(item.lots))}</td>
|
||||
<td>${escapeHtml(formatNumber(item.qty))}</td>
|
||||
<td>${escapeHtml(safeText(item.percentage, 0))}%</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
}
|
||||
|
||||
function renderLots(data) {
|
||||
const tbody = document.getElementById('lotBody');
|
||||
const lots = data.lots;
|
||||
|
||||
if (lots.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="10" class="placeholder">No data</td></tr>';
|
||||
document.getElementById('tableInfo').textContent = 'No data';
|
||||
document.getElementById('pagination').style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = lots.map(lot => `
|
||||
<tr>
|
||||
<td>${escapeHtml(safeText(lot.lotId))}</td>
|
||||
<td>${escapeHtml(safeText(lot.workorder))}</td>
|
||||
<td>${escapeHtml(formatNumber(lot.qty))}</td>
|
||||
<td>${escapeHtml(safeText(lot.package))}</td>
|
||||
<td>${escapeHtml(safeText(lot.workcenter))}</td>
|
||||
<td>${escapeHtml(safeText(lot.spec))}</td>
|
||||
<td>${escapeHtml(safeText(lot.age))}天</td>
|
||||
<td>${escapeHtml(safeText(lot.holdBy))}</td>
|
||||
<td>${escapeHtml(safeText(lot.dept))}</td>
|
||||
<td>${escapeHtml(safeText(lot.holdComment))}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
// Update pagination
|
||||
const pg = data.pagination;
|
||||
const start = (pg.page - 1) * pg.perPage + 1;
|
||||
const end = Math.min(pg.page * pg.perPage, pg.total);
|
||||
document.getElementById('tableInfo').textContent = `顯示 ${start} - ${end} / ${formatNumber(pg.total)}`;
|
||||
|
||||
if (pg.totalPages > 1) {
|
||||
document.getElementById('pagination').style.display = 'flex';
|
||||
document.getElementById('pageInfo').textContent = `Page ${pg.page} / ${pg.totalPages}`;
|
||||
document.getElementById('btnPrev').disabled = pg.page <= 1;
|
||||
document.getElementById('btnNext').disabled = pg.page >= pg.totalPages;
|
||||
} else {
|
||||
document.getElementById('pagination').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function updateFilterIndicator() {
|
||||
const indicator = document.getElementById('filterIndicator');
|
||||
const text = document.getElementById('filterText');
|
||||
const parts = [];
|
||||
|
||||
if (state.filters.workcenter) parts.push(`Workcenter=${state.filters.workcenter}`);
|
||||
if (state.filters.package) parts.push(`Package=${state.filters.package}`);
|
||||
if (state.filters.ageRange) parts.push(`Age=${state.filters.ageRange}天`);
|
||||
|
||||
if (parts.length > 0) {
|
||||
text.textContent = '篩選: ' + parts.join(', ');
|
||||
indicator.style.display = 'flex';
|
||||
} else {
|
||||
indicator.style.display = 'none';
|
||||
}
|
||||
|
||||
// Update active states
|
||||
document.querySelectorAll('.age-card').forEach(card => {
|
||||
card.classList.toggle('active', card.dataset.range === state.filters.ageRange);
|
||||
});
|
||||
document.querySelectorAll('#workcenterBody tr').forEach(row => {
|
||||
row.classList.toggle('active', row.dataset.workcenter === state.filters.workcenter);
|
||||
});
|
||||
document.querySelectorAll('#packageBody tr').forEach(row => {
|
||||
row.classList.toggle('active', row.dataset.package === state.filters.package);
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Filter Functions
|
||||
// ============================================================
|
||||
function toggleAgeFilter(range) {
|
||||
state.filters.ageRange = state.filters.ageRange === range ? null : range;
|
||||
state.page = 1;
|
||||
updateFilterIndicator();
|
||||
loadLots();
|
||||
}
|
||||
|
||||
function toggleWorkcenterFilter(wc) {
|
||||
state.filters.workcenter = state.filters.workcenter === wc ? null : wc;
|
||||
state.page = 1;
|
||||
updateFilterIndicator();
|
||||
loadLots();
|
||||
}
|
||||
|
||||
function togglePackageFilter(pkg) {
|
||||
state.filters.package = state.filters.package === pkg ? null : pkg;
|
||||
state.page = 1;
|
||||
updateFilterIndicator();
|
||||
loadLots();
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
state.filters = { workcenter: null, package: null, ageRange: null };
|
||||
state.page = 1;
|
||||
updateFilterIndicator();
|
||||
loadLots();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Pagination
|
||||
// ============================================================
|
||||
function prevPage() {
|
||||
if (state.page > 1) {
|
||||
state.page--;
|
||||
loadLots();
|
||||
}
|
||||
}
|
||||
|
||||
function nextPage() {
|
||||
if (state.lots && state.page < state.lots.pagination.totalPages) {
|
||||
state.page++;
|
||||
loadLots();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Data Loading
|
||||
// ============================================================
|
||||
async function loadLots() {
|
||||
document.getElementById('lotBody').innerHTML = '<tr><td colspan="10" class="placeholder">Loading...</td></tr>';
|
||||
document.getElementById('refreshIndicator').classList.add('active');
|
||||
|
||||
try {
|
||||
state.lots = await fetchLots();
|
||||
renderLots(state.lots);
|
||||
} catch (error) {
|
||||
console.error('Load lots failed:', error);
|
||||
document.getElementById('lotBody').innerHTML = '<tr><td colspan="10" class="placeholder">Error loading data</td></tr>';
|
||||
} finally {
|
||||
document.getElementById('refreshIndicator').classList.remove('active');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAllData(showOverlay = true) {
|
||||
if (showOverlay) {
|
||||
document.getElementById('loadingOverlay').style.display = 'flex';
|
||||
}
|
||||
document.getElementById('refreshIndicator').classList.add('active');
|
||||
|
||||
try {
|
||||
const [summary, distribution, lots] = await Promise.all([
|
||||
fetchSummary(),
|
||||
fetchDistribution(),
|
||||
fetchLots()
|
||||
]);
|
||||
|
||||
state.summary = summary;
|
||||
state.distribution = distribution;
|
||||
state.lots = lots;
|
||||
|
||||
renderSummary(summary);
|
||||
renderDistribution(distribution);
|
||||
renderLots(lots);
|
||||
updateFilterIndicator();
|
||||
|
||||
document.getElementById('lastUpdate').textContent = `Last Update: ${new Date().toLocaleString('zh-TW')}`;
|
||||
} catch (error) {
|
||||
console.error('Load data failed:', error);
|
||||
} finally {
|
||||
document.getElementById('loadingOverlay').style.display = 'none';
|
||||
document.getElementById('refreshIndicator').classList.remove('active');
|
||||
}
|
||||
}
|
||||
|
||||
function manualRefresh() {
|
||||
loadAllData(false);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Initialize
|
||||
// ============================================================
|
||||
window.onload = function() {
|
||||
loadAllData(true);
|
||||
};
|
||||
|
||||
Object.assign(window, {
|
||||
toggleAgeFilter,
|
||||
toggleWorkcenterFilter,
|
||||
togglePackageFilter,
|
||||
clearFilters,
|
||||
prevPage,
|
||||
nextPage,
|
||||
manualRefresh,
|
||||
loadAllData,
|
||||
loadLots
|
||||
});
|
||||
})();
|
||||
474
frontend/src/job-query/main.js
Normal file
474
frontend/src/job-query/main.js
Normal file
@@ -0,0 +1,474 @@
|
||||
import { ensureMesApiAvailable } from '../core/api.js';
|
||||
import { getPageContract } from '../core/field-contracts.js';
|
||||
import { escapeHtml, groupBy, sortBy, safeText } from '../core/table-tree.js';
|
||||
|
||||
ensureMesApiAvailable();
|
||||
window.__MES_FRONTEND_CORE__ = { groupBy, sortBy, escapeHtml, safeText };
|
||||
window.__FIELD_CONTRACTS__ = window.__FIELD_CONTRACTS__ || {};
|
||||
window.__FIELD_CONTRACTS__['job_query:jobs_table'] = getPageContract('job_query', 'jobs_table');
|
||||
window.__FIELD_CONTRACTS__['job_query:txn_table'] = getPageContract('job_query', 'txn_table');
|
||||
|
||||
const jobTableFields = getPageContract('job_query', 'jobs_table');
|
||||
const txnTableFields = getPageContract('job_query', 'txn_table');
|
||||
|
||||
function renderJobCell(job, apiKey) {
|
||||
if (apiKey === 'JOBSTATUS') {
|
||||
const value = safeText(job[apiKey]);
|
||||
return `<span class="status-badge ${value}">${value}</span>`;
|
||||
}
|
||||
if (apiKey === 'CREATEDATE' || apiKey === 'COMPLETEDATE') {
|
||||
return formatDate(job[apiKey]);
|
||||
}
|
||||
return escapeHtml(safeText(job[apiKey]));
|
||||
}
|
||||
|
||||
function renderTxnCell(txn, apiKey) {
|
||||
if (apiKey === 'FROMJOBSTATUS' || apiKey === 'JOBSTATUS') {
|
||||
const value = safeText(txn[apiKey], '-');
|
||||
return `<span class="status-badge ${escapeHtml(value)}">${escapeHtml(value)}</span>`;
|
||||
}
|
||||
if (apiKey === 'TXNDATE') {
|
||||
return formatDate(txn[apiKey]);
|
||||
}
|
||||
if (apiKey === 'USER_NAME') {
|
||||
return escapeHtml(safeText(txn.USER_NAME || txn.EMP_NAME));
|
||||
}
|
||||
return escapeHtml(safeText(txn[apiKey]));
|
||||
}
|
||||
|
||||
|
||||
// State
|
||||
let allEquipments = [];
|
||||
let selectedEquipments = new Set();
|
||||
let jobsData = [];
|
||||
let expandedJobs = new Set();
|
||||
|
||||
// Initialize
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadEquipments();
|
||||
setLast90Days();
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
const dropdown = document.getElementById('equipmentDropdown');
|
||||
const selector = document.querySelector('.equipment-selector');
|
||||
if (!selector.contains(e.target)) {
|
||||
dropdown.classList.remove('show');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Load equipments from cache
|
||||
async function loadEquipments() {
|
||||
try {
|
||||
const data = await MesApi.get('/api/job-query/resources');
|
||||
if (data.error) {
|
||||
document.getElementById('equipmentList').innerHTML = `<div class="error">${data.error}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
allEquipments = data.data;
|
||||
renderEquipmentList(allEquipments);
|
||||
} catch (error) {
|
||||
document.getElementById('equipmentList').innerHTML = `<div class="error">載入失敗: ${error.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Render equipment list
|
||||
function renderEquipmentList(equipments) {
|
||||
const container = document.getElementById('equipmentList');
|
||||
|
||||
if (!equipments || equipments.length === 0) {
|
||||
container.innerHTML = '<div class="empty-state">無設備資料</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
const grouped = groupBy(equipments, (eq) => safeText(eq.WORKCENTERNAME, '未分類'));
|
||||
const workcenters = sortBy(Object.keys(grouped), (name) => name);
|
||||
|
||||
workcenters.forEach((workcenterName) => {
|
||||
html += `<div style="padding: 8px 15px; background: #f0f0f0; font-weight: 600; font-size: 12px; color: #666;">${escapeHtml(workcenterName)}</div>`;
|
||||
grouped[workcenterName].forEach((eq) => {
|
||||
const isSelected = selectedEquipments.has(eq.RESOURCEID);
|
||||
const resourceId = escapeHtml(safeText(eq.RESOURCEID));
|
||||
const resourceName = escapeHtml(safeText(eq.RESOURCENAME));
|
||||
const familyName = escapeHtml(safeText(eq.RESOURCEFAMILYNAME));
|
||||
|
||||
html += `
|
||||
<div class="equipment-item ${isSelected ? 'selected' : ''}" onclick="toggleEquipment('${resourceId}')">
|
||||
<input type="checkbox" ${isSelected ? 'checked' : ''} onclick="event.stopPropagation(); toggleEquipment('${resourceId}')">
|
||||
<div class="equipment-info">
|
||||
<div class="equipment-name">${resourceName}</div>
|
||||
<div class="equipment-workcenter">${familyName}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
});
|
||||
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
// Toggle equipment dropdown
|
||||
function toggleEquipmentDropdown() {
|
||||
const dropdown = document.getElementById('equipmentDropdown');
|
||||
dropdown.classList.toggle('show');
|
||||
}
|
||||
|
||||
// Filter equipments by search
|
||||
function filterEquipments(query) {
|
||||
const q = query.toLowerCase();
|
||||
const filtered = allEquipments.filter(eq =>
|
||||
(eq.RESOURCENAME && eq.RESOURCENAME.toLowerCase().includes(q)) ||
|
||||
(eq.WORKCENTERNAME && eq.WORKCENTERNAME.toLowerCase().includes(q)) ||
|
||||
(eq.RESOURCEFAMILYNAME && eq.RESOURCEFAMILYNAME.toLowerCase().includes(q))
|
||||
);
|
||||
renderEquipmentList(filtered);
|
||||
}
|
||||
|
||||
// Toggle equipment selection
|
||||
function toggleEquipment(resourceId) {
|
||||
if (selectedEquipments.has(resourceId)) {
|
||||
selectedEquipments.delete(resourceId);
|
||||
} else {
|
||||
selectedEquipments.add(resourceId);
|
||||
}
|
||||
updateSelectedDisplay();
|
||||
renderEquipmentList(allEquipments.filter(eq => {
|
||||
const search = document.querySelector('.equipment-search');
|
||||
if (!search || !search.value) return true;
|
||||
const q = search.value.toLowerCase();
|
||||
return (eq.RESOURCENAME && eq.RESOURCENAME.toLowerCase().includes(q)) ||
|
||||
(eq.WORKCENTERNAME && eq.WORKCENTERNAME.toLowerCase().includes(q));
|
||||
}));
|
||||
}
|
||||
|
||||
// Update selected display
|
||||
function updateSelectedDisplay() {
|
||||
const display = document.getElementById('equipmentDisplay');
|
||||
const count = document.getElementById('selectedCount');
|
||||
|
||||
if (selectedEquipments.size === 0) {
|
||||
display.textContent = '點擊選擇設備...';
|
||||
count.textContent = '';
|
||||
} else if (selectedEquipments.size <= 3) {
|
||||
const names = allEquipments
|
||||
.filter(eq => selectedEquipments.has(eq.RESOURCEID))
|
||||
.map(eq => eq.RESOURCENAME)
|
||||
.join(', ');
|
||||
display.textContent = names;
|
||||
count.textContent = `已選擇 ${selectedEquipments.size} 台設備`;
|
||||
} else {
|
||||
display.textContent = `已選擇 ${selectedEquipments.size} 台設備`;
|
||||
count.textContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
// Set last 90 days
|
||||
function setLast90Days() {
|
||||
const today = new Date();
|
||||
const past = new Date();
|
||||
past.setDate(today.getDate() - 90);
|
||||
|
||||
document.getElementById('dateFrom').value = past.toISOString().split('T')[0];
|
||||
document.getElementById('dateTo').value = today.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
// Validate inputs
|
||||
function validateInputs() {
|
||||
if (selectedEquipments.size === 0) {
|
||||
Toast.error('請選擇至少一台設備');
|
||||
return false;
|
||||
}
|
||||
|
||||
const dateFrom = document.getElementById('dateFrom').value;
|
||||
const dateTo = document.getElementById('dateTo').value;
|
||||
|
||||
if (!dateFrom || !dateTo) {
|
||||
Toast.error('請指定日期範圍');
|
||||
return false;
|
||||
}
|
||||
|
||||
const from = new Date(dateFrom);
|
||||
const to = new Date(dateTo);
|
||||
|
||||
if (to < from) {
|
||||
Toast.error('結束日期不可早於起始日期');
|
||||
return false;
|
||||
}
|
||||
|
||||
const daysDiff = (to - from) / (1000 * 60 * 60 * 24);
|
||||
if (daysDiff > 365) {
|
||||
Toast.error('日期範圍不可超過 365 天');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Query jobs
|
||||
async function queryJobs() {
|
||||
if (!validateInputs()) return;
|
||||
|
||||
const resultSection = document.getElementById('resultSection');
|
||||
resultSection.innerHTML = `
|
||||
<div class="loading">
|
||||
<div class="loading-spinner"></div>
|
||||
<br>查詢中...
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById('queryBtn').disabled = true;
|
||||
document.getElementById('exportBtn').disabled = true;
|
||||
|
||||
try {
|
||||
const data = await MesApi.post('/api/job-query/jobs', {
|
||||
resource_ids: Array.from(selectedEquipments),
|
||||
start_date: document.getElementById('dateFrom').value,
|
||||
end_date: document.getElementById('dateTo').value
|
||||
});
|
||||
|
||||
if (data.error) {
|
||||
resultSection.innerHTML = `<div class="error">${data.error}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
jobsData = data.data;
|
||||
expandedJobs.clear();
|
||||
renderJobsTable();
|
||||
|
||||
document.getElementById('exportBtn').disabled = jobsData.length === 0;
|
||||
|
||||
} catch (error) {
|
||||
resultSection.innerHTML = `<div class="error">查詢失敗: ${error.message}</div>`;
|
||||
} finally {
|
||||
document.getElementById('queryBtn').disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Render jobs table
|
||||
function renderJobsTable() {
|
||||
const resultSection = document.getElementById('resultSection');
|
||||
const jobHeaders = jobTableFields.map((field) => `<th>${escapeHtml(field.ui_label)}</th>`).join('');
|
||||
|
||||
if (!jobsData || jobsData.length === 0) {
|
||||
resultSection.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<p>無符合條件的工單</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
let html = `
|
||||
<div class="result-header">
|
||||
<div class="result-info">共 ${jobsData.length} 筆工單</div>
|
||||
<div class="result-actions">
|
||||
<button class="btn btn-secondary btn-sm" onclick="expandAll()">全部展開</button>
|
||||
<button class="btn btn-secondary btn-sm" onclick="collapseAll()">全部收合</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 40px;"></th>
|
||||
${jobHeaders}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
`;
|
||||
|
||||
jobsData.forEach((job, idx) => {
|
||||
const isExpanded = expandedJobs.has(job.JOBID);
|
||||
const jobCells = jobTableFields
|
||||
.map((field) => `<td>${renderJobCell(job, field.api_key)}</td>`)
|
||||
.join('');
|
||||
html += `
|
||||
<tr class="job-row ${isExpanded ? 'expanded' : ''}" id="job-row-${idx}">
|
||||
<td>
|
||||
<button class="expand-btn" onclick="toggleJobHistory('${escapeHtml(safeText(job.JOBID))}', ${idx})">
|
||||
<span class="arrow-icon ${isExpanded ? 'rotated' : ''}">▶</span>
|
||||
</button>
|
||||
</td>
|
||||
${jobCells}
|
||||
</tr>
|
||||
<tr class="txn-history-row ${isExpanded ? 'show' : ''}" id="txn-row-${idx}">
|
||||
<td colspan="${jobTableFields.length + 1}" class="txn-history-cell">
|
||||
<div id="txn-content-${idx}">
|
||||
${isExpanded ? '<div class="loading"><div class="loading-spinner"></div></div>' : ''}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
|
||||
html += `
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
|
||||
resultSection.innerHTML = html;
|
||||
|
||||
// Load expanded histories
|
||||
expandedJobs.forEach(jobId => {
|
||||
const idx = jobsData.findIndex(j => j.JOBID === jobId);
|
||||
if (idx >= 0) loadJobHistory(jobId, idx);
|
||||
});
|
||||
}
|
||||
|
||||
// Toggle job history
|
||||
async function toggleJobHistory(jobId, idx) {
|
||||
const txnRow = document.getElementById(`txn-row-${idx}`);
|
||||
const jobRow = document.getElementById(`job-row-${idx}`);
|
||||
const arrow = jobRow.querySelector('.arrow-icon');
|
||||
|
||||
if (expandedJobs.has(jobId)) {
|
||||
expandedJobs.delete(jobId);
|
||||
txnRow.classList.remove('show');
|
||||
jobRow.classList.remove('expanded');
|
||||
arrow.classList.remove('rotated');
|
||||
} else {
|
||||
expandedJobs.add(jobId);
|
||||
txnRow.classList.add('show');
|
||||
jobRow.classList.add('expanded');
|
||||
arrow.classList.add('rotated');
|
||||
loadJobHistory(jobId, idx);
|
||||
}
|
||||
}
|
||||
|
||||
// Load job history
|
||||
async function loadJobHistory(jobId, idx) {
|
||||
const container = document.getElementById(`txn-content-${idx}`);
|
||||
container.innerHTML = '<div class="loading" style="padding: 20px;"><div class="loading-spinner"></div></div>';
|
||||
|
||||
try {
|
||||
const data = await MesApi.get(`/api/job-query/txn/${jobId}`);
|
||||
|
||||
if (data.error) {
|
||||
container.innerHTML = `<div class="error" style="margin: 10px 20px;">${data.error}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data.data || data.data.length === 0) {
|
||||
container.innerHTML = '<div style="padding: 20px; color: #666;">無交易歷史記錄</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const txnHeaders = txnTableFields.map((field) => `<th>${escapeHtml(field.ui_label)}</th>`).join('');
|
||||
let html = `
|
||||
<table class="txn-history-table">
|
||||
<thead>
|
||||
<tr>
|
||||
${txnHeaders}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
`;
|
||||
|
||||
data.data.forEach(txn => {
|
||||
const txnCells = txnTableFields
|
||||
.map((field) => `<td>${renderTxnCell(txn, field.api_key)}</td>`)
|
||||
.join('');
|
||||
html += `
|
||||
<tr>
|
||||
${txnCells}
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
|
||||
html += '</tbody></table>';
|
||||
container.innerHTML = html;
|
||||
|
||||
} catch (error) {
|
||||
container.innerHTML = `<div class="error" style="margin: 10px 20px;">載入失敗: ${error.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Expand all
|
||||
function expandAll() {
|
||||
jobsData.forEach((job, idx) => {
|
||||
if (!expandedJobs.has(job.JOBID)) {
|
||||
expandedJobs.add(job.JOBID);
|
||||
}
|
||||
});
|
||||
renderJobsTable();
|
||||
}
|
||||
|
||||
// Collapse all
|
||||
function collapseAll() {
|
||||
expandedJobs.clear();
|
||||
renderJobsTable();
|
||||
}
|
||||
|
||||
// Export CSV
|
||||
async function exportCsv() {
|
||||
if (!validateInputs()) return;
|
||||
|
||||
document.getElementById('exportBtn').disabled = true;
|
||||
document.getElementById('exportBtn').textContent = '匯出中...';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/job-query/export', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
resource_ids: Array.from(selectedEquipments),
|
||||
start_date: document.getElementById('dateFrom').value,
|
||||
end_date: document.getElementById('dateTo').value
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || '匯出失敗');
|
||||
}
|
||||
|
||||
// Download file
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `job_history_${document.getElementById('dateFrom').value}_${document.getElementById('dateTo').value}.csv`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
Toast.success('CSV 匯出完成');
|
||||
|
||||
} catch (error) {
|
||||
Toast.error('匯出失敗: ' + error.message);
|
||||
} finally {
|
||||
document.getElementById('exportBtn').disabled = false;
|
||||
document.getElementById('exportBtn').textContent = '匯出 CSV';
|
||||
}
|
||||
}
|
||||
|
||||
// Format date
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
return dateStr.replace('T', ' ').substring(0, 19);
|
||||
}
|
||||
|
||||
|
||||
Object.assign(window, {
|
||||
loadEquipments,
|
||||
renderEquipmentList,
|
||||
toggleEquipmentDropdown,
|
||||
filterEquipments,
|
||||
toggleEquipment,
|
||||
updateSelectedDisplay,
|
||||
setLast90Days,
|
||||
validateInputs,
|
||||
queryJobs,
|
||||
renderJobsTable,
|
||||
toggleJobHistory,
|
||||
loadJobHistory,
|
||||
expandAll,
|
||||
collapseAll,
|
||||
exportCsv,
|
||||
formatDate,
|
||||
});
|
||||
193
frontend/src/portal/main.js
Normal file
193
frontend/src/portal/main.js
Normal file
@@ -0,0 +1,193 @@
|
||||
import './portal.css';
|
||||
|
||||
(function initPortal() {
|
||||
const tabs = document.querySelectorAll('.tab');
|
||||
const frames = document.querySelectorAll('iframe');
|
||||
const toolFrame = document.getElementById('toolFrame');
|
||||
const healthDot = document.getElementById('healthDot');
|
||||
const healthLabel = document.getElementById('healthLabel');
|
||||
const healthPopup = document.getElementById('healthPopup');
|
||||
const healthStatus = document.getElementById('healthStatus');
|
||||
const dbStatus = document.getElementById('dbStatus');
|
||||
const redisStatus = document.getElementById('redisStatus');
|
||||
const cacheEnabled = document.getElementById('cacheEnabled');
|
||||
const cacheSysDate = document.getElementById('cacheSysDate');
|
||||
const cacheUpdatedAt = document.getElementById('cacheUpdatedAt');
|
||||
const resourceCacheEnabled = document.getElementById('resourceCacheEnabled');
|
||||
const resourceCacheCount = document.getElementById('resourceCacheCount');
|
||||
const resourceCacheUpdatedAt = document.getElementById('resourceCacheUpdatedAt');
|
||||
const routeCacheMode = document.getElementById('routeCacheMode');
|
||||
const routeCacheHitRate = document.getElementById('routeCacheHitRate');
|
||||
const routeCacheDegraded = document.getElementById('routeCacheDegraded');
|
||||
|
||||
function setFrameHeight() {
|
||||
const header = document.querySelector('.header');
|
||||
const tabArea = document.querySelector('.tabs');
|
||||
if (!header || !tabArea) return;
|
||||
const height = Math.max(600, window.innerHeight - header.offsetHeight - tabArea.offsetHeight - 60);
|
||||
frames.forEach((frame) => {
|
||||
frame.style.height = `${height}px`;
|
||||
});
|
||||
}
|
||||
|
||||
function activateTab(targetId) {
|
||||
tabs.forEach((tab) => tab.classList.remove('active'));
|
||||
frames.forEach((frame) => frame.classList.remove('active'));
|
||||
|
||||
const tabBtn = document.querySelector(`[data-target="${targetId}"]`);
|
||||
const targetFrame = document.getElementById(targetId);
|
||||
|
||||
if (tabBtn) tabBtn.classList.add('active');
|
||||
if (targetFrame) {
|
||||
targetFrame.classList.add('active');
|
||||
if (targetFrame.dataset.src && !targetFrame.src) {
|
||||
targetFrame.src = targetFrame.dataset.src;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function openTool(path) {
|
||||
if (!toolFrame) return false;
|
||||
tabs.forEach((tab) => tab.classList.remove('active'));
|
||||
frames.forEach((frame) => frame.classList.remove('active'));
|
||||
toolFrame.classList.add('active');
|
||||
if (toolFrame.src !== path) {
|
||||
toolFrame.src = path;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function toggleHealthPopup() {
|
||||
if (!healthPopup) return;
|
||||
healthPopup.classList.toggle('show');
|
||||
}
|
||||
|
||||
function formatStatus(status) {
|
||||
const icons = { ok: '✓', error: '✗', disabled: '○' };
|
||||
return icons[status] || status;
|
||||
}
|
||||
|
||||
function setStatusClass(element, status) {
|
||||
if (!element) return;
|
||||
element.classList.remove('ok', 'error', 'disabled');
|
||||
element.classList.add(status === 'ok' ? 'ok' : status === 'error' ? 'error' : 'disabled');
|
||||
}
|
||||
|
||||
function formatDateTime(dateStr) {
|
||||
if (!dateStr) return '--';
|
||||
try {
|
||||
const date = new Date(dateStr);
|
||||
if (Number.isNaN(date.getTime())) return dateStr;
|
||||
return date.toLocaleString('zh-TW', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
|
||||
async function checkHealth() {
|
||||
if (!healthDot || !healthLabel) return;
|
||||
try {
|
||||
const response = await fetch('/health', { cache: 'no-store' });
|
||||
const data = await response.json();
|
||||
|
||||
healthDot.classList.remove('loading', 'healthy', 'degraded', 'unhealthy');
|
||||
if (data.status === 'healthy') {
|
||||
healthDot.classList.add('healthy');
|
||||
healthLabel.textContent = '連線正常';
|
||||
} else if (data.status === 'degraded') {
|
||||
healthDot.classList.add('degraded');
|
||||
healthLabel.textContent = '部分降級';
|
||||
} else {
|
||||
healthDot.classList.add('unhealthy');
|
||||
healthLabel.textContent = '連線異常';
|
||||
}
|
||||
|
||||
const dbState = data.services?.database || 'error';
|
||||
if (dbStatus) dbStatus.innerHTML = `${formatStatus(dbState)} ${dbState === 'ok' ? '正常' : '異常'}`;
|
||||
setStatusClass(dbStatus, dbState);
|
||||
|
||||
const redisState = data.services?.redis || 'disabled';
|
||||
const redisText = redisState === 'ok' ? '正常' : redisState === 'disabled' ? '未啟用' : '異常';
|
||||
if (redisStatus) redisStatus.innerHTML = `${formatStatus(redisState)} ${redisText}`;
|
||||
setStatusClass(redisStatus, redisState);
|
||||
|
||||
const cache = data.cache || {};
|
||||
if (cacheEnabled) cacheEnabled.textContent = cache.enabled ? '已啟用' : '未啟用';
|
||||
if (cacheSysDate) cacheSysDate.textContent = cache.sys_date || '--';
|
||||
if (cacheUpdatedAt) cacheUpdatedAt.textContent = formatDateTime(cache.updated_at);
|
||||
|
||||
const resCache = data.resource_cache || {};
|
||||
if (resCache.enabled) {
|
||||
if (resourceCacheEnabled) {
|
||||
resourceCacheEnabled.textContent = resCache.loaded ? '已載入' : '未載入';
|
||||
resourceCacheEnabled.style.color = resCache.loaded ? '#22c55e' : '#f59e0b';
|
||||
}
|
||||
if (resourceCacheCount) resourceCacheCount.textContent = resCache.count ? `${resCache.count} 筆` : '--';
|
||||
if (resourceCacheUpdatedAt) resourceCacheUpdatedAt.textContent = formatDateTime(resCache.updated_at);
|
||||
} else {
|
||||
if (resourceCacheEnabled) {
|
||||
resourceCacheEnabled.textContent = '未啟用';
|
||||
resourceCacheEnabled.style.color = '#9ca3af';
|
||||
}
|
||||
if (resourceCacheCount) resourceCacheCount.textContent = '--';
|
||||
if (resourceCacheUpdatedAt) resourceCacheUpdatedAt.textContent = '--';
|
||||
}
|
||||
|
||||
const routeCache = data.route_cache || {};
|
||||
if (routeCacheMode) {
|
||||
routeCacheMode.textContent = routeCache.mode || '--';
|
||||
}
|
||||
if (routeCacheHitRate) {
|
||||
const l1 = routeCache.l1_hit_rate ?? '--';
|
||||
const l2 = routeCache.l2_hit_rate ?? '--';
|
||||
routeCacheHitRate.textContent = `${l1} / ${l2}`;
|
||||
}
|
||||
if (routeCacheDegraded) {
|
||||
routeCacheDegraded.textContent = routeCache.degraded ? '是' : '否';
|
||||
routeCacheDegraded.style.color = routeCache.degraded ? '#f59e0b' : '#22c55e';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Health check failed:', error);
|
||||
healthDot.classList.remove('loading', 'healthy', 'degraded');
|
||||
healthDot.classList.add('unhealthy');
|
||||
healthLabel.textContent = '無法連線';
|
||||
if (dbStatus) {
|
||||
dbStatus.innerHTML = '✗ 無法確認';
|
||||
setStatusClass(dbStatus, 'error');
|
||||
}
|
||||
if (redisStatus) {
|
||||
redisStatus.innerHTML = '✗ 無法確認';
|
||||
setStatusClass(redisStatus, 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tabs.forEach((tab) => {
|
||||
tab.addEventListener('click', () => activateTab(tab.dataset.target));
|
||||
});
|
||||
|
||||
if (tabs.length > 0) {
|
||||
activateTab(tabs[0].dataset.target);
|
||||
}
|
||||
|
||||
window.openTool = openTool;
|
||||
window.toggleHealthPopup = toggleHealthPopup;
|
||||
if (healthStatus) {
|
||||
healthStatus.addEventListener('click', toggleHealthPopup);
|
||||
}
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('#healthStatus') && !e.target.closest('#healthPopup') && healthPopup) {
|
||||
healthPopup.classList.remove('show');
|
||||
}
|
||||
});
|
||||
|
||||
checkHealth();
|
||||
setInterval(checkHealth, 30000);
|
||||
window.addEventListener('resize', setFrameHeight);
|
||||
setFrameHeight();
|
||||
})();
|
||||
29
frontend/src/portal/portal.css
Normal file
29
frontend/src/portal/portal.css
Normal file
@@ -0,0 +1,29 @@
|
||||
.drawer {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #e3e8f2;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.drawer > summary {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
padding: 10px 14px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.drawer > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.drawer-content {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
}
|
||||
844
frontend/src/resource-history/main.js
Normal file
844
frontend/src/resource-history/main.js
Normal file
@@ -0,0 +1,844 @@
|
||||
import { ensureMesApiAvailable } from '../core/api.js';
|
||||
import { getPageContract } from '../core/field-contracts.js';
|
||||
import { buildResourceKpiFromHours } from '../core/compute.js';
|
||||
import { groupBy, sortBy, toggleTreeState, setTreeStateBulk, escapeHtml, safeText } from '../core/table-tree.js';
|
||||
|
||||
ensureMesApiAvailable();
|
||||
window.__MES_FRONTEND_CORE__ = { buildResourceKpiFromHours, groupBy, sortBy, toggleTreeState, setTreeStateBulk, escapeHtml, safeText };
|
||||
window.__FIELD_CONTRACTS__ = window.__FIELD_CONTRACTS__ || {};
|
||||
window.__FIELD_CONTRACTS__['resource_history:detail_table'] = getPageContract('resource_history', 'detail_table');
|
||||
window.__FIELD_CONTRACTS__['resource_history:kpi'] = getPageContract('resource_history', 'kpi');
|
||||
|
||||
const detailTableFields = getPageContract('resource_history', 'detail_table');
|
||||
|
||||
|
||||
(function() {
|
||||
// ============================================================
|
||||
// State
|
||||
// ============================================================
|
||||
let currentGranularity = 'day';
|
||||
let summaryData = null;
|
||||
let detailData = null;
|
||||
let hierarchyState = {}; // Track expanded/collapsed state
|
||||
let charts = {};
|
||||
|
||||
// ============================================================
|
||||
// DOM Elements
|
||||
// ============================================================
|
||||
const startDateInput = document.getElementById('startDate');
|
||||
const endDateInput = document.getElementById('endDate');
|
||||
const workcenterGroupsTrigger = document.getElementById('workcenterGroupsTrigger');
|
||||
const workcenterGroupsDropdown = document.getElementById('workcenterGroupsDropdown');
|
||||
const workcenterGroupsOptions = document.getElementById('workcenterGroupsOptions');
|
||||
const familiesTrigger = document.getElementById('familiesTrigger');
|
||||
const familiesDropdown = document.getElementById('familiesDropdown');
|
||||
const familiesOptions = document.getElementById('familiesOptions');
|
||||
const isProductionCheckbox = document.getElementById('isProduction');
|
||||
const isKeyCheckbox = document.getElementById('isKey');
|
||||
const isMonitorCheckbox = document.getElementById('isMonitor');
|
||||
const queryBtn = document.getElementById('queryBtn');
|
||||
const exportBtn = document.getElementById('exportBtn');
|
||||
const expandAllBtn = document.getElementById('expandAllBtn');
|
||||
const collapseAllBtn = document.getElementById('collapseAllBtn');
|
||||
const loadingOverlay = document.getElementById('loadingOverlay');
|
||||
|
||||
// Selected values for multi-select
|
||||
let selectedWorkcenterGroups = [];
|
||||
let selectedFamilies = [];
|
||||
|
||||
// ============================================================
|
||||
// Initialization
|
||||
// ============================================================
|
||||
function init() {
|
||||
setDefaultDates();
|
||||
applyDetailTableHeaders();
|
||||
loadFilterOptions();
|
||||
setupEventListeners();
|
||||
initCharts();
|
||||
}
|
||||
|
||||
function setDefaultDates() {
|
||||
const today = new Date();
|
||||
const endDate = new Date(today);
|
||||
endDate.setDate(endDate.getDate() - 1); // Yesterday
|
||||
const startDate = new Date(endDate);
|
||||
startDate.setDate(startDate.getDate() - 6); // 7 days ago
|
||||
|
||||
startDateInput.value = formatDate(startDate);
|
||||
endDateInput.value = formatDate(endDate);
|
||||
}
|
||||
|
||||
function formatDate(date) {
|
||||
return date.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
function setupEventListeners() {
|
||||
// Granularity buttons
|
||||
document.querySelectorAll('.granularity-btns button').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('.granularity-btns button').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
currentGranularity = btn.dataset.granularity;
|
||||
});
|
||||
});
|
||||
|
||||
// Query button
|
||||
queryBtn.addEventListener('click', executeQuery);
|
||||
|
||||
// Export button
|
||||
exportBtn.addEventListener('click', exportCsv);
|
||||
|
||||
// Expand/Collapse buttons
|
||||
expandAllBtn.addEventListener('click', () => toggleAllRows(true));
|
||||
collapseAllBtn.addEventListener('click', () => toggleAllRows(false));
|
||||
}
|
||||
|
||||
function applyDetailTableHeaders() {
|
||||
const headers = document.querySelectorAll('.detail-table thead th');
|
||||
if (!headers || headers.length < 10) return;
|
||||
|
||||
const byKey = {};
|
||||
detailTableFields.forEach((field) => {
|
||||
byKey[field.api_key] = field.ui_label;
|
||||
});
|
||||
|
||||
headers[1].textContent = byKey.ou_pct || headers[1].textContent;
|
||||
headers[2].textContent = byKey.availability_pct || headers[2].textContent;
|
||||
headers[3].textContent = byKey.prd_hours ? byKey.prd_hours.replace('(h)', '') : headers[3].textContent;
|
||||
headers[4].textContent = byKey.sby_hours ? byKey.sby_hours.replace('(h)', '') : headers[4].textContent;
|
||||
headers[5].textContent = byKey.udt_hours ? byKey.udt_hours.replace('(h)', '') : headers[5].textContent;
|
||||
headers[6].textContent = byKey.sdt_hours ? byKey.sdt_hours.replace('(h)', '') : headers[6].textContent;
|
||||
headers[7].textContent = byKey.egt_hours ? byKey.egt_hours.replace('(h)', '') : headers[7].textContent;
|
||||
headers[8].textContent = byKey.nst_hours ? byKey.nst_hours.replace('(h)', '') : headers[8].textContent;
|
||||
}
|
||||
|
||||
function initCharts() {
|
||||
charts.trend = echarts.init(document.getElementById('trendChart'));
|
||||
charts.stacked = echarts.init(document.getElementById('stackedChart'));
|
||||
charts.comparison = echarts.init(document.getElementById('comparisonChart'));
|
||||
charts.heatmap = echarts.init(document.getElementById('heatmapChart'));
|
||||
|
||||
// Handle window resize
|
||||
window.addEventListener('resize', () => {
|
||||
Object.values(charts).forEach(chart => chart.resize());
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// API Calls (using MesApi client with timeout and retry)
|
||||
// ============================================================
|
||||
const API_TIMEOUT = 60000; // 60 seconds timeout
|
||||
|
||||
async function loadFilterOptions() {
|
||||
try {
|
||||
const result = await MesApi.get('/api/resource/history/options', {
|
||||
timeout: API_TIMEOUT,
|
||||
silent: true // Don't show toast for filter options
|
||||
});
|
||||
if (result.success) {
|
||||
populateMultiSelect(workcenterGroupsOptions, result.data.workcenter_groups, 'workcenter');
|
||||
populateMultiSelect(familiesOptions, result.data.families.map(f => ({name: f})), 'family');
|
||||
setupMultiSelectDropdowns();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load filter options:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function populateMultiSelect(container, options, type) {
|
||||
container.innerHTML = '';
|
||||
options.forEach(opt => {
|
||||
const name = opt.name || opt;
|
||||
const div = document.createElement('div');
|
||||
div.className = 'multi-select-option';
|
||||
div.innerHTML = `
|
||||
<input type="checkbox" value="${name}" data-type="${type}">
|
||||
<span>${name}</span>
|
||||
`;
|
||||
div.querySelector('input').addEventListener('change', (e) => {
|
||||
if (type === 'workcenter') {
|
||||
updateSelectedWorkcenterGroups();
|
||||
} else {
|
||||
updateSelectedFamilies();
|
||||
}
|
||||
});
|
||||
container.appendChild(div);
|
||||
});
|
||||
}
|
||||
|
||||
function setupMultiSelectDropdowns() {
|
||||
// Workcenter Groups dropdown toggle
|
||||
workcenterGroupsTrigger.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
workcenterGroupsDropdown.classList.toggle('show');
|
||||
familiesDropdown.classList.remove('show');
|
||||
});
|
||||
|
||||
// Families dropdown toggle
|
||||
familiesTrigger.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
familiesDropdown.classList.toggle('show');
|
||||
workcenterGroupsDropdown.classList.remove('show');
|
||||
});
|
||||
|
||||
// Close dropdowns when clicking outside
|
||||
document.addEventListener('click', () => {
|
||||
workcenterGroupsDropdown.classList.remove('show');
|
||||
familiesDropdown.classList.remove('show');
|
||||
});
|
||||
|
||||
// Prevent dropdown close when clicking inside
|
||||
workcenterGroupsDropdown.addEventListener('click', (e) => e.stopPropagation());
|
||||
familiesDropdown.addEventListener('click', (e) => e.stopPropagation());
|
||||
}
|
||||
|
||||
function updateSelectedWorkcenterGroups() {
|
||||
const checkboxes = workcenterGroupsOptions.querySelectorAll('input[type="checkbox"]:checked');
|
||||
selectedWorkcenterGroups = Array.from(checkboxes).map(cb => cb.value);
|
||||
updateMultiSelectText(workcenterGroupsTrigger, selectedWorkcenterGroups, '全部站點');
|
||||
}
|
||||
|
||||
function updateSelectedFamilies() {
|
||||
const checkboxes = familiesOptions.querySelectorAll('input[type="checkbox"]:checked');
|
||||
selectedFamilies = Array.from(checkboxes).map(cb => cb.value);
|
||||
updateMultiSelectText(familiesTrigger, selectedFamilies, '全部型號');
|
||||
}
|
||||
|
||||
function updateMultiSelectText(trigger, selected, defaultText) {
|
||||
const textSpan = trigger.querySelector('.multi-select-text');
|
||||
if (selected.length === 0) {
|
||||
textSpan.textContent = defaultText;
|
||||
} else if (selected.length === 1) {
|
||||
textSpan.textContent = selected[0];
|
||||
} else {
|
||||
textSpan.textContent = `已選 ${selected.length} 項`;
|
||||
}
|
||||
}
|
||||
|
||||
// Global functions for select all / clear all
|
||||
window.selectAllWorkcenterGroups = function() {
|
||||
workcenterGroupsOptions.querySelectorAll('input[type="checkbox"]').forEach(cb => cb.checked = true);
|
||||
updateSelectedWorkcenterGroups();
|
||||
};
|
||||
|
||||
window.clearAllWorkcenterGroups = function() {
|
||||
workcenterGroupsOptions.querySelectorAll('input[type="checkbox"]').forEach(cb => cb.checked = false);
|
||||
updateSelectedWorkcenterGroups();
|
||||
};
|
||||
|
||||
window.selectAllFamilies = function() {
|
||||
familiesOptions.querySelectorAll('input[type="checkbox"]').forEach(cb => cb.checked = true);
|
||||
updateSelectedFamilies();
|
||||
};
|
||||
|
||||
window.clearAllFamilies = function() {
|
||||
familiesOptions.querySelectorAll('input[type="checkbox"]').forEach(cb => cb.checked = false);
|
||||
updateSelectedFamilies();
|
||||
};
|
||||
|
||||
function buildQueryString() {
|
||||
const params = new URLSearchParams();
|
||||
params.append('start_date', startDateInput.value);
|
||||
params.append('end_date', endDateInput.value);
|
||||
params.append('granularity', currentGranularity);
|
||||
|
||||
// Add multi-select params
|
||||
selectedWorkcenterGroups.forEach(g => params.append('workcenter_groups', g));
|
||||
selectedFamilies.forEach(f => params.append('families', f));
|
||||
|
||||
if (isProductionCheckbox.checked) params.append('is_production', '1');
|
||||
if (isKeyCheckbox.checked) params.append('is_key', '1');
|
||||
if (isMonitorCheckbox.checked) params.append('is_monitor', '1');
|
||||
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
async function executeQuery() {
|
||||
// Validate date range
|
||||
const startDate = new Date(startDateInput.value);
|
||||
const endDate = new Date(endDateInput.value);
|
||||
const diffDays = (endDate - startDate) / (1000 * 60 * 60 * 24);
|
||||
|
||||
if (diffDays > 730) {
|
||||
Toast.warning('查詢範圍不可超過兩年');
|
||||
return;
|
||||
}
|
||||
|
||||
if (diffDays < 0) {
|
||||
Toast.warning('結束日期必須大於起始日期');
|
||||
return;
|
||||
}
|
||||
|
||||
showLoading();
|
||||
queryBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const queryString = buildQueryString();
|
||||
const summaryUrl = `/api/resource/history/summary?${queryString}`;
|
||||
const detailUrl = `/api/resource/history/detail?${queryString}`;
|
||||
|
||||
// Fetch summary and detail in parallel using MesApi
|
||||
const [summaryResult, detailResult] = await Promise.all([
|
||||
MesApi.get(summaryUrl, { timeout: API_TIMEOUT }),
|
||||
MesApi.get(detailUrl, { timeout: API_TIMEOUT })
|
||||
]);
|
||||
|
||||
if (summaryResult.success) {
|
||||
const rawSummary = summaryResult.data || {};
|
||||
const computedKpi = mergeComputedKpi(rawSummary.kpi || {});
|
||||
const computedTrend = (rawSummary.trend || []).map((trendPoint) => mergeComputedKpi(trendPoint));
|
||||
summaryData = {
|
||||
...rawSummary,
|
||||
kpi: computedKpi,
|
||||
trend: computedTrend
|
||||
};
|
||||
|
||||
updateKpiCards(summaryData.kpi);
|
||||
updateTrendChart(summaryData.trend);
|
||||
updateStackedChart(summaryData.trend);
|
||||
updateComparisonChart(summaryData.workcenter_comparison);
|
||||
updateHeatmapChart(summaryData.heatmap);
|
||||
} else {
|
||||
Toast.error(summaryResult.error || '查詢摘要失敗');
|
||||
}
|
||||
|
||||
if (detailResult.success) {
|
||||
detailData = detailResult.data;
|
||||
hierarchyState = {};
|
||||
renderDetailTable(detailData);
|
||||
|
||||
// Show warning if data was truncated
|
||||
if (detailResult.truncated) {
|
||||
Toast.warning(`明細資料超過 ${detailResult.max_records} 筆,僅顯示前 ${detailResult.max_records} 筆。請使用篩選條件縮小範圍。`);
|
||||
}
|
||||
} else {
|
||||
Toast.error(detailResult.error || '查詢明細失敗');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Query failed:', error);
|
||||
Toast.error('查詢失敗: ' + error.message);
|
||||
} finally {
|
||||
hideLoading();
|
||||
queryBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// KPI Cards
|
||||
// ============================================================
|
||||
function mergeComputedKpi(kpi) {
|
||||
return {
|
||||
...kpi,
|
||||
...buildResourceKpiFromHours(kpi)
|
||||
};
|
||||
}
|
||||
|
||||
function updateKpiCards(kpi) {
|
||||
// OU% and AVAIL%
|
||||
document.getElementById('kpiOuPct').textContent = kpi.ou_pct + '%';
|
||||
document.getElementById('kpiAvailabilityPct').textContent = kpi.availability_pct + '%';
|
||||
|
||||
// PRD
|
||||
document.getElementById('kpiPrdHours').textContent = formatHours(kpi.prd_hours);
|
||||
document.getElementById('kpiPrdPct').textContent = `生產 (${kpi.prd_pct || 0}%)`;
|
||||
|
||||
// SBY
|
||||
document.getElementById('kpiSbyHours').textContent = formatHours(kpi.sby_hours);
|
||||
document.getElementById('kpiSbyPct').textContent = `待機 (${kpi.sby_pct || 0}%)`;
|
||||
|
||||
// UDT
|
||||
document.getElementById('kpiUdtHours').textContent = formatHours(kpi.udt_hours);
|
||||
document.getElementById('kpiUdtPct').textContent = `非計畫停機 (${kpi.udt_pct || 0}%)`;
|
||||
|
||||
// SDT
|
||||
document.getElementById('kpiSdtHours').textContent = formatHours(kpi.sdt_hours);
|
||||
document.getElementById('kpiSdtPct').textContent = `計畫停機 (${kpi.sdt_pct || 0}%)`;
|
||||
|
||||
// EGT
|
||||
document.getElementById('kpiEgtHours').textContent = formatHours(kpi.egt_hours);
|
||||
document.getElementById('kpiEgtPct').textContent = `工程 (${kpi.egt_pct || 0}%)`;
|
||||
|
||||
// NST
|
||||
document.getElementById('kpiNstHours').textContent = formatHours(kpi.nst_hours);
|
||||
document.getElementById('kpiNstPct').textContent = `未排程 (${kpi.nst_pct || 0}%)`;
|
||||
|
||||
// Machine count
|
||||
const machineCount = Number(kpi.machine_count || 0);
|
||||
document.getElementById('kpiMachineCount').textContent = machineCount.toLocaleString();
|
||||
}
|
||||
|
||||
function formatHours(hours) {
|
||||
if (hours >= 1000) {
|
||||
return (hours / 1000).toFixed(1) + 'K';
|
||||
}
|
||||
return hours.toLocaleString();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Charts
|
||||
// ============================================================
|
||||
function updateTrendChart(trend) {
|
||||
const dates = trend.map(t => t.date);
|
||||
const ouPcts = trend.map(t => t.ou_pct);
|
||||
const availabilityPcts = trend.map(t => t.availability_pct);
|
||||
|
||||
charts.trend.setOption({
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
formatter: function(params) {
|
||||
const d = trend[params[0].dataIndex];
|
||||
return `${d.date}<br/>
|
||||
<span style="color:#3B82F6">●</span> OU%: <b>${d.ou_pct}%</b><br/>
|
||||
<span style="color:#10B981">●</span> AVAIL%: <b>${d.availability_pct}%</b><br/>
|
||||
PRD: ${d.prd_hours}h<br/>
|
||||
SBY: ${d.sby_hours}h<br/>
|
||||
UDT: ${d.udt_hours}h`;
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
data: ['OU%', 'AVAIL%'],
|
||||
bottom: 0,
|
||||
textStyle: { fontSize: 11 }
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: dates,
|
||||
axisLabel: { fontSize: 11 }
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: '%',
|
||||
max: 100,
|
||||
axisLabel: { formatter: '{value}%' }
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: 'OU%',
|
||||
data: ouPcts,
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
areaStyle: { opacity: 0.2 },
|
||||
itemStyle: { color: '#3B82F6' },
|
||||
lineStyle: { width: 2 }
|
||||
},
|
||||
{
|
||||
name: 'AVAIL%',
|
||||
data: availabilityPcts,
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
areaStyle: { opacity: 0.2 },
|
||||
itemStyle: { color: '#10B981' },
|
||||
lineStyle: { width: 2 }
|
||||
}
|
||||
],
|
||||
grid: { left: 50, right: 20, top: 30, bottom: 50 }
|
||||
});
|
||||
}
|
||||
|
||||
function updateStackedChart(trend) {
|
||||
const dates = trend.map(t => t.date);
|
||||
|
||||
charts.stacked.setOption({
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'shadow' },
|
||||
formatter: function(params) {
|
||||
const idx = params[0].dataIndex;
|
||||
const d = trend[idx];
|
||||
const total = d.prd_hours + d.sby_hours + d.udt_hours + d.sdt_hours + d.egt_hours + d.nst_hours;
|
||||
const pct = (v) => total > 0 ? (v / total * 100).toFixed(1) : 0;
|
||||
return `<b>${d.date}</b><br/>
|
||||
<span style="color:#22c55e">●</span> PRD: ${d.prd_hours}h (${pct(d.prd_hours)}%)<br/>
|
||||
<span style="color:#3b82f6">●</span> SBY: ${d.sby_hours}h (${pct(d.sby_hours)}%)<br/>
|
||||
<span style="color:#ef4444">●</span> UDT: ${d.udt_hours}h (${pct(d.udt_hours)}%)<br/>
|
||||
<span style="color:#f59e0b">●</span> SDT: ${d.sdt_hours}h (${pct(d.sdt_hours)}%)<br/>
|
||||
<span style="color:#8b5cf6">●</span> EGT: ${d.egt_hours}h (${pct(d.egt_hours)}%)<br/>
|
||||
<span style="color:#64748b">●</span> NST: ${d.nst_hours}h (${pct(d.nst_hours)}%)<br/>
|
||||
<b>Total: ${total.toFixed(1)}h</b>`;
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
data: ['PRD', 'SBY', 'UDT', 'SDT', 'EGT', 'NST'],
|
||||
bottom: 0,
|
||||
textStyle: { fontSize: 10 }
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: dates,
|
||||
axisLabel: { fontSize: 10 }
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: '時數',
|
||||
axisLabel: { formatter: '{value}h' }
|
||||
},
|
||||
series: [
|
||||
{ name: 'PRD', type: 'bar', stack: 'total', data: trend.map(t => t.prd_hours), itemStyle: { color: '#22c55e' } },
|
||||
{ name: 'SBY', type: 'bar', stack: 'total', data: trend.map(t => t.sby_hours), itemStyle: { color: '#3b82f6' } },
|
||||
{ name: 'UDT', type: 'bar', stack: 'total', data: trend.map(t => t.udt_hours), itemStyle: { color: '#ef4444' } },
|
||||
{ name: 'SDT', type: 'bar', stack: 'total', data: trend.map(t => t.sdt_hours), itemStyle: { color: '#f59e0b' } },
|
||||
{ name: 'EGT', type: 'bar', stack: 'total', data: trend.map(t => t.egt_hours), itemStyle: { color: '#8b5cf6' } },
|
||||
{ name: 'NST', type: 'bar', stack: 'total', data: trend.map(t => t.nst_hours), itemStyle: { color: '#64748b' } }
|
||||
],
|
||||
grid: { left: 50, right: 20, top: 30, bottom: 60 }
|
||||
});
|
||||
}
|
||||
|
||||
function updateComparisonChart(comparison) {
|
||||
// Take top 15 workcenters and reverse for bottom-to-top display (highest at top)
|
||||
const data = comparison.slice(0, 15).reverse();
|
||||
const workcenters = data.map(d => d.workcenter);
|
||||
const ouPcts = data.map(d => d.ou_pct);
|
||||
|
||||
charts.comparison.setOption({
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'shadow' },
|
||||
formatter: function(params) {
|
||||
const d = data[params[0].dataIndex];
|
||||
return `${d.workcenter}<br/>OU%: <b>${d.ou_pct}%</b><br/>機台數: ${d.machine_count}`;
|
||||
}
|
||||
},
|
||||
xAxis: {
|
||||
type: 'value',
|
||||
name: 'OU%',
|
||||
max: 100,
|
||||
axisLabel: { formatter: '{value}%' }
|
||||
},
|
||||
yAxis: {
|
||||
type: 'category',
|
||||
data: workcenters,
|
||||
axisLabel: { fontSize: 10 }
|
||||
},
|
||||
series: [{
|
||||
type: 'bar',
|
||||
data: ouPcts,
|
||||
itemStyle: {
|
||||
color: function(params) {
|
||||
const val = params.value;
|
||||
if (val >= 80) return '#22c55e';
|
||||
if (val >= 50) return '#f59e0b';
|
||||
return '#ef4444';
|
||||
}
|
||||
}
|
||||
}],
|
||||
grid: { left: 100, right: 30, top: 20, bottom: 30 }
|
||||
});
|
||||
}
|
||||
|
||||
function updateHeatmapChart(heatmap) {
|
||||
if (!heatmap || heatmap.length === 0) {
|
||||
charts.heatmap.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
// Build workcenter list with sequence for sorting
|
||||
const wcSeqMap = {};
|
||||
heatmap.forEach(h => {
|
||||
wcSeqMap[h.workcenter] = h.workcenter_seq ?? 999;
|
||||
});
|
||||
|
||||
// Get unique workcenters sorted by sequence ascending (smaller sequence first, e.g. 點測 before TMTT)
|
||||
const workcenters = [...new Set(heatmap.map(h => h.workcenter))]
|
||||
.sort((a, b) => wcSeqMap[a] - wcSeqMap[b]);
|
||||
const dates = [...new Set(heatmap.map(h => h.date))].sort();
|
||||
|
||||
// Build data matrix
|
||||
const data = heatmap.map(h => [
|
||||
dates.indexOf(h.date),
|
||||
workcenters.indexOf(h.workcenter),
|
||||
h.ou_pct
|
||||
]);
|
||||
|
||||
charts.heatmap.setOption({
|
||||
tooltip: {
|
||||
position: 'top',
|
||||
formatter: function(params) {
|
||||
return `${workcenters[params.value[1]]}<br/>${dates[params.value[0]]}<br/>OU%: <b>${params.value[2]}%</b>`;
|
||||
}
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: dates,
|
||||
splitArea: { show: true },
|
||||
axisLabel: { fontSize: 9, rotate: 45 }
|
||||
},
|
||||
yAxis: {
|
||||
type: 'category',
|
||||
data: workcenters,
|
||||
splitArea: { show: true },
|
||||
axisLabel: { fontSize: 9 }
|
||||
},
|
||||
visualMap: {
|
||||
min: 0,
|
||||
max: 100,
|
||||
calculable: true,
|
||||
orient: 'horizontal',
|
||||
left: 'center',
|
||||
bottom: 0,
|
||||
inRange: {
|
||||
color: ['#ef4444', '#f59e0b', '#22c55e']
|
||||
}
|
||||
},
|
||||
series: [{
|
||||
type: 'heatmap',
|
||||
data: data,
|
||||
label: { show: false },
|
||||
emphasis: {
|
||||
itemStyle: { shadowBlur: 10, shadowColor: 'rgba(0, 0, 0, 0.5)' }
|
||||
}
|
||||
}],
|
||||
grid: { left: 100, right: 20, top: 10, bottom: 60 }
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Hierarchical Table
|
||||
// ============================================================
|
||||
function renderDetailTable(data) {
|
||||
const tbody = document.getElementById('detailTableBody');
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
tbody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="10">
|
||||
<div class="placeholder">
|
||||
<div class="placeholder-icon">🔍</div>
|
||||
<div class="placeholder-text">無符合條件的資料</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Build hierarchy
|
||||
const hierarchy = buildHierarchy(data);
|
||||
|
||||
// Render rows
|
||||
tbody.innerHTML = '';
|
||||
hierarchy.forEach(wc => {
|
||||
// Workcenter level
|
||||
const wcRow = createRow(wc, 0, `wc_${wc.workcenter}`);
|
||||
tbody.appendChild(wcRow);
|
||||
|
||||
// Family level
|
||||
if (hierarchyState[`wc_${wc.workcenter}`]) {
|
||||
wc.families.forEach(fam => {
|
||||
const famRow = createRow(fam, 1, `fam_${wc.workcenter}_${fam.family}`);
|
||||
famRow.dataset.parent = `wc_${wc.workcenter}`;
|
||||
tbody.appendChild(famRow);
|
||||
|
||||
// Resource level
|
||||
if (hierarchyState[`fam_${wc.workcenter}_${fam.family}`]) {
|
||||
fam.resources.forEach(res => {
|
||||
const resRow = createRow(res, 2);
|
||||
resRow.dataset.parent = `fam_${wc.workcenter}_${fam.family}`;
|
||||
tbody.appendChild(resRow);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function buildHierarchy(data) {
|
||||
const wcMap = {};
|
||||
|
||||
data.forEach(item => {
|
||||
const wc = item.workcenter;
|
||||
const fam = item.family;
|
||||
const wcSeq = item.workcenter_seq ?? 999;
|
||||
|
||||
if (!wcMap[wc]) {
|
||||
wcMap[wc] = {
|
||||
workcenter: wc,
|
||||
name: wc,
|
||||
sequence: wcSeq,
|
||||
families: [],
|
||||
familyMap: {},
|
||||
ou_pct: 0, availability_pct: 0, prd_hours: 0, prd_pct: 0,
|
||||
sby_hours: 0, sby_pct: 0, udt_hours: 0, udt_pct: 0,
|
||||
sdt_hours: 0, sdt_pct: 0, egt_hours: 0, egt_pct: 0,
|
||||
nst_hours: 0, nst_pct: 0, machine_count: 0
|
||||
};
|
||||
}
|
||||
|
||||
if (!wcMap[wc].familyMap[fam]) {
|
||||
wcMap[wc].familyMap[fam] = {
|
||||
family: fam,
|
||||
name: fam,
|
||||
resources: [],
|
||||
ou_pct: 0, availability_pct: 0, prd_hours: 0, prd_pct: 0,
|
||||
sby_hours: 0, sby_pct: 0, udt_hours: 0, udt_pct: 0,
|
||||
sdt_hours: 0, sdt_pct: 0, egt_hours: 0, egt_pct: 0,
|
||||
nst_hours: 0, nst_pct: 0, machine_count: 0
|
||||
};
|
||||
wcMap[wc].families.push(wcMap[wc].familyMap[fam]);
|
||||
}
|
||||
|
||||
// Add resource
|
||||
wcMap[wc].familyMap[fam].resources.push({
|
||||
name: item.resource,
|
||||
...item
|
||||
});
|
||||
|
||||
// Aggregate to family
|
||||
const famObj = wcMap[wc].familyMap[fam];
|
||||
famObj.prd_hours += item.prd_hours;
|
||||
famObj.sby_hours += item.sby_hours;
|
||||
famObj.udt_hours += item.udt_hours;
|
||||
famObj.sdt_hours += item.sdt_hours;
|
||||
famObj.egt_hours += item.egt_hours;
|
||||
famObj.nst_hours += item.nst_hours;
|
||||
famObj.machine_count += 1;
|
||||
|
||||
// Aggregate to workcenter
|
||||
wcMap[wc].prd_hours += item.prd_hours;
|
||||
wcMap[wc].sby_hours += item.sby_hours;
|
||||
wcMap[wc].udt_hours += item.udt_hours;
|
||||
wcMap[wc].sdt_hours += item.sdt_hours;
|
||||
wcMap[wc].egt_hours += item.egt_hours;
|
||||
wcMap[wc].nst_hours += item.nst_hours;
|
||||
wcMap[wc].machine_count += 1;
|
||||
});
|
||||
|
||||
// Calculate OU% and percentages
|
||||
Object.values(wcMap).forEach(wc => {
|
||||
calcPercentages(wc);
|
||||
wc.families.forEach(fam => {
|
||||
calcPercentages(fam);
|
||||
});
|
||||
});
|
||||
|
||||
// Sort by workcenter sequence ascending (smaller sequence first, e.g. 點測 before TMTT)
|
||||
return Object.values(wcMap).sort((a, b) => a.sequence - b.sequence);
|
||||
}
|
||||
|
||||
function calcPercentages(obj) {
|
||||
Object.assign(obj, buildResourceKpiFromHours(obj));
|
||||
}
|
||||
|
||||
function createRow(item, level, rowId) {
|
||||
const tr = document.createElement('tr');
|
||||
tr.className = `row-level-${level}`;
|
||||
if (rowId) tr.dataset.rowId = rowId;
|
||||
|
||||
const indentClass = level > 0 ? `indent-${level}` : '';
|
||||
const hasChildren = level < 2 && (item.families?.length > 0 || item.resources?.length > 0);
|
||||
const isExpanded = rowId ? hierarchyState[rowId] : false;
|
||||
|
||||
const expandBtn = hasChildren
|
||||
? `<button class="expand-btn ${isExpanded ? 'expanded' : ''}" onclick="toggleRow('${rowId}')">▶</button>`
|
||||
: '<span style="display:inline-block;width:24px;"></span>';
|
||||
|
||||
tr.innerHTML = `
|
||||
<td class="${indentClass}">${expandBtn}${item.name}</td>
|
||||
<td><b>${item.ou_pct}%</b></td>
|
||||
<td><b>${item.availability_pct}%</b></td>
|
||||
<td class="status-prd">${formatHoursPct(item.prd_hours, item.prd_pct)}</td>
|
||||
<td class="status-sby">${formatHoursPct(item.sby_hours, item.sby_pct)}</td>
|
||||
<td class="status-udt">${formatHoursPct(item.udt_hours, item.udt_pct)}</td>
|
||||
<td class="status-sdt">${formatHoursPct(item.sdt_hours, item.sdt_pct)}</td>
|
||||
<td class="status-egt">${formatHoursPct(item.egt_hours, item.egt_pct)}</td>
|
||||
<td class="status-nst">${formatHoursPct(item.nst_hours, item.nst_pct)}</td>
|
||||
<td>${item.machine_count}</td>
|
||||
`;
|
||||
|
||||
return tr;
|
||||
}
|
||||
|
||||
function formatHoursPct(hours, pct) {
|
||||
return `${Math.round(hours * 10) / 10}h (${pct}%)`;
|
||||
}
|
||||
|
||||
// Make toggleRow global
|
||||
window.toggleRow = function(rowId) {
|
||||
hierarchyState[rowId] = !hierarchyState[rowId];
|
||||
renderDetailTable(detailData);
|
||||
};
|
||||
|
||||
function toggleAllRows(expand) {
|
||||
if (!detailData) return;
|
||||
|
||||
const hierarchy = buildHierarchy(detailData);
|
||||
hierarchy.forEach(wc => {
|
||||
hierarchyState[`wc_${wc.workcenter}`] = expand;
|
||||
wc.families.forEach(fam => {
|
||||
hierarchyState[`fam_${wc.workcenter}_${fam.family}`] = expand;
|
||||
});
|
||||
});
|
||||
renderDetailTable(detailData);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Export
|
||||
// ============================================================
|
||||
function exportCsv() {
|
||||
if (!startDateInput.value || !endDateInput.value) {
|
||||
Toast.warning('請先設定查詢條件');
|
||||
return;
|
||||
}
|
||||
|
||||
const queryString = buildQueryString();
|
||||
const url = `/api/resource/history/export?${queryString}`;
|
||||
|
||||
// Create download link
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `resource_history_${startDateInput.value}_to_${endDateInput.value}.csv`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
|
||||
Toast.success('CSV 匯出中...');
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Loading
|
||||
// ============================================================
|
||||
function showLoading() {
|
||||
loadingOverlay.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function hideLoading() {
|
||||
loadingOverlay.classList.add('hidden');
|
||||
}
|
||||
|
||||
Object.assign(window, {
|
||||
init,
|
||||
setDefaultDates,
|
||||
formatDate,
|
||||
setupEventListeners,
|
||||
initCharts,
|
||||
loadFilterOptions,
|
||||
populateMultiSelect,
|
||||
setupMultiSelectDropdowns,
|
||||
updateSelectedWorkcenterGroups,
|
||||
updateSelectedFamilies,
|
||||
updateMultiSelectText,
|
||||
buildQueryString,
|
||||
executeQuery,
|
||||
updateKpiCards,
|
||||
formatHours,
|
||||
updateTrendChart,
|
||||
updateStackedChart,
|
||||
updateComparisonChart,
|
||||
updateHeatmapChart,
|
||||
renderDetailTable,
|
||||
buildHierarchy,
|
||||
calcPercentages,
|
||||
createRow,
|
||||
formatHoursPct,
|
||||
toggleAllRows,
|
||||
exportCsv,
|
||||
showLoading,
|
||||
hideLoading,
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Start
|
||||
// ============================================================
|
||||
init();
|
||||
})();
|
||||
853
frontend/src/resource-status/main.js
Normal file
853
frontend/src/resource-status/main.js
Normal file
@@ -0,0 +1,853 @@
|
||||
import { ensureMesApiAvailable } from '../core/api.js';
|
||||
import { getPageContract } from '../core/field-contracts.js';
|
||||
import { buildResourceKpiFromHours } from '../core/compute.js';
|
||||
import { groupBy, sortBy, toggleTreeState, setTreeStateBulk, escapeHtml, safeText } from '../core/table-tree.js';
|
||||
|
||||
ensureMesApiAvailable();
|
||||
window.__MES_FRONTEND_CORE__ = { buildResourceKpiFromHours, groupBy, sortBy, toggleTreeState, setTreeStateBulk, escapeHtml, safeText };
|
||||
window.__FIELD_CONTRACTS__ = window.__FIELD_CONTRACTS__ || {};
|
||||
window.__FIELD_CONTRACTS__['resource_status:matrix_summary'] = getPageContract('resource_status', 'matrix_summary');
|
||||
|
||||
|
||||
let allEquipment = [];
|
||||
let workcenterGroups = [];
|
||||
let matrixFilter = null; // { workcenter_group, status }
|
||||
let matrixHierarchyState = {}; // Track expanded/collapsed state for matrix rows
|
||||
|
||||
// ============================================================
|
||||
// Hierarchical Matrix Functions
|
||||
// ============================================================
|
||||
|
||||
function buildMatrixHierarchy(equipment) {
|
||||
// Build hierarchy: workcenter_group -> resourcefamily -> equipment
|
||||
const groupMap = {};
|
||||
|
||||
equipment.forEach(eq => {
|
||||
const group = eq.WORKCENTER_GROUP || 'UNKNOWN';
|
||||
const family = eq.RESOURCEFAMILYNAME || 'UNKNOWN';
|
||||
const status = eq.EQUIPMENTASSETSSTATUS || 'OTHER';
|
||||
const groupSeq = eq.WORKCENTER_GROUP_SEQ ?? 999;
|
||||
|
||||
// Initialize group
|
||||
if (!groupMap[group]) {
|
||||
groupMap[group] = {
|
||||
name: group,
|
||||
sequence: groupSeq,
|
||||
families: {},
|
||||
counts: { total: 0, PRD: 0, SBY: 0, UDT: 0, SDT: 0, EGT: 0, NST: 0, OTHER: 0 }
|
||||
};
|
||||
}
|
||||
|
||||
// Initialize family
|
||||
if (!groupMap[group].families[family]) {
|
||||
groupMap[group].families[family] = {
|
||||
name: family,
|
||||
equipment: [],
|
||||
counts: { total: 0, PRD: 0, SBY: 0, UDT: 0, SDT: 0, EGT: 0, NST: 0, OTHER: 0 }
|
||||
};
|
||||
}
|
||||
|
||||
// Add equipment to family
|
||||
groupMap[group].families[family].equipment.push(eq);
|
||||
|
||||
// Map status to count key
|
||||
let statusKey = 'OTHER';
|
||||
if (['PRD'].includes(status)) statusKey = 'PRD';
|
||||
else if (['SBY'].includes(status)) statusKey = 'SBY';
|
||||
else if (['UDT', 'PM', 'BKD'].includes(status)) statusKey = 'UDT';
|
||||
else if (['SDT'].includes(status)) statusKey = 'SDT';
|
||||
else if (['EGT', 'ENG'].includes(status)) statusKey = 'EGT';
|
||||
else if (['NST', 'OFF'].includes(status)) statusKey = 'NST';
|
||||
|
||||
// Update counts
|
||||
groupMap[group].counts.total++;
|
||||
groupMap[group].counts[statusKey]++;
|
||||
groupMap[group].families[family].counts.total++;
|
||||
groupMap[group].families[family].counts[statusKey]++;
|
||||
});
|
||||
|
||||
// Convert to array structure
|
||||
// Sort groups by sequence ascending (smaller sequence first, e.g. 點測 before TMTT)
|
||||
// Sort families by total count descending
|
||||
const hierarchy = Object.values(groupMap).map(g => ({
|
||||
...g,
|
||||
families: Object.values(g.families).sort((a, b) => b.counts.total - a.counts.total)
|
||||
})).sort((a, b) => a.sequence - b.sequence);
|
||||
|
||||
return hierarchy;
|
||||
}
|
||||
|
||||
function toggleMatrixRow(rowId) {
|
||||
matrixHierarchyState[rowId] = !matrixHierarchyState[rowId];
|
||||
renderMatrixHierarchy();
|
||||
}
|
||||
|
||||
function toggleAllMatrixRows(expand) {
|
||||
const hierarchy = buildMatrixHierarchy(allEquipment);
|
||||
hierarchy.forEach(group => {
|
||||
matrixHierarchyState[`grp_${group.name}`] = expand;
|
||||
group.families.forEach(fam => {
|
||||
matrixHierarchyState[`fam_${group.name}_${fam.name}`] = expand;
|
||||
});
|
||||
});
|
||||
renderMatrixHierarchy();
|
||||
}
|
||||
|
||||
function renderMatrixHierarchy() {
|
||||
const container = document.getElementById('matrixContainer');
|
||||
const hierarchy = buildMatrixHierarchy(allEquipment);
|
||||
|
||||
if (hierarchy.length === 0) {
|
||||
container.innerHTML = '<div class="empty-state">無資料</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = `
|
||||
<table class="matrix-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>工站群組 / 型號 / 機台</th>
|
||||
<th>總數</th>
|
||||
<th>PRD</th>
|
||||
<th>SBY</th>
|
||||
<th>UDT</th>
|
||||
<th>SDT</th>
|
||||
<th>EGT</th>
|
||||
<th>NST</th>
|
||||
<th>OTHER</th>
|
||||
<th>OU%</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
`;
|
||||
|
||||
hierarchy.forEach(group => {
|
||||
const grpId = `grp_${group.name}`;
|
||||
const isGroupExpanded = matrixHierarchyState[grpId];
|
||||
const hasChildren = group.families.length > 0;
|
||||
|
||||
// Calculate OU%
|
||||
const avail = group.counts.PRD + group.counts.SBY + group.counts.UDT + group.counts.SDT + group.counts.EGT;
|
||||
const ou = avail > 0 ? ((group.counts.PRD / avail) * 100).toFixed(1) : 0;
|
||||
const ouClass = ou >= 80 ? 'high' : (ou >= 50 ? 'medium' : 'low');
|
||||
|
||||
// Group row (Level 0)
|
||||
const expandBtn = hasChildren
|
||||
? `<button class="expand-btn ${isGroupExpanded ? 'expanded' : ''}" onclick="toggleMatrixRow('${grpId}')">▶</button>`
|
||||
: '<span class="expand-placeholder"></span>';
|
||||
|
||||
// Helper to check if this cell is selected (supports all levels)
|
||||
const isSelected = (wg, st, fam = null, res = null) => {
|
||||
if (!matrixFilter) return false;
|
||||
if (matrixFilter.workcenter_group !== wg) return false;
|
||||
if (matrixFilter.status !== st) return false;
|
||||
if (fam !== null && matrixFilter.family !== fam) return false;
|
||||
if (res !== null && matrixFilter.resource !== res) return false;
|
||||
// Match level: if matrixFilter has family but we're checking group level, no match
|
||||
if (matrixFilter.family && fam === null) return false;
|
||||
if (matrixFilter.resource && res === null) return false;
|
||||
return true;
|
||||
};
|
||||
const grpName = group.name;
|
||||
|
||||
html += `
|
||||
<tr class="row-level-0">
|
||||
<td><span class="row-name">${expandBtn}${group.name}</span></td>
|
||||
<td class="col-total">${group.counts.total}</td>
|
||||
<td class="col-prd clickable ${group.counts.PRD === 0 ? 'zero' : ''} ${isSelected(grpName, 'PRD') ? 'selected' : ''}" data-wg="${grpName}" data-status="PRD" onclick="filterByMatrixCell('${grpName}', 'PRD')">${group.counts.PRD}</td>
|
||||
<td class="col-sby clickable ${group.counts.SBY === 0 ? 'zero' : ''} ${isSelected(grpName, 'SBY') ? 'selected' : ''}" data-wg="${grpName}" data-status="SBY" onclick="filterByMatrixCell('${grpName}', 'SBY')">${group.counts.SBY}</td>
|
||||
<td class="col-udt clickable ${group.counts.UDT === 0 ? 'zero' : ''} ${isSelected(grpName, 'UDT') ? 'selected' : ''}" data-wg="${grpName}" data-status="UDT" onclick="filterByMatrixCell('${grpName}', 'UDT')">${group.counts.UDT}</td>
|
||||
<td class="col-sdt clickable ${group.counts.SDT === 0 ? 'zero' : ''} ${isSelected(grpName, 'SDT') ? 'selected' : ''}" data-wg="${grpName}" data-status="SDT" onclick="filterByMatrixCell('${grpName}', 'SDT')">${group.counts.SDT}</td>
|
||||
<td class="col-egt clickable ${group.counts.EGT === 0 ? 'zero' : ''} ${isSelected(grpName, 'EGT') ? 'selected' : ''}" data-wg="${grpName}" data-status="EGT" onclick="filterByMatrixCell('${grpName}', 'EGT')">${group.counts.EGT}</td>
|
||||
<td class="col-nst clickable ${group.counts.NST === 0 ? 'zero' : ''} ${isSelected(grpName, 'NST') ? 'selected' : ''}" data-wg="${grpName}" data-status="NST" onclick="filterByMatrixCell('${grpName}', 'NST')">${group.counts.NST}</td>
|
||||
<td class="col-other clickable ${group.counts.OTHER === 0 ? 'zero' : ''} ${isSelected(grpName, 'OTHER') ? 'selected' : ''}" data-wg="${grpName}" data-status="OTHER" onclick="filterByMatrixCell('${grpName}', 'OTHER')">${group.counts.OTHER}</td>
|
||||
<td><span class="ou-badge ${ouClass}">${ou}%</span></td>
|
||||
</tr>
|
||||
`;
|
||||
|
||||
// Family rows (Level 1)
|
||||
if (isGroupExpanded) {
|
||||
group.families.forEach(fam => {
|
||||
const famId = `fam_${group.name}_${fam.name}`;
|
||||
const isFamExpanded = matrixHierarchyState[famId];
|
||||
const hasEquipment = fam.equipment.length > 0;
|
||||
|
||||
const famAvail = fam.counts.PRD + fam.counts.SBY + fam.counts.UDT + fam.counts.SDT + fam.counts.EGT;
|
||||
const famOu = famAvail > 0 ? ((fam.counts.PRD / famAvail) * 100).toFixed(1) : 0;
|
||||
const famOuClass = famOu >= 80 ? 'high' : (famOu >= 50 ? 'medium' : 'low');
|
||||
|
||||
const famExpandBtn = hasEquipment
|
||||
? `<button class="expand-btn ${isFamExpanded ? 'expanded' : ''}" onclick="toggleMatrixRow('${famId}')">▶</button>`
|
||||
: '<span class="expand-placeholder"></span>';
|
||||
|
||||
const famName = fam.name;
|
||||
const escFamName = famName.replace(/'/g, "\\'");
|
||||
|
||||
html += `
|
||||
<tr class="row-level-1 indent-1">
|
||||
<td><span class="row-name">${famExpandBtn}${fam.name}</span></td>
|
||||
<td class="col-total">${fam.counts.total}</td>
|
||||
<td class="col-prd clickable ${fam.counts.PRD === 0 ? 'zero' : ''} ${isSelected(grpName, 'PRD', famName) ? 'selected' : ''}" data-wg="${grpName}" data-fam="${famName}" data-status="PRD" onclick="filterByMatrixCell('${grpName}', 'PRD', '${escFamName}')">${fam.counts.PRD}</td>
|
||||
<td class="col-sby clickable ${fam.counts.SBY === 0 ? 'zero' : ''} ${isSelected(grpName, 'SBY', famName) ? 'selected' : ''}" data-wg="${grpName}" data-fam="${famName}" data-status="SBY" onclick="filterByMatrixCell('${grpName}', 'SBY', '${escFamName}')">${fam.counts.SBY}</td>
|
||||
<td class="col-udt clickable ${fam.counts.UDT === 0 ? 'zero' : ''} ${isSelected(grpName, 'UDT', famName) ? 'selected' : ''}" data-wg="${grpName}" data-fam="${famName}" data-status="UDT" onclick="filterByMatrixCell('${grpName}', 'UDT', '${escFamName}')">${fam.counts.UDT}</td>
|
||||
<td class="col-sdt clickable ${fam.counts.SDT === 0 ? 'zero' : ''} ${isSelected(grpName, 'SDT', famName) ? 'selected' : ''}" data-wg="${grpName}" data-fam="${famName}" data-status="SDT" onclick="filterByMatrixCell('${grpName}', 'SDT', '${escFamName}')">${fam.counts.SDT}</td>
|
||||
<td class="col-egt clickable ${fam.counts.EGT === 0 ? 'zero' : ''} ${isSelected(grpName, 'EGT', famName) ? 'selected' : ''}" data-wg="${grpName}" data-fam="${famName}" data-status="EGT" onclick="filterByMatrixCell('${grpName}', 'EGT', '${escFamName}')">${fam.counts.EGT}</td>
|
||||
<td class="col-nst clickable ${fam.counts.NST === 0 ? 'zero' : ''} ${isSelected(grpName, 'NST', famName) ? 'selected' : ''}" data-wg="${grpName}" data-fam="${famName}" data-status="NST" onclick="filterByMatrixCell('${grpName}', 'NST', '${escFamName}')">${fam.counts.NST}</td>
|
||||
<td class="col-other clickable ${fam.counts.OTHER === 0 ? 'zero' : ''} ${isSelected(grpName, 'OTHER', famName) ? 'selected' : ''}" data-wg="${grpName}" data-fam="${famName}" data-status="OTHER" onclick="filterByMatrixCell('${grpName}', 'OTHER', '${escFamName}')">${fam.counts.OTHER}</td>
|
||||
<td><span class="ou-badge ${famOuClass}">${famOu}%</span></td>
|
||||
</tr>
|
||||
`;
|
||||
|
||||
// Equipment rows (Level 2)
|
||||
if (isFamExpanded) {
|
||||
fam.equipment.forEach(eq => {
|
||||
const status = eq.EQUIPMENTASSETSSTATUS || '--';
|
||||
const statusCat = (eq.STATUS_CATEGORY || 'OTHER').toLowerCase();
|
||||
const resId = eq.RESOURCEID || '';
|
||||
const resName = eq.RESOURCENAME || eq.RESOURCEID || '--';
|
||||
const escResId = resId.replace(/'/g, "\\'");
|
||||
|
||||
// Determine status category key for this equipment
|
||||
let eqStatusKey = 'OTHER';
|
||||
if (['PRD'].includes(status)) eqStatusKey = 'PRD';
|
||||
else if (['SBY'].includes(status)) eqStatusKey = 'SBY';
|
||||
else if (['UDT', 'PM', 'BKD'].includes(status)) eqStatusKey = 'UDT';
|
||||
else if (['SDT'].includes(status)) eqStatusKey = 'SDT';
|
||||
else if (['EGT', 'ENG'].includes(status)) eqStatusKey = 'EGT';
|
||||
else if (['NST', 'OFF'].includes(status)) eqStatusKey = 'NST';
|
||||
|
||||
const isEqSelected = isSelected(grpName, eqStatusKey, famName, resId);
|
||||
|
||||
html += `
|
||||
<tr class="row-level-2 indent-2 clickable-row ${isEqSelected ? 'selected' : ''}" data-wg="${grpName}" data-fam="${famName}" data-res="${resId}" onclick="filterByMatrixCell('${grpName}', '${eqStatusKey}', '${escFamName}', '${escResId}')">
|
||||
<td><span class="row-name"><span class="expand-placeholder"></span>${resName}</span></td>
|
||||
<td>1</td>
|
||||
<td class="col-prd ${status !== 'PRD' ? 'zero' : ''}">${status === 'PRD' ? '●' : '-'}</td>
|
||||
<td class="col-sby ${status !== 'SBY' ? 'zero' : ''}">${status === 'SBY' ? '●' : '-'}</td>
|
||||
<td class="col-udt ${!['UDT', 'PM', 'BKD'].includes(status) ? 'zero' : ''}">${['UDT', 'PM', 'BKD'].includes(status) ? '●' : '-'}</td>
|
||||
<td class="col-sdt ${status !== 'SDT' ? 'zero' : ''}">${status === 'SDT' ? '●' : '-'}</td>
|
||||
<td class="col-egt ${!['EGT', 'ENG'].includes(status) ? 'zero' : ''}">${['EGT', 'ENG'].includes(status) ? '●' : '-'}</td>
|
||||
<td class="col-nst ${!['NST', 'OFF'].includes(status) ? 'zero' : ''}">${['NST', 'OFF'].includes(status) ? '●' : '-'}</td>
|
||||
<td class="col-other">${!['PRD', 'SBY', 'UDT', 'PM', 'BKD', 'SDT', 'EGT', 'ENG', 'NST', 'OFF'].includes(status) ? '●' : '-'}</td>
|
||||
<td><span class="eq-status ${statusCat}">${status}</span></td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
html += '</tbody></table>';
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
function toggleFilter(checkbox, id) {
|
||||
const label = document.getElementById(id);
|
||||
if (checkbox.checked) {
|
||||
label.classList.add('active');
|
||||
} else {
|
||||
label.classList.remove('active');
|
||||
}
|
||||
loadData();
|
||||
}
|
||||
|
||||
function getFilters() {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
const group = document.getElementById('filterGroup').value;
|
||||
if (group) params.append('workcenter_groups', group);
|
||||
|
||||
if (document.querySelector('#chkProduction input').checked) {
|
||||
params.append('is_production', 'true');
|
||||
}
|
||||
if (document.querySelector('#chkKey input').checked) {
|
||||
params.append('is_key', 'true');
|
||||
}
|
||||
if (document.querySelector('#chkMonitor input').checked) {
|
||||
params.append('is_monitor', 'true');
|
||||
}
|
||||
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
async function loadOptions() {
|
||||
try {
|
||||
const result = await MesApi.get('/api/resource/status/options', { silent: true });
|
||||
|
||||
if (result.success) {
|
||||
const select = document.getElementById('filterGroup');
|
||||
workcenterGroups = result.data.workcenter_groups || [];
|
||||
|
||||
workcenterGroups.forEach(group => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = group;
|
||||
opt.textContent = group;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('載入選項失敗:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSummary() {
|
||||
try {
|
||||
const queryString = getFilters();
|
||||
const endpoint = queryString
|
||||
? `/api/resource/status/summary?${queryString}`
|
||||
: '/api/resource/status/summary';
|
||||
const result = await MesApi.get(endpoint, { silent: true });
|
||||
|
||||
if (result.success) {
|
||||
const d = result.data;
|
||||
const total = d.total_count || 0;
|
||||
const status = d.by_status || {};
|
||||
|
||||
// Get individual status counts
|
||||
const prd = status.PRD || 0;
|
||||
const sby = status.SBY || 0;
|
||||
const udt = status.UDT || 0;
|
||||
const sdt = status.SDT || 0;
|
||||
const egt = status.EGT || 0;
|
||||
const nst = status.NST || 0;
|
||||
|
||||
// Calculate percentage denominator (includes NST)
|
||||
const totalStatus = prd + sby + udt + sdt + egt + nst;
|
||||
|
||||
// Update OU% and AVAIL%
|
||||
const hasOuPct = d.ou_pct !== null && d.ou_pct !== undefined;
|
||||
const hasAvailPct = d.availability_pct !== null && d.availability_pct !== undefined;
|
||||
document.getElementById('ouPct').textContent = hasOuPct ? `${d.ou_pct}%` : '--';
|
||||
document.getElementById('availabilityPct').textContent = hasAvailPct ? `${d.availability_pct}%` : '--';
|
||||
|
||||
// Update status cards with count and percentage
|
||||
document.getElementById('prdCount').textContent = prd;
|
||||
document.getElementById('prdPct').textContent = totalStatus ? `生產 (${((prd/totalStatus)*100).toFixed(1)}%)` : '生產';
|
||||
|
||||
document.getElementById('sbyCount').textContent = sby;
|
||||
document.getElementById('sbyPct').textContent = totalStatus ? `待機 (${((sby/totalStatus)*100).toFixed(1)}%)` : '待機';
|
||||
|
||||
document.getElementById('udtCount').textContent = udt;
|
||||
document.getElementById('udtPct').textContent = totalStatus ? `非計畫停機 (${((udt/totalStatus)*100).toFixed(1)}%)` : '非計畫停機';
|
||||
|
||||
document.getElementById('sdtCount').textContent = sdt;
|
||||
document.getElementById('sdtPct').textContent = totalStatus ? `計畫停機 (${((sdt/totalStatus)*100).toFixed(1)}%)` : '計畫停機';
|
||||
|
||||
document.getElementById('egtCount').textContent = egt;
|
||||
document.getElementById('egtPct').textContent = totalStatus ? `工程 (${((egt/totalStatus)*100).toFixed(1)}%)` : '工程';
|
||||
|
||||
document.getElementById('nstCount').textContent = nst;
|
||||
document.getElementById('nstPct').textContent = totalStatus ? `未排程 (${((nst/totalStatus)*100).toFixed(1)}%)` : '未排程';
|
||||
|
||||
// Update JOB count (equipment with active maintenance/repair job)
|
||||
const jobCount = d.with_active_job || 0;
|
||||
document.getElementById('jobCount').textContent = jobCount;
|
||||
|
||||
// Update total count
|
||||
document.getElementById('totalCount').textContent = total;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('載入摘要失敗:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function loadMatrix() {
|
||||
// Matrix is now rendered from allEquipment data using hierarchy
|
||||
// This function is called after loadEquipment populates allEquipment
|
||||
renderMatrixHierarchy();
|
||||
}
|
||||
|
||||
async function loadEquipment() {
|
||||
const container = document.getElementById('equipmentContainer');
|
||||
|
||||
// Clear matrix filter when reloading data
|
||||
matrixFilter = null;
|
||||
document.getElementById('matrixFilterIndicator').classList.remove('active');
|
||||
|
||||
try {
|
||||
const queryString = getFilters();
|
||||
const endpoint = queryString
|
||||
? `/api/resource/status?${queryString}`
|
||||
: '/api/resource/status';
|
||||
const result = await MesApi.get(endpoint, { silent: true });
|
||||
|
||||
if (result.success && result.data.length > 0) {
|
||||
allEquipment = result.data;
|
||||
document.getElementById('equipmentCount').textContent = result.count;
|
||||
renderEquipmentList(allEquipment);
|
||||
} else {
|
||||
allEquipment = [];
|
||||
document.getElementById('equipmentCount').textContent = 0;
|
||||
container.innerHTML = '<div class="empty-state">無符合條件的設備</div>';
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('載入設備失敗:', e);
|
||||
container.innerHTML = '<div class="empty-state">載入失敗</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Floating Tooltip Functions
|
||||
// ============================================================
|
||||
let currentTooltipData = null;
|
||||
|
||||
function showTooltip(event, type, data) {
|
||||
event.stopPropagation();
|
||||
const tooltip = document.getElementById('floatingTooltip');
|
||||
const titleEl = document.getElementById('tooltipTitle');
|
||||
const contentEl = document.getElementById('tooltipContent');
|
||||
|
||||
// Set content based on type
|
||||
if (type === 'lot') {
|
||||
titleEl.textContent = '在製批次明細';
|
||||
contentEl.innerHTML = renderLotContent(data);
|
||||
} else if (type === 'job') {
|
||||
titleEl.textContent = 'JOB 單詳細資訊';
|
||||
contentEl.innerHTML = renderJobContent(data);
|
||||
}
|
||||
|
||||
// Position the tooltip
|
||||
tooltip.classList.add('show');
|
||||
|
||||
// Get dimensions
|
||||
const rect = tooltip.getBoundingClientRect();
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
// Calculate initial position near the click
|
||||
let x = event.clientX + 10;
|
||||
let y = event.clientY + 10;
|
||||
|
||||
// Adjust if overflowing right
|
||||
if (x + rect.width > viewportWidth - 20) {
|
||||
x = event.clientX - rect.width - 10;
|
||||
}
|
||||
|
||||
// Adjust if overflowing bottom
|
||||
if (y + rect.height > viewportHeight - 20) {
|
||||
y = viewportHeight - rect.height - 20;
|
||||
}
|
||||
|
||||
// Ensure not off-screen left or top
|
||||
x = Math.max(10, x);
|
||||
y = Math.max(10, y);
|
||||
|
||||
tooltip.style.left = x + 'px';
|
||||
tooltip.style.top = y + 'px';
|
||||
|
||||
currentTooltipData = { type, data };
|
||||
}
|
||||
|
||||
function hideTooltip() {
|
||||
const tooltip = document.getElementById('floatingTooltip');
|
||||
tooltip.classList.remove('show');
|
||||
currentTooltipData = null;
|
||||
}
|
||||
|
||||
// Close tooltip when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
const tooltip = document.getElementById('floatingTooltip');
|
||||
if (tooltip && !tooltip.contains(e.target) && !e.target.classList.contains('info-trigger')) {
|
||||
hideTooltip();
|
||||
}
|
||||
});
|
||||
|
||||
// Helper functions to show specific tooltip types
|
||||
function showLotTooltip(event, resourceId) {
|
||||
const eq = allEquipment.find(e => e.RESOURCEID === resourceId);
|
||||
if (eq && eq.LOT_DETAILS) {
|
||||
showTooltip(event, 'lot', eq.LOT_DETAILS);
|
||||
}
|
||||
}
|
||||
|
||||
function showJobTooltip(event, resourceId) {
|
||||
const eq = allEquipment.find(e => e.RESOURCEID === resourceId);
|
||||
if (eq && eq.JOBORDER) {
|
||||
showTooltip(event, 'job', eq);
|
||||
}
|
||||
}
|
||||
|
||||
function renderLotContent(lotDetails) {
|
||||
if (!lotDetails || lotDetails.length === 0) return '<div style="color: #94a3b8;">無批次資料</div>';
|
||||
|
||||
let html = '<div class="lot-tooltip-content">';
|
||||
lotDetails.forEach(lot => {
|
||||
const trackinTime = lot.LOTTRACKINTIME ? new Date(lot.LOTTRACKINTIME).toLocaleString('zh-TW') : '--';
|
||||
const qty = lot.LOTTRACKINQTY_PCS != null ? lot.LOTTRACKINQTY_PCS.toLocaleString() : '--';
|
||||
html += `
|
||||
<div class="lot-item">
|
||||
<div class="lot-item-header">${lot.RUNCARDLOTID || '--'}</div>
|
||||
<div class="lot-item-row">
|
||||
<div class="lot-item-field"><span class="lot-item-label">數量:</span><span class="lot-item-value">${qty} pcs</span></div>
|
||||
<div class="lot-item-field"><span class="lot-item-label">TrackIn:</span><span class="lot-item-value">${trackinTime}</span></div>
|
||||
<div class="lot-item-field"><span class="lot-item-label">操作員:</span><span class="lot-item-value">${lot.LOTTRACKINEMPLOYEE || '--'}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderJobContent(eq) {
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return '--';
|
||||
try {
|
||||
return new Date(dateStr).toLocaleString('zh-TW');
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
};
|
||||
|
||||
const field = (label, value, isHighlight = false) => {
|
||||
const valueClass = isHighlight ? 'highlight' : '';
|
||||
return `
|
||||
<div class="job-detail-field">
|
||||
<span class="job-detail-label">${label}</span>
|
||||
<span class="job-detail-value ${valueClass}">${value || '--'}</span>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
return `
|
||||
<div class="job-detail-grid">
|
||||
${field('JOBORDER', eq.JOBORDER, true)}
|
||||
${field('JOBSTATUS', eq.JOBSTATUS, true)}
|
||||
${field('JOBMODEL', eq.JOBMODEL)}
|
||||
${field('JOBSTAGE', eq.JOBSTAGE)}
|
||||
${field('JOBID', eq.JOBID)}
|
||||
${field('建立時間', formatDate(eq.CREATEDATE))}
|
||||
${field('建立人員', eq.CREATEUSERNAME || eq.CREATEUSER)}
|
||||
${field('技術員', eq.TECHNICIANUSERNAME || eq.TECHNICIANUSER)}
|
||||
${field('症狀碼', eq.SYMPTOMCODE)}
|
||||
${field('原因碼', eq.CAUSECODE)}
|
||||
${field('維修碼', eq.REPAIRCODE)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderEquipmentList(equipment) {
|
||||
const container = document.getElementById('equipmentContainer');
|
||||
|
||||
if (equipment.length === 0) {
|
||||
container.innerHTML = '<div class="empty-state">無符合條件的設備</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<div class="equipment-grid">';
|
||||
|
||||
equipment.forEach((eq) => {
|
||||
const statusCat = (eq.STATUS_CATEGORY || 'OTHER').toLowerCase();
|
||||
const statusDisplay = getStatusDisplay(eq.EQUIPMENTASSETSSTATUS, eq.STATUS_CATEGORY);
|
||||
const resourceId = eq.RESOURCEID || '';
|
||||
const escapedResourceId = resourceId.replace(/'/g, "\\'");
|
||||
|
||||
// Build LOT info with click trigger
|
||||
let lotHtml = '';
|
||||
if (eq.LOT_COUNT > 0) {
|
||||
lotHtml = `<span class="info-trigger" onclick="showLotTooltip(event, '${escapedResourceId}')" title="點擊查看批次詳情">📦 ${eq.LOT_COUNT} 批</span>`;
|
||||
}
|
||||
|
||||
// Build JOB info with click trigger
|
||||
let jobHtml = '';
|
||||
if (eq.JOBORDER) {
|
||||
jobHtml = `<span class="info-trigger" onclick="showJobTooltip(event, '${escapedResourceId}')" title="點擊查看JOB詳情">📋 ${eq.JOBORDER}</span>`;
|
||||
}
|
||||
|
||||
html += `
|
||||
<div class="equipment-card status-${statusCat}">
|
||||
<div class="eq-header">
|
||||
<div class="eq-name">${eq.RESOURCENAME || eq.RESOURCEID || '--'}</div>
|
||||
<span class="eq-status ${statusCat}">${statusDisplay}</span>
|
||||
</div>
|
||||
<div class="eq-info">
|
||||
<span title="工站">📍 ${eq.WORKCENTERNAME || '--'}</span>
|
||||
<span title="群組">🏭 ${eq.WORKCENTER_GROUP || '--'}</span>
|
||||
<span title="家族">🔧 ${eq.RESOURCEFAMILYNAME || '--'}</span>
|
||||
<span title="區域">🏢 ${eq.LOCATIONNAME || '--'}</span>
|
||||
${lotHtml}
|
||||
${jobHtml}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
html += '</div>';
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
function filterByMatrixCell(workcenterGroup, status, family = null, resource = null) {
|
||||
// Toggle off if clicking same cell (exact match including family and resource)
|
||||
if (matrixFilter &&
|
||||
matrixFilter.workcenter_group === workcenterGroup &&
|
||||
matrixFilter.status === status &&
|
||||
matrixFilter.family === family &&
|
||||
matrixFilter.resource === resource) {
|
||||
clearMatrixFilter();
|
||||
return;
|
||||
}
|
||||
|
||||
matrixFilter = {
|
||||
workcenter_group: workcenterGroup,
|
||||
status: status,
|
||||
family: family,
|
||||
resource: resource
|
||||
};
|
||||
|
||||
// Update selected cell highlighting for group and family level cells
|
||||
document.querySelectorAll('.matrix-table td.clickable').forEach(cell => {
|
||||
cell.classList.remove('selected');
|
||||
const cellWg = cell.dataset.wg;
|
||||
const cellStatus = cell.dataset.status;
|
||||
const cellFam = cell.dataset.fam;
|
||||
|
||||
// Match based on level
|
||||
if (cellWg === workcenterGroup && cellStatus === status) {
|
||||
if (family === null && resource === null && !cellFam) {
|
||||
// Group level match
|
||||
cell.classList.add('selected');
|
||||
} else if (family !== null && cellFam === family && resource === null) {
|
||||
// Family level match
|
||||
cell.classList.add('selected');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Update selected row highlighting for equipment level
|
||||
document.querySelectorAll('.matrix-table tr.clickable-row').forEach(row => {
|
||||
row.classList.remove('selected');
|
||||
if (resource !== null && row.dataset.res === resource) {
|
||||
row.classList.add('selected');
|
||||
}
|
||||
});
|
||||
|
||||
// Show filter indicator with hierarchical label
|
||||
const statusLabels = {
|
||||
'PRD': '生產中',
|
||||
'SBY': '待機',
|
||||
'UDT': '非計畫停機',
|
||||
'SDT': '計畫停機',
|
||||
'EGT': '工程',
|
||||
'NST': '未排程',
|
||||
'OTHER': '其他'
|
||||
};
|
||||
|
||||
let filterLabel = workcenterGroup;
|
||||
if (family) filterLabel += ` / ${family}`;
|
||||
if (resource) {
|
||||
// Find resource name from allEquipment
|
||||
const eqInfo = allEquipment.find(e => e.RESOURCEID === resource);
|
||||
const resName = eqInfo ? (eqInfo.RESOURCENAME || resource) : resource;
|
||||
filterLabel += ` / ${resName}`;
|
||||
}
|
||||
filterLabel += ` - ${statusLabels[status] || status}`;
|
||||
|
||||
document.getElementById('matrixFilterText').textContent = filterLabel;
|
||||
document.getElementById('matrixFilterIndicator').classList.add('active');
|
||||
|
||||
// Filter and render equipment list
|
||||
// Use same grouping logic as buildMatrixHierarchy
|
||||
const filtered = allEquipment.filter(eq => {
|
||||
// Match workcenter group
|
||||
const eqGroup = eq.WORKCENTER_GROUP || 'UNKNOWN';
|
||||
if (eqGroup !== workcenterGroup) return false;
|
||||
|
||||
// Match family if specified
|
||||
if (family !== null) {
|
||||
const eqFamily = eq.RESOURCEFAMILYNAME || 'UNKNOWN';
|
||||
if (eqFamily !== family) return false;
|
||||
}
|
||||
|
||||
// Match resource if specified
|
||||
if (resource !== null) {
|
||||
if (eq.RESOURCEID !== resource) return false;
|
||||
}
|
||||
|
||||
// Match status based on EQUIPMENTASSETSSTATUS (same logic as matrix calculation)
|
||||
const eqStatus = eq.EQUIPMENTASSETSSTATUS || '';
|
||||
|
||||
// Map equipment status to matrix status category (same as buildMatrixHierarchy)
|
||||
let eqStatusKey = 'OTHER';
|
||||
if (['PRD'].includes(eqStatus)) eqStatusKey = 'PRD';
|
||||
else if (['SBY'].includes(eqStatus)) eqStatusKey = 'SBY';
|
||||
else if (['UDT', 'PM', 'BKD'].includes(eqStatus)) eqStatusKey = 'UDT';
|
||||
else if (['SDT'].includes(eqStatus)) eqStatusKey = 'SDT';
|
||||
else if (['EGT', 'ENG'].includes(eqStatus)) eqStatusKey = 'EGT';
|
||||
else if (['NST', 'OFF'].includes(eqStatus)) eqStatusKey = 'NST';
|
||||
|
||||
return eqStatusKey === status;
|
||||
});
|
||||
|
||||
document.getElementById('equipmentCount').textContent = filtered.length;
|
||||
renderEquipmentList(filtered);
|
||||
}
|
||||
|
||||
function clearMatrixFilter() {
|
||||
matrixFilter = null;
|
||||
|
||||
// Remove selected highlighting from cells
|
||||
document.querySelectorAll('.matrix-table td.clickable').forEach(cell => {
|
||||
cell.classList.remove('selected');
|
||||
});
|
||||
|
||||
// Remove selected highlighting from rows
|
||||
document.querySelectorAll('.matrix-table tr.clickable-row').forEach(row => {
|
||||
row.classList.remove('selected');
|
||||
});
|
||||
|
||||
// Hide filter indicator
|
||||
document.getElementById('matrixFilterIndicator').classList.remove('active');
|
||||
|
||||
// Show all equipment
|
||||
document.getElementById('equipmentCount').textContent = allEquipment.length;
|
||||
renderEquipmentList(allEquipment);
|
||||
}
|
||||
|
||||
function getStatusDisplay(status, category) {
|
||||
const statusMap = {
|
||||
'PRD': '生產中',
|
||||
'SBY': '待機',
|
||||
'UDT': '非計畫停機',
|
||||
'SDT': '計畫停機',
|
||||
'EGT': '工程',
|
||||
'NST': '未排程'
|
||||
};
|
||||
|
||||
if (status && statusMap[status]) {
|
||||
return statusMap[status];
|
||||
}
|
||||
|
||||
const catMap = {
|
||||
'PRODUCTIVE': '生產中',
|
||||
'STANDBY': '待機',
|
||||
'DOWN': '停機',
|
||||
'ENGINEERING': '工程',
|
||||
'NOT_SCHEDULED': '未排程',
|
||||
'INACTIVE': '停用'
|
||||
};
|
||||
|
||||
return catMap[category] || status || '--';
|
||||
}
|
||||
|
||||
async function checkCacheStatus() {
|
||||
try {
|
||||
const data = await MesApi.get('/health', {
|
||||
silent: true,
|
||||
retries: 0,
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
const dot = document.getElementById('cacheDot');
|
||||
const status = document.getElementById('cacheStatus');
|
||||
const resCache = data.resource_cache || {};
|
||||
const eqCache = data.equipment_status_cache || {};
|
||||
|
||||
// 使用 resource_cache 的數量(過濾後的設備數)
|
||||
if (resCache.enabled && resCache.loaded) {
|
||||
dot.className = 'cache-dot';
|
||||
status.textContent = `快取正常 (${resCache.count} 筆)`;
|
||||
} else if (resCache.enabled) {
|
||||
dot.className = 'cache-dot loading';
|
||||
status.textContent = '快取載入中...';
|
||||
} else {
|
||||
dot.className = 'cache-dot error';
|
||||
status.textContent = '快取未啟用';
|
||||
}
|
||||
|
||||
// 使用 equipment_status_cache 的更新時間(即時狀態更新時間)
|
||||
if (eqCache.updated_at) {
|
||||
document.getElementById('lastUpdate').textContent =
|
||||
`更新: ${new Date(eqCache.updated_at).toLocaleString('zh-TW')}`;
|
||||
}
|
||||
} catch (e) {
|
||||
document.getElementById('cacheDot').className = 'cache-dot error';
|
||||
document.getElementById('cacheStatus').textContent = '無法連線';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
const btn = document.getElementById('btnRefresh');
|
||||
btn.disabled = true;
|
||||
|
||||
try {
|
||||
// loadSummary can run in parallel
|
||||
// loadEquipment must complete before loadMatrix (matrix uses allEquipment data)
|
||||
await Promise.all([
|
||||
loadSummary(),
|
||||
loadEquipment()
|
||||
]);
|
||||
// Now render the matrix from the loaded equipment data
|
||||
loadMatrix();
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Auto-refresh
|
||||
// ============================================================
|
||||
const REFRESH_INTERVAL = 5 * 60 * 1000; // 5 minutes
|
||||
let refreshTimer = null;
|
||||
|
||||
function startAutoRefresh() {
|
||||
if (refreshTimer) {
|
||||
clearInterval(refreshTimer);
|
||||
}
|
||||
console.log('[Resource Status] Auto-refresh started, interval:', REFRESH_INTERVAL / 1000, 'seconds');
|
||||
refreshTimer = setInterval(() => {
|
||||
if (!document.hidden) {
|
||||
console.log('[Resource Status] Auto-refresh triggered at', new Date().toLocaleTimeString());
|
||||
checkCacheStatus();
|
||||
loadData();
|
||||
} else {
|
||||
console.log('[Resource Status] Auto-refresh skipped (tab hidden)');
|
||||
}
|
||||
}, REFRESH_INTERVAL);
|
||||
}
|
||||
|
||||
// Handle page visibility - refresh when tab becomes visible
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (!document.hidden) {
|
||||
console.log('[Resource Status] Tab became visible, refreshing...');
|
||||
checkCacheStatus();
|
||||
loadData();
|
||||
startAutoRefresh();
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
await loadOptions();
|
||||
await checkCacheStatus();
|
||||
await loadData();
|
||||
|
||||
// Start auto-refresh
|
||||
startAutoRefresh();
|
||||
});
|
||||
|
||||
Object.assign(window, {
|
||||
buildMatrixHierarchy,
|
||||
toggleMatrixRow,
|
||||
toggleAllMatrixRows,
|
||||
renderMatrixHierarchy,
|
||||
toggleFilter,
|
||||
getFilters,
|
||||
loadOptions,
|
||||
loadSummary,
|
||||
loadMatrix,
|
||||
loadEquipment,
|
||||
showTooltip,
|
||||
hideTooltip,
|
||||
showLotTooltip,
|
||||
showJobTooltip,
|
||||
renderLotContent,
|
||||
renderJobContent,
|
||||
renderEquipmentList,
|
||||
filterByMatrixCell,
|
||||
clearMatrixFilter,
|
||||
getStatusDisplay,
|
||||
checkCacheStatus,
|
||||
loadData,
|
||||
startAutoRefresh,
|
||||
});
|
||||
236
frontend/src/tables/main.js
Normal file
236
frontend/src/tables/main.js
Normal file
@@ -0,0 +1,236 @@
|
||||
import { ensureMesApiAvailable } from '../core/api.js';
|
||||
import { getPageContract } from '../core/field-contracts.js';
|
||||
import { buildResourceKpiFromHours } from '../core/compute.js';
|
||||
import { groupBy, sortBy, toggleTreeState, setTreeStateBulk, escapeHtml, safeText } from '../core/table-tree.js';
|
||||
|
||||
ensureMesApiAvailable();
|
||||
window.__MES_FRONTEND_CORE__ = { buildResourceKpiFromHours, groupBy, sortBy, toggleTreeState, setTreeStateBulk, escapeHtml, safeText };
|
||||
window.__FIELD_CONTRACTS__ = window.__FIELD_CONTRACTS__ || {};
|
||||
window.__FIELD_CONTRACTS__['tables:result_table'] = getPageContract('tables', 'result_table');
|
||||
|
||||
|
||||
let currentTable = null;
|
||||
let currentDisplayName = null;
|
||||
let currentTimeField = null;
|
||||
let currentColumns = [];
|
||||
let currentFilters = {};
|
||||
|
||||
function toFilterInputId(column) {
|
||||
return `filter_${encodeURIComponent(safeText(column))}`;
|
||||
}
|
||||
|
||||
function toJsSingleQuoted(value) {
|
||||
return safeText(value).replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
||||
}
|
||||
|
||||
async function loadTableData(tableName, displayName, timeField) {
|
||||
// Mark current selected table
|
||||
document.querySelectorAll('.table-card').forEach(card => {
|
||||
card.classList.remove('active');
|
||||
});
|
||||
event.currentTarget.classList.add('active');
|
||||
|
||||
currentTable = tableName;
|
||||
currentDisplayName = displayName;
|
||||
currentTimeField = timeField || null;
|
||||
currentFilters = {};
|
||||
|
||||
const viewer = document.getElementById('dataViewer');
|
||||
const title = document.getElementById('viewerTitle');
|
||||
const content = document.getElementById('tableContent');
|
||||
const statsContainer = document.getElementById('statsContainer');
|
||||
|
||||
viewer.classList.add('active');
|
||||
title.textContent = `正在載入: ${displayName}`;
|
||||
content.innerHTML = '<div class="loading">正在載入欄位資訊...</div>';
|
||||
statsContainer.innerHTML = '';
|
||||
|
||||
viewer.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
|
||||
try {
|
||||
const data = await MesApi.post('/api/get_table_columns', { table_name: tableName });
|
||||
|
||||
if (data.error) {
|
||||
content.innerHTML = `<div class="error">${escapeHtml(data.error)}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
currentColumns = data.columns;
|
||||
title.textContent = `${displayName} (${currentColumns.length} 欄位)`;
|
||||
|
||||
renderFilterControls();
|
||||
} catch (error) {
|
||||
content.innerHTML = `<div class="error">請求失敗: ${escapeHtml(error.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderFilterControls() {
|
||||
const statsContainer = document.getElementById('statsContainer');
|
||||
const content = document.getElementById('tableContent');
|
||||
|
||||
statsContainer.innerHTML = `
|
||||
<div class="stats">
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">表名</div>
|
||||
<div class="stat-value" style="font-size: 14px;">${escapeHtml(currentTable)}</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">欄位數</div>
|
||||
<div class="stat-value">${currentColumns.length}</div>
|
||||
</div>
|
||||
<span class="filter-hint">在下方輸入框填入篩選條件 (模糊匹配)</span>
|
||||
<button class="query-btn" onclick="executeQuery()">查詢</button>
|
||||
<button class="clear-btn" onclick="clearFilters()">清除篩選</button>
|
||||
</div>
|
||||
<div id="activeFilters" class="active-filters"></div>
|
||||
`;
|
||||
|
||||
let html = '<table><thead>';
|
||||
html += '<tr>';
|
||||
currentColumns.forEach(col => {
|
||||
html += `<th>${escapeHtml(col)}</th>`;
|
||||
});
|
||||
html += '</tr>';
|
||||
|
||||
html += '<tr class="filter-row">';
|
||||
currentColumns.forEach(col => {
|
||||
const filterId = toFilterInputId(col);
|
||||
const jsCol = toJsSingleQuoted(col);
|
||||
html += `<th><input type="text" id="${filterId}" placeholder="篩選..." onkeypress="handleFilterKeypress(event)" onchange="updateFilter('${jsCol}', this.value)"></th>`;
|
||||
});
|
||||
html += '</tr>';
|
||||
|
||||
html += '</thead><tbody id="dataBody">';
|
||||
html += '<tr><td colspan="' + currentColumns.length + '" style="text-align: center; padding: 40px; color: #666;">請輸入篩選條件後點擊「查詢」,或直接點擊「查詢」載入最後 1000 筆資料</td></tr>';
|
||||
html += '</tbody></table>';
|
||||
|
||||
content.innerHTML = html;
|
||||
}
|
||||
|
||||
function updateFilter(column, value) {
|
||||
if (value && value.trim()) {
|
||||
currentFilters[column] = value.trim();
|
||||
} else {
|
||||
delete currentFilters[column];
|
||||
}
|
||||
renderActiveFilters();
|
||||
}
|
||||
|
||||
function renderActiveFilters() {
|
||||
const container = document.getElementById('activeFilters');
|
||||
if (!container) return;
|
||||
|
||||
const filterKeys = Object.keys(currentFilters);
|
||||
if (filterKeys.length === 0) {
|
||||
container.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
filterKeys.forEach(col => {
|
||||
html += `<span class="filter-tag">${escapeHtml(col)}: ${escapeHtml(currentFilters[col])} <span class="remove" onclick="removeFilter('${toJsSingleQuoted(col)}')">×</span></span>`;
|
||||
});
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
function removeFilter(column) {
|
||||
delete currentFilters[column];
|
||||
const input = document.getElementById(toFilterInputId(column));
|
||||
if (input) input.value = '';
|
||||
renderActiveFilters();
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
currentFilters = {};
|
||||
currentColumns.forEach(col => {
|
||||
const input = document.getElementById(toFilterInputId(col));
|
||||
if (input) input.value = '';
|
||||
});
|
||||
renderActiveFilters();
|
||||
}
|
||||
|
||||
function handleFilterKeypress(event) {
|
||||
if (event.key === 'Enter') {
|
||||
executeQuery();
|
||||
}
|
||||
}
|
||||
|
||||
async function executeQuery() {
|
||||
const title = document.getElementById('viewerTitle');
|
||||
const tbody = document.getElementById('dataBody');
|
||||
|
||||
currentFilters = {};
|
||||
currentColumns.forEach(col => {
|
||||
const input = document.getElementById(toFilterInputId(col));
|
||||
if (input && input.value.trim()) {
|
||||
currentFilters[col] = input.value.trim();
|
||||
}
|
||||
});
|
||||
renderActiveFilters();
|
||||
|
||||
title.textContent = `正在查詢: ${currentDisplayName}`;
|
||||
tbody.innerHTML = `<tr><td colspan="${currentColumns.length}" class="loading">正在查詢資料...</td></tr>`;
|
||||
|
||||
try {
|
||||
const data = await MesApi.post('/api/query_table', {
|
||||
table_name: currentTable,
|
||||
limit: 1000,
|
||||
time_field: currentTimeField,
|
||||
filters: Object.keys(currentFilters).length > 0 ? currentFilters : null
|
||||
});
|
||||
|
||||
if (data.error) {
|
||||
tbody.innerHTML = `<tr><td colspan="${currentColumns.length}" class="error">${escapeHtml(data.error)}</td></tr>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const filterCount = Object.keys(currentFilters).length;
|
||||
const filterText = filterCount > 0 ? ` [${filterCount} 個篩選]` : '';
|
||||
title.textContent = `${currentDisplayName} (${data.row_count} 筆)${filterText}`;
|
||||
|
||||
if (data.data.length === 0) {
|
||||
tbody.innerHTML = `<tr><td colspan="${currentColumns.length}" style="text-align: center; padding: 40px; color: #999;">查無資料</td></tr>`;
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
data.data.forEach(row => {
|
||||
html += '<tr>';
|
||||
currentColumns.forEach(col => {
|
||||
const value = row[col];
|
||||
if (value === null || value === undefined) {
|
||||
html += '<td><i style="color: #999;">NULL</i></td>';
|
||||
} else {
|
||||
html += `<td>${escapeHtml(safeText(value))}</td>`;
|
||||
}
|
||||
});
|
||||
html += '</tr>';
|
||||
});
|
||||
tbody.innerHTML = html;
|
||||
} catch (error) {
|
||||
tbody.innerHTML = `<tr><td colspan="${currentColumns.length}" class="error">請求失敗: ${escapeHtml(error.message)}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
function closeViewer() {
|
||||
document.getElementById('dataViewer').classList.remove('active');
|
||||
document.querySelectorAll('.table-card').forEach(card => {
|
||||
card.classList.remove('active');
|
||||
});
|
||||
currentTable = null;
|
||||
currentColumns = [];
|
||||
currentFilters = {};
|
||||
}
|
||||
|
||||
|
||||
Object.assign(window, {
|
||||
loadTableData,
|
||||
renderFilterControls,
|
||||
updateFilter,
|
||||
renderActiveFilters,
|
||||
removeFilter,
|
||||
clearFilters,
|
||||
handleFilterKeypress,
|
||||
executeQuery,
|
||||
closeViewer,
|
||||
});
|
||||
844
frontend/src/wip-detail/main.js
Normal file
844
frontend/src/wip-detail/main.js
Normal file
@@ -0,0 +1,844 @@
|
||||
import { ensureMesApiAvailable } from '../core/api.js';
|
||||
import {
|
||||
debounce,
|
||||
fetchWipAutocompleteItems,
|
||||
} from '../core/autocomplete.js';
|
||||
|
||||
ensureMesApiAvailable();
|
||||
|
||||
(function initWipDetailPage() {
|
||||
// ============================================================
|
||||
// State Management
|
||||
// ============================================================
|
||||
const state = {
|
||||
workcenter: '',
|
||||
data: null,
|
||||
packages: [],
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
filters: {
|
||||
package: '',
|
||||
type: '',
|
||||
workorder: '',
|
||||
lotid: ''
|
||||
},
|
||||
isLoading: false,
|
||||
refreshTimer: null,
|
||||
REFRESH_INTERVAL: 10 * 60 * 1000, // 10 minutes
|
||||
};
|
||||
|
||||
// WIP Status filter (separate from other filters)
|
||||
let activeStatusFilter = null; // null | 'run' | 'queue' | 'quality-hold' | 'non-quality-hold'
|
||||
|
||||
// AbortController for cancelling in-flight requests
|
||||
let tableAbortController = null; // For loadTableOnly()
|
||||
let loadAllAbortController = null; // For loadAllData()
|
||||
|
||||
// ============================================================
|
||||
// Utility Functions
|
||||
// ============================================================
|
||||
function formatNumber(num) {
|
||||
if (num === null || num === undefined || num === '-') return '-';
|
||||
return num.toLocaleString('zh-TW');
|
||||
}
|
||||
|
||||
function updateElementWithTransition(elementId, newValue) {
|
||||
const el = document.getElementById(elementId);
|
||||
const oldValue = el.textContent;
|
||||
const formattedNew = formatNumber(newValue);
|
||||
|
||||
if (oldValue !== formattedNew) {
|
||||
el.textContent = formattedNew;
|
||||
el.classList.add('updated');
|
||||
setTimeout(() => el.classList.remove('updated'), 500);
|
||||
}
|
||||
}
|
||||
|
||||
function getUrlParam(name) {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return params.get(name) || '';
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// API Functions (using MesApi)
|
||||
// ============================================================
|
||||
const API_TIMEOUT = 60000; // 60 seconds timeout
|
||||
|
||||
async function fetchPackages() {
|
||||
const result = await MesApi.get('/api/wip/meta/packages', { silent: true, timeout: API_TIMEOUT });
|
||||
if (result.success) {
|
||||
return result.data;
|
||||
}
|
||||
throw new Error(result.error || 'Failed to fetch packages');
|
||||
}
|
||||
|
||||
async function fetchDetail(signal = null) {
|
||||
const params = {
|
||||
page: state.page,
|
||||
page_size: state.pageSize
|
||||
};
|
||||
|
||||
if (state.filters.package) {
|
||||
params.package = state.filters.package;
|
||||
}
|
||||
if (state.filters.type) {
|
||||
params.type = state.filters.type;
|
||||
}
|
||||
if (activeStatusFilter) {
|
||||
// Handle hold type filters
|
||||
if (activeStatusFilter === 'quality-hold') {
|
||||
params.status = 'HOLD';
|
||||
params.hold_type = 'quality';
|
||||
} else if (activeStatusFilter === 'non-quality-hold') {
|
||||
params.status = 'HOLD';
|
||||
params.hold_type = 'non-quality';
|
||||
} else {
|
||||
// Convert to API status format (RUN/QUEUE)
|
||||
params.status = activeStatusFilter.toUpperCase();
|
||||
}
|
||||
}
|
||||
if (state.filters.workorder) {
|
||||
params.workorder = state.filters.workorder;
|
||||
}
|
||||
if (state.filters.lotid) {
|
||||
params.lotid = state.filters.lotid;
|
||||
}
|
||||
|
||||
const result = await MesApi.get(`/api/wip/detail/${encodeURIComponent(state.workcenter)}`, {
|
||||
params,
|
||||
timeout: API_TIMEOUT,
|
||||
signal
|
||||
});
|
||||
if (result.success) {
|
||||
return result.data;
|
||||
}
|
||||
throw new Error(result.error || 'Failed to fetch detail');
|
||||
}
|
||||
|
||||
async function fetchWorkcenters() {
|
||||
const result = await MesApi.get('/api/wip/meta/workcenters', { silent: true, timeout: API_TIMEOUT });
|
||||
if (result.success) {
|
||||
return result.data;
|
||||
}
|
||||
throw new Error(result.error || 'Failed to fetch workcenters');
|
||||
}
|
||||
|
||||
async function searchAutocompleteItems(type, query) {
|
||||
return fetchWipAutocompleteItems({
|
||||
searchType: type,
|
||||
query,
|
||||
filters: {
|
||||
workorder: document.getElementById('filterWorkorder').value,
|
||||
lotid: document.getElementById('filterLotid').value,
|
||||
package: document.getElementById('filterPackage').value,
|
||||
type: document.getElementById('filterType').value,
|
||||
},
|
||||
request: (url, options) => MesApi.get(url, options),
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Render Functions
|
||||
// ============================================================
|
||||
function renderSummary(summary) {
|
||||
if (!summary) return;
|
||||
|
||||
updateElementWithTransition('totalLots', summary.totalLots);
|
||||
updateElementWithTransition('runLots', summary.runLots);
|
||||
updateElementWithTransition('queueLots', summary.queueLots);
|
||||
updateElementWithTransition('qualityHoldLots', summary.qualityHoldLots);
|
||||
updateElementWithTransition('nonQualityHoldLots', summary.nonQualityHoldLots);
|
||||
}
|
||||
|
||||
function renderTable(data) {
|
||||
const container = document.getElementById('tableContainer');
|
||||
|
||||
if (!data || !data.lots || data.lots.length === 0) {
|
||||
container.innerHTML = '<div class="placeholder">No data available</div>';
|
||||
document.getElementById('tableInfo').textContent = 'No data';
|
||||
document.getElementById('pagination').style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
const specs = data.specs || [];
|
||||
|
||||
let html = '<table><thead><tr>';
|
||||
// Fixed columns
|
||||
html += '<th class="fixed-col">LOT ID</th>';
|
||||
html += '<th class="fixed-col">Equipment</th>';
|
||||
html += '<th class="fixed-col">WIP Status</th>';
|
||||
html += '<th class="fixed-col">Package</th>';
|
||||
|
||||
// Spec columns
|
||||
specs.forEach(spec => {
|
||||
html += `<th class="spec-col">${spec}</th>`;
|
||||
});
|
||||
|
||||
html += '</tr></thead><tbody>';
|
||||
|
||||
data.lots.forEach(lot => {
|
||||
html += '<tr>';
|
||||
|
||||
// Fixed columns - LOT ID is clickable
|
||||
const lotIdDisplay = lot.lotId
|
||||
? `<span class="lot-id-link" onclick="showLotDetail('${lot.lotId}')">${lot.lotId}</span>`
|
||||
: '-';
|
||||
html += `<td class="fixed-col">${lotIdDisplay}</td>`;
|
||||
html += `<td class="fixed-col">${lot.equipment || '<span style="color: var(--muted);">-</span>'}</td>`;
|
||||
|
||||
// WIP Status with color and hold reason
|
||||
const statusClass = `wip-status-${(lot.wipStatus || 'queue').toLowerCase()}`;
|
||||
let statusText = lot.wipStatus || 'QUEUE';
|
||||
if (lot.wipStatus === 'HOLD' && lot.holdReason) {
|
||||
statusText = `HOLD (${lot.holdReason})`;
|
||||
}
|
||||
html += `<td class="fixed-col ${statusClass}">${statusText}</td>`;
|
||||
|
||||
html += `<td class="fixed-col">${lot.package || '-'}</td>`;
|
||||
|
||||
// Spec columns - show QTY in matching spec column
|
||||
specs.forEach(spec => {
|
||||
if (lot.spec === spec) {
|
||||
html += `<td class="spec-cell has-data">${formatNumber(lot.qty)}</td>`;
|
||||
} else {
|
||||
html += '<td class="spec-cell"></td>';
|
||||
}
|
||||
});
|
||||
|
||||
html += '</tr>';
|
||||
});
|
||||
|
||||
html += '</tbody></table>';
|
||||
container.innerHTML = html;
|
||||
|
||||
// Update info
|
||||
const pagination = data.pagination;
|
||||
const start = (pagination.page - 1) * pagination.page_size + 1;
|
||||
const end = Math.min(pagination.page * pagination.page_size, pagination.total_count);
|
||||
document.getElementById('tableInfo').textContent =
|
||||
`Showing ${start} - ${end} of ${formatNumber(pagination.total_count)}`;
|
||||
|
||||
// Update pagination
|
||||
if (pagination.total_pages > 1) {
|
||||
document.getElementById('pagination').style.display = 'flex';
|
||||
document.getElementById('pageInfo').textContent =
|
||||
`Page ${pagination.page} / ${pagination.total_pages}`;
|
||||
document.getElementById('btnPrev').disabled = pagination.page <= 1;
|
||||
document.getElementById('btnNext').disabled = pagination.page >= pagination.total_pages;
|
||||
} else {
|
||||
document.getElementById('pagination').style.display = 'none';
|
||||
}
|
||||
|
||||
// Update last update time
|
||||
if (data.sys_date) {
|
||||
document.getElementById('lastUpdate').textContent = `Last Update: ${data.sys_date}`;
|
||||
}
|
||||
}
|
||||
|
||||
function populatePackageFilter(packages) {
|
||||
const select = document.getElementById('filterPackage');
|
||||
const currentValue = select.value;
|
||||
|
||||
select.innerHTML = '<option value="">All</option>';
|
||||
packages.forEach(pkg => {
|
||||
const option = document.createElement('option');
|
||||
option.value = pkg.name;
|
||||
option.textContent = `${pkg.name} (${pkg.lot_count})`;
|
||||
select.appendChild(option);
|
||||
});
|
||||
|
||||
select.value = currentValue;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Data Loading
|
||||
// ============================================================
|
||||
async function loadAllData(showOverlay = true) {
|
||||
// Cancel any in-flight request to prevent connection pile-up
|
||||
if (loadAllAbortController) {
|
||||
loadAllAbortController.abort();
|
||||
console.log('[WIP Detail] Previous request cancelled');
|
||||
}
|
||||
loadAllAbortController = new AbortController();
|
||||
const signal = loadAllAbortController.signal;
|
||||
|
||||
state.isLoading = true;
|
||||
|
||||
if (showOverlay) {
|
||||
document.getElementById('loadingOverlay').style.display = 'flex';
|
||||
}
|
||||
|
||||
// Show refresh indicator
|
||||
document.getElementById('refreshIndicator').classList.add('active');
|
||||
document.getElementById('refreshError').classList.remove('active');
|
||||
document.getElementById('refreshSuccess').classList.remove('active');
|
||||
|
||||
try {
|
||||
// Load packages for filter (non-blocking - don't fail if this times out)
|
||||
if (state.packages.length === 0) {
|
||||
try {
|
||||
state.packages = await fetchPackages();
|
||||
populatePackageFilter(state.packages);
|
||||
} catch (pkgError) {
|
||||
console.warn('Failed to load packages filter:', pkgError);
|
||||
}
|
||||
}
|
||||
|
||||
// Load detail data (main data - this is critical)
|
||||
state.data = await fetchDetail(signal);
|
||||
|
||||
renderSummary(state.data.summary);
|
||||
renderTable(state.data);
|
||||
|
||||
// Show success indicator
|
||||
document.getElementById('refreshSuccess').classList.add('active');
|
||||
setTimeout(() => {
|
||||
document.getElementById('refreshSuccess').classList.remove('active');
|
||||
}, 1500);
|
||||
|
||||
} catch (error) {
|
||||
// Ignore abort errors (expected when user triggers new request)
|
||||
if (error.name === 'AbortError') {
|
||||
console.log('[WIP Detail] Request cancelled (new request started)');
|
||||
return;
|
||||
}
|
||||
console.error('Data load failed:', error);
|
||||
document.getElementById('refreshError').classList.add('active');
|
||||
} finally {
|
||||
state.isLoading = false;
|
||||
document.getElementById('loadingOverlay').style.display = 'none';
|
||||
document.getElementById('refreshIndicator').classList.remove('active');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Autocomplete Functions
|
||||
// ============================================================
|
||||
function showDropdown(dropdownId, items, onSelect) {
|
||||
const dropdown = document.getElementById(dropdownId);
|
||||
if (!items || items.length === 0) {
|
||||
dropdown.innerHTML = '<div class="autocomplete-empty">No results</div>';
|
||||
dropdown.classList.add('show');
|
||||
return;
|
||||
}
|
||||
dropdown.innerHTML = items.map(item =>
|
||||
`<div class="autocomplete-item" data-value="${item}">${item}</div>`
|
||||
).join('');
|
||||
dropdown.classList.add('show');
|
||||
|
||||
dropdown.querySelectorAll('.autocomplete-item').forEach(el => {
|
||||
el.addEventListener('click', () => {
|
||||
onSelect(el.dataset.value);
|
||||
dropdown.classList.remove('show');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function hideDropdown(dropdownId) {
|
||||
document.getElementById(dropdownId).classList.remove('show');
|
||||
}
|
||||
|
||||
function showLoading(dropdownId) {
|
||||
const dropdown = document.getElementById(dropdownId);
|
||||
dropdown.innerHTML = '<div class="autocomplete-loading">Searching...</div>';
|
||||
dropdown.classList.add('show');
|
||||
}
|
||||
|
||||
function setupAutocomplete(inputId, dropdownId, searchType) {
|
||||
const input = document.getElementById(inputId);
|
||||
|
||||
const doSearch = debounce(async (query) => {
|
||||
if (query.length < 2) {
|
||||
hideDropdown(dropdownId);
|
||||
return;
|
||||
}
|
||||
showLoading(dropdownId);
|
||||
try {
|
||||
const items = await searchAutocompleteItems(searchType, query);
|
||||
showDropdown(dropdownId, items, (value) => {
|
||||
input.value = value;
|
||||
});
|
||||
} catch (e) {
|
||||
hideDropdown(dropdownId);
|
||||
}
|
||||
}, 300);
|
||||
|
||||
input.addEventListener('input', (e) => {
|
||||
doSearch(e.target.value);
|
||||
});
|
||||
|
||||
input.addEventListener('focus', (e) => {
|
||||
if (e.target.value.length >= 2) {
|
||||
doSearch(e.target.value);
|
||||
}
|
||||
});
|
||||
|
||||
// Hide dropdown when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.target.closest(`#${inputId}`) && !e.target.closest(`#${dropdownId}`)) {
|
||||
hideDropdown(dropdownId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Status Filter Toggle (Clickable Cards)
|
||||
// ============================================================
|
||||
function toggleStatusFilter(status) {
|
||||
if (activeStatusFilter === status) {
|
||||
// Clicking the same card again removes the filter
|
||||
activeStatusFilter = null;
|
||||
} else {
|
||||
// Apply new filter
|
||||
activeStatusFilter = status;
|
||||
}
|
||||
|
||||
// Update card styles
|
||||
updateCardStyles();
|
||||
|
||||
// Update table title
|
||||
updateTableTitle();
|
||||
|
||||
// Reset to page 1 and reload table only (no isLoading guard)
|
||||
state.page = 1;
|
||||
loadTableOnly();
|
||||
}
|
||||
|
||||
async function loadTableOnly() {
|
||||
// Cancel any in-flight request to prevent pile-up
|
||||
if (tableAbortController) {
|
||||
tableAbortController.abort();
|
||||
}
|
||||
tableAbortController = new AbortController();
|
||||
|
||||
// Show loading in table container
|
||||
const container = document.getElementById('tableContainer');
|
||||
container.innerHTML = '<div class="placeholder">Loading...</div>';
|
||||
|
||||
// Show refresh indicator
|
||||
document.getElementById('refreshIndicator').classList.add('active');
|
||||
|
||||
try {
|
||||
state.data = await fetchDetail(tableAbortController.signal);
|
||||
renderSummary(state.data.summary);
|
||||
renderTable(state.data);
|
||||
|
||||
// Show success indicator
|
||||
document.getElementById('refreshSuccess').classList.add('active');
|
||||
setTimeout(() => {
|
||||
document.getElementById('refreshSuccess').classList.remove('active');
|
||||
}, 1500);
|
||||
} catch (error) {
|
||||
// Ignore abort errors (expected when user clicks quickly)
|
||||
if (error.name === 'AbortError') {
|
||||
console.log('[WIP Detail] Table request cancelled (new filter selected)');
|
||||
return;
|
||||
}
|
||||
console.error('Table load failed:', error);
|
||||
container.innerHTML = '<div class="placeholder">Error loading data</div>';
|
||||
document.getElementById('refreshError').classList.add('active');
|
||||
} finally {
|
||||
document.getElementById('refreshIndicator').classList.remove('active');
|
||||
}
|
||||
}
|
||||
|
||||
function updateCardStyles() {
|
||||
const row = document.getElementById('summaryRow');
|
||||
const statusCards = document.querySelectorAll('.summary-card.status-run, .summary-card.status-queue, .summary-card.status-quality-hold, .summary-card.status-non-quality-hold');
|
||||
|
||||
// Remove active from all status cards
|
||||
statusCards.forEach(card => {
|
||||
card.classList.remove('active');
|
||||
});
|
||||
|
||||
if (activeStatusFilter) {
|
||||
// Add filtering class to row (dims non-active cards)
|
||||
row.classList.add('filtering');
|
||||
|
||||
// Add active to the selected card
|
||||
const activeCard = document.querySelector(`.summary-card.status-${activeStatusFilter}`);
|
||||
if (activeCard) {
|
||||
activeCard.classList.add('active');
|
||||
}
|
||||
} else {
|
||||
// Remove filtering class
|
||||
row.classList.remove('filtering');
|
||||
}
|
||||
}
|
||||
|
||||
function updateTableTitle() {
|
||||
const titleEl = document.querySelector('.table-title');
|
||||
const baseTitle = 'Lot Details';
|
||||
|
||||
if (activeStatusFilter) {
|
||||
let statusLabel;
|
||||
if (activeStatusFilter === 'quality-hold') {
|
||||
statusLabel = '品質異常 Hold';
|
||||
} else if (activeStatusFilter === 'non-quality-hold') {
|
||||
statusLabel = '非品質異常 Hold';
|
||||
} else {
|
||||
statusLabel = activeStatusFilter.toUpperCase();
|
||||
}
|
||||
titleEl.textContent = `${baseTitle} - ${statusLabel} Only`;
|
||||
} else {
|
||||
titleEl.textContent = baseTitle;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Filter & Pagination
|
||||
// ============================================================
|
||||
function applyFilters() {
|
||||
state.filters.workorder = document.getElementById('filterWorkorder').value.trim();
|
||||
state.filters.lotid = document.getElementById('filterLotid').value.trim();
|
||||
state.filters.package = document.getElementById('filterPackage').value.trim();
|
||||
state.filters.type = document.getElementById('filterType').value.trim();
|
||||
state.page = 1;
|
||||
loadAllData(false);
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
document.getElementById('filterWorkorder').value = '';
|
||||
document.getElementById('filterLotid').value = '';
|
||||
document.getElementById('filterPackage').value = '';
|
||||
document.getElementById('filterType').value = '';
|
||||
state.filters = { package: '', type: '', workorder: '', lotid: '' };
|
||||
|
||||
// Also clear status filter
|
||||
activeStatusFilter = null;
|
||||
updateCardStyles();
|
||||
updateTableTitle();
|
||||
|
||||
state.page = 1;
|
||||
loadAllData(false);
|
||||
}
|
||||
|
||||
function prevPage() {
|
||||
if (state.page > 1) {
|
||||
state.page--;
|
||||
loadAllData(false);
|
||||
}
|
||||
}
|
||||
|
||||
function nextPage() {
|
||||
if (state.data && state.page < state.data.pagination.total_pages) {
|
||||
state.page++;
|
||||
loadAllData(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Auto-refresh
|
||||
// ============================================================
|
||||
function startAutoRefresh() {
|
||||
if (state.refreshTimer) {
|
||||
clearInterval(state.refreshTimer);
|
||||
}
|
||||
state.refreshTimer = setInterval(() => {
|
||||
if (!document.hidden) {
|
||||
loadAllData(false);
|
||||
}
|
||||
}, state.REFRESH_INTERVAL);
|
||||
}
|
||||
|
||||
function manualRefresh() {
|
||||
startAutoRefresh();
|
||||
loadAllData(false);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Lot Detail Functions
|
||||
// ============================================================
|
||||
let selectedLotId = null;
|
||||
|
||||
async function fetchLotDetail(lotId) {
|
||||
const result = await MesApi.get(`/api/wip/lot/${encodeURIComponent(lotId)}`, {
|
||||
timeout: API_TIMEOUT
|
||||
});
|
||||
if (result.success) {
|
||||
return result.data;
|
||||
}
|
||||
throw new Error(result.error || 'Failed to fetch lot detail');
|
||||
}
|
||||
|
||||
async function showLotDetail(lotId) {
|
||||
// Update selected state
|
||||
selectedLotId = lotId;
|
||||
|
||||
// Highlight the selected row
|
||||
document.querySelectorAll('.lot-id-link').forEach(el => {
|
||||
el.classList.toggle('active', el.textContent === lotId);
|
||||
});
|
||||
|
||||
// Show panel
|
||||
const panel = document.getElementById('lotDetailPanel');
|
||||
panel.classList.add('show');
|
||||
|
||||
// Update title
|
||||
document.getElementById('lotDetailLotId').textContent = lotId;
|
||||
|
||||
// Show loading
|
||||
document.getElementById('lotDetailContent').innerHTML = `
|
||||
<div class="lot-detail-loading">
|
||||
<span class="loading-spinner"></span>Loading...
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Scroll to panel
|
||||
panel.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
|
||||
try {
|
||||
const data = await fetchLotDetail(lotId);
|
||||
renderLotDetail(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to load lot detail:', error);
|
||||
document.getElementById('lotDetailContent').innerHTML = `
|
||||
<div class="lot-detail-loading" style="color: var(--danger);">
|
||||
載入失敗:${error.message || '未知錯誤'}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderLotDetail(data) {
|
||||
const labels = data.fieldLabels || {};
|
||||
|
||||
// Helper to format value
|
||||
const formatValue = (value) => {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '<span class="empty">-</span>';
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return formatNumber(value);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
// Helper to create field HTML
|
||||
const field = (key, customLabel = null) => {
|
||||
const label = customLabel || labels[key] || key;
|
||||
const value = data[key];
|
||||
let valueClass = '';
|
||||
|
||||
// Special styling for WIP Status
|
||||
if (key === 'wipStatus') {
|
||||
valueClass = `status-${(value || '').toLowerCase()}`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="lot-detail-field">
|
||||
<span class="lot-detail-label">${label}</span>
|
||||
<span class="lot-detail-value ${valueClass}">${formatValue(value)}</span>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
const html = `
|
||||
<div class="lot-detail-grid">
|
||||
<!-- Basic Info -->
|
||||
<div class="lot-detail-section">
|
||||
<div class="lot-detail-section-title">基本資訊</div>
|
||||
${field('lotId')}
|
||||
${field('workorder')}
|
||||
${field('wipStatus')}
|
||||
${field('status')}
|
||||
${field('qty')}
|
||||
${field('qty2')}
|
||||
${field('ageByDays')}
|
||||
${field('priority')}
|
||||
</div>
|
||||
|
||||
<!-- Product Info -->
|
||||
<div class="lot-detail-section">
|
||||
<div class="lot-detail-section-title">產品資訊</div>
|
||||
${field('product')}
|
||||
${field('productLine')}
|
||||
${field('packageLef')}
|
||||
${field('pjType')}
|
||||
${field('pjFunction')}
|
||||
${field('bop')}
|
||||
${field('dateCode')}
|
||||
${field('produceRegion')}
|
||||
</div>
|
||||
|
||||
<!-- Process Info -->
|
||||
<div class="lot-detail-section">
|
||||
<div class="lot-detail-section-title">製程資訊</div>
|
||||
${field('workcenterGroup')}
|
||||
${field('workcenter')}
|
||||
${field('spec')}
|
||||
${field('specSequence')}
|
||||
${field('workflow')}
|
||||
${field('equipment')}
|
||||
${field('equipmentCount')}
|
||||
${field('location')}
|
||||
</div>
|
||||
|
||||
<!-- Material Info -->
|
||||
<div class="lot-detail-section">
|
||||
<div class="lot-detail-section-title">物料資訊</div>
|
||||
${field('waferLotId')}
|
||||
${field('waferPn')}
|
||||
${field('waferLotPrefix')}
|
||||
${field('leadframeName')}
|
||||
${field('leadframeOption')}
|
||||
${field('compoundName')}
|
||||
${field('dieConsumption')}
|
||||
${field('uts')}
|
||||
</div>
|
||||
|
||||
<!-- Hold Info (if HOLD status) -->
|
||||
${data.wipStatus === 'HOLD' || data.holdCount > 0 ? `
|
||||
<div class="lot-detail-section">
|
||||
<div class="lot-detail-section-title">Hold 資訊</div>
|
||||
${field('holdReason')}
|
||||
${field('holdCount')}
|
||||
${field('holdEmp')}
|
||||
${field('holdDept')}
|
||||
${field('holdComment')}
|
||||
${field('releaseTime')}
|
||||
${field('releaseEmp')}
|
||||
${field('releaseComment')}
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<!-- NCR Info (if exists) -->
|
||||
${data.ncrId ? `
|
||||
<div class="lot-detail-section">
|
||||
<div class="lot-detail-section-title">NCR 資訊</div>
|
||||
${field('ncrId')}
|
||||
${field('ncrDate')}
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<!-- Comments -->
|
||||
<div class="lot-detail-section">
|
||||
<div class="lot-detail-section-title">備註資訊</div>
|
||||
${field('comment')}
|
||||
${field('commentDate')}
|
||||
${field('commentEmp')}
|
||||
${field('futureHoldComment')}
|
||||
</div>
|
||||
|
||||
<!-- Other Info -->
|
||||
<div class="lot-detail-section">
|
||||
<div class="lot-detail-section-title">其他資訊</div>
|
||||
${field('owner')}
|
||||
${field('startDate')}
|
||||
${field('tmttRemaining')}
|
||||
${field('dataUpdateDate')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById('lotDetailContent').innerHTML = html;
|
||||
}
|
||||
|
||||
function closeLotDetail() {
|
||||
const panel = document.getElementById('lotDetailPanel');
|
||||
panel.classList.remove('show');
|
||||
|
||||
// Remove highlight from selected row
|
||||
document.querySelectorAll('.lot-id-link').forEach(el => {
|
||||
el.classList.remove('active');
|
||||
});
|
||||
|
||||
selectedLotId = null;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Initialize
|
||||
// ============================================================
|
||||
async function init() {
|
||||
// Setup autocomplete for WORKORDER, LOT ID, PACKAGE, and TYPE
|
||||
setupAutocomplete('filterWorkorder', 'workorderDropdown', 'workorder');
|
||||
setupAutocomplete('filterLotid', 'lotidDropdown', 'lotid');
|
||||
setupAutocomplete('filterPackage', 'packageDropdown', 'package');
|
||||
setupAutocomplete('filterType', 'typeDropdown', 'type');
|
||||
|
||||
// Allow Enter key to trigger filter
|
||||
document.getElementById('filterWorkorder').addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') applyFilters();
|
||||
});
|
||||
document.getElementById('filterLotid').addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') applyFilters();
|
||||
});
|
||||
document.getElementById('filterPackage').addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') applyFilters();
|
||||
});
|
||||
document.getElementById('filterType').addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') applyFilters();
|
||||
});
|
||||
|
||||
// Get workcenter from URL or use first available
|
||||
state.workcenter = getUrlParam('workcenter');
|
||||
|
||||
// Get filters from URL params (passed from wip_overview)
|
||||
const urlWorkorder = getUrlParam('workorder');
|
||||
const urlLotid = getUrlParam('lotid');
|
||||
const urlPackage = getUrlParam('package');
|
||||
const urlType = getUrlParam('type');
|
||||
if (urlWorkorder) {
|
||||
state.filters.workorder = urlWorkorder;
|
||||
document.getElementById('filterWorkorder').value = urlWorkorder;
|
||||
}
|
||||
if (urlLotid) {
|
||||
state.filters.lotid = urlLotid;
|
||||
document.getElementById('filterLotid').value = urlLotid;
|
||||
}
|
||||
if (urlPackage) {
|
||||
state.filters.package = urlPackage;
|
||||
document.getElementById('filterPackage').value = urlPackage;
|
||||
}
|
||||
if (urlType) {
|
||||
state.filters.type = urlType;
|
||||
document.getElementById('filterType').value = urlType;
|
||||
}
|
||||
|
||||
if (!state.workcenter) {
|
||||
// Fetch workcenters and use first one
|
||||
try {
|
||||
const workcenters = await fetchWorkcenters();
|
||||
if (workcenters && workcenters.length > 0) {
|
||||
state.workcenter = workcenters[0].name;
|
||||
// Update URL without reload
|
||||
window.history.replaceState({}, '', `/wip-detail?workcenter=${encodeURIComponent(state.workcenter)}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch workcenters:', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (state.workcenter) {
|
||||
document.getElementById('pageTitle').textContent = `WIP Detail - ${state.workcenter}`;
|
||||
loadAllData(true);
|
||||
startAutoRefresh();
|
||||
|
||||
// Handle page visibility (must be after workcenter is set)
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (!document.hidden && state.workcenter) {
|
||||
loadAllData(false);
|
||||
startAutoRefresh();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
document.getElementById('tableContainer').innerHTML =
|
||||
'<div class="placeholder">No workcenter available</div>';
|
||||
document.getElementById('loadingOverlay').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
window.onload = init;
|
||||
|
||||
Object.assign(window, {
|
||||
applyFilters,
|
||||
clearFilters,
|
||||
toggleStatusFilter,
|
||||
prevPage,
|
||||
nextPage,
|
||||
manualRefresh,
|
||||
showLotDetail,
|
||||
closeLotDetail,
|
||||
init
|
||||
});
|
||||
})();
|
||||
829
frontend/src/wip-overview/main.js
Normal file
829
frontend/src/wip-overview/main.js
Normal file
@@ -0,0 +1,829 @@
|
||||
import { ensureMesApiAvailable } from '../core/api.js';
|
||||
import {
|
||||
debounce,
|
||||
fetchWipAutocompleteItems,
|
||||
} from '../core/autocomplete.js';
|
||||
|
||||
ensureMesApiAvailable();
|
||||
|
||||
(function initWipOverviewPage() {
|
||||
// ============================================================
|
||||
// State Management
|
||||
// ============================================================
|
||||
const state = {
|
||||
summary: null,
|
||||
matrix: null,
|
||||
hold: null,
|
||||
isLoading: false,
|
||||
lastError: false,
|
||||
refreshTimer: null,
|
||||
REFRESH_INTERVAL: 10 * 60 * 1000, // 10 minutes
|
||||
filters: {
|
||||
workorder: '',
|
||||
lotid: '',
|
||||
package: '',
|
||||
type: ''
|
||||
}
|
||||
};
|
||||
|
||||
// Status filter state (null = no filter, 'run'/'queue'/'hold' = filtered)
|
||||
let activeStatusFilter = null;
|
||||
|
||||
// AbortController for cancelling in-flight requests
|
||||
let matrixAbortController = null; // For loadMatrixOnly()
|
||||
let loadAllAbortController = null; // For loadAllData()
|
||||
|
||||
// ============================================================
|
||||
// Utility Functions
|
||||
// ============================================================
|
||||
function formatNumber(num) {
|
||||
if (num === null || num === undefined || num === '-') return '-';
|
||||
return num.toLocaleString('zh-TW');
|
||||
}
|
||||
|
||||
function updateElementWithTransition(elementId, newValue) {
|
||||
const el = document.getElementById(elementId);
|
||||
const oldValue = el.textContent;
|
||||
let formattedNew;
|
||||
if (typeof newValue === 'number') {
|
||||
formattedNew = formatNumber(newValue);
|
||||
} else if (newValue === null || newValue === undefined) {
|
||||
formattedNew = '-';
|
||||
} else {
|
||||
formattedNew = newValue;
|
||||
}
|
||||
|
||||
if (oldValue !== formattedNew) {
|
||||
el.textContent = formattedNew;
|
||||
el.classList.add('updated');
|
||||
setTimeout(() => el.classList.remove('updated'), 500);
|
||||
}
|
||||
}
|
||||
|
||||
function buildQueryParams() {
|
||||
const params = {};
|
||||
if (state.filters.workorder) {
|
||||
params.workorder = state.filters.workorder;
|
||||
}
|
||||
if (state.filters.lotid) {
|
||||
params.lotid = state.filters.lotid;
|
||||
}
|
||||
if (state.filters.package) {
|
||||
params.package = state.filters.package;
|
||||
}
|
||||
if (state.filters.type) {
|
||||
params.type = state.filters.type;
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// API Functions (using MesApi)
|
||||
// ============================================================
|
||||
const API_TIMEOUT = 60000; // 60 seconds timeout
|
||||
|
||||
async function fetchSummary(signal = null) {
|
||||
const params = buildQueryParams();
|
||||
const result = await MesApi.get('/api/wip/overview/summary', {
|
||||
params,
|
||||
timeout: API_TIMEOUT,
|
||||
signal
|
||||
});
|
||||
if (result.success) {
|
||||
return result.data;
|
||||
}
|
||||
throw new Error(result.error || 'Failed to fetch summary');
|
||||
}
|
||||
|
||||
async function fetchMatrix(signal = null) {
|
||||
const params = buildQueryParams();
|
||||
// Add status filter if active
|
||||
if (activeStatusFilter) {
|
||||
if (activeStatusFilter === 'quality-hold') {
|
||||
params.status = 'HOLD';
|
||||
params.hold_type = 'quality';
|
||||
} else if (activeStatusFilter === 'non-quality-hold') {
|
||||
params.status = 'HOLD';
|
||||
params.hold_type = 'non-quality';
|
||||
} else {
|
||||
params.status = activeStatusFilter.toUpperCase();
|
||||
}
|
||||
}
|
||||
const result = await MesApi.get('/api/wip/overview/matrix', {
|
||||
params,
|
||||
timeout: API_TIMEOUT,
|
||||
signal
|
||||
});
|
||||
if (result.success) {
|
||||
return result.data;
|
||||
}
|
||||
throw new Error(result.error || 'Failed to fetch matrix');
|
||||
}
|
||||
|
||||
async function fetchHold(signal = null) {
|
||||
const params = buildQueryParams();
|
||||
const result = await MesApi.get('/api/wip/overview/hold', {
|
||||
params,
|
||||
timeout: API_TIMEOUT,
|
||||
signal
|
||||
});
|
||||
if (result.success) {
|
||||
return result.data;
|
||||
}
|
||||
throw new Error(result.error || 'Failed to fetch hold');
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Autocomplete Functions
|
||||
// ============================================================
|
||||
async function searchAutocomplete(type, query) {
|
||||
const loadingEl = document.getElementById(`${type}Loading`);
|
||||
loadingEl.classList.add('active');
|
||||
try {
|
||||
return await fetchWipAutocompleteItems({
|
||||
searchType: type,
|
||||
query,
|
||||
filters: {
|
||||
workorder: document.getElementById('filterWorkorder').value,
|
||||
lotid: document.getElementById('filterLotid').value,
|
||||
package: document.getElementById('filterPackage').value,
|
||||
type: document.getElementById('filterType').value,
|
||||
},
|
||||
request: (url, options) => MesApi.get(url, options),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Search ${type} failed:`, error);
|
||||
} finally {
|
||||
loadingEl.classList.remove('active');
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function showDropdown(type, items) {
|
||||
const dropdown = document.getElementById(`${type}Dropdown`);
|
||||
|
||||
if (items.length === 0) {
|
||||
dropdown.innerHTML = '<div class="autocomplete-item no-results">無符合結果</div>';
|
||||
} else {
|
||||
dropdown.innerHTML = items.map(item =>
|
||||
`<div class="autocomplete-item" onclick="selectAutocomplete('${type}', '${item}')">${item}</div>`
|
||||
).join('');
|
||||
}
|
||||
dropdown.classList.add('active');
|
||||
}
|
||||
|
||||
function hideDropdown(type) {
|
||||
const dropdown = document.getElementById(`${type}Dropdown`);
|
||||
dropdown.classList.remove('active');
|
||||
}
|
||||
|
||||
function selectAutocomplete(type, value) {
|
||||
const input = document.getElementById(`filter${type.charAt(0).toUpperCase() + type.slice(1)}`);
|
||||
input.value = value;
|
||||
hideDropdown(type);
|
||||
}
|
||||
|
||||
// Setup autocomplete for inputs
|
||||
function setupAutocomplete(type) {
|
||||
const input = document.getElementById(`filter${type.charAt(0).toUpperCase() + type.slice(1)}`);
|
||||
|
||||
const debouncedSearch = debounce(async (query) => {
|
||||
if (query.length >= 2) {
|
||||
const items = await searchAutocomplete(type, query);
|
||||
showDropdown(type, items);
|
||||
} else {
|
||||
hideDropdown(type);
|
||||
}
|
||||
}, 300);
|
||||
|
||||
input.addEventListener('input', (e) => {
|
||||
debouncedSearch(e.target.value);
|
||||
});
|
||||
|
||||
input.addEventListener('focus', async () => {
|
||||
const query = input.value;
|
||||
if (query.length >= 2) {
|
||||
const items = await searchAutocomplete(type, query);
|
||||
showDropdown(type, items);
|
||||
}
|
||||
});
|
||||
|
||||
input.addEventListener('blur', () => {
|
||||
// Delay hide to allow click on dropdown
|
||||
setTimeout(() => hideDropdown(type), 200);
|
||||
});
|
||||
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
hideDropdown(type);
|
||||
applyFilters();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Filter Functions
|
||||
// ============================================================
|
||||
function applyFilters() {
|
||||
state.filters.workorder = document.getElementById('filterWorkorder').value.trim();
|
||||
state.filters.lotid = document.getElementById('filterLotid').value.trim();
|
||||
state.filters.package = document.getElementById('filterPackage').value.trim();
|
||||
state.filters.type = document.getElementById('filterType').value.trim();
|
||||
|
||||
updateActiveFiltersDisplay();
|
||||
loadAllData(false);
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
document.getElementById('filterWorkorder').value = '';
|
||||
document.getElementById('filterLotid').value = '';
|
||||
document.getElementById('filterPackage').value = '';
|
||||
document.getElementById('filterType').value = '';
|
||||
state.filters.workorder = '';
|
||||
state.filters.lotid = '';
|
||||
state.filters.package = '';
|
||||
state.filters.type = '';
|
||||
|
||||
updateActiveFiltersDisplay();
|
||||
loadAllData(false);
|
||||
}
|
||||
|
||||
function removeFilter(type) {
|
||||
document.getElementById(`filter${type.charAt(0).toUpperCase() + type.slice(1)}`).value = '';
|
||||
state.filters[type] = '';
|
||||
updateActiveFiltersDisplay();
|
||||
loadAllData(false);
|
||||
}
|
||||
|
||||
function updateActiveFiltersDisplay() {
|
||||
const container = document.getElementById('activeFilters');
|
||||
let html = '';
|
||||
|
||||
if (state.filters.workorder) {
|
||||
html += `<span class="filter-tag">WO: ${state.filters.workorder} <span class="remove" onclick="removeFilter('workorder')">×</span></span>`;
|
||||
}
|
||||
if (state.filters.lotid) {
|
||||
html += `<span class="filter-tag">Lot: ${state.filters.lotid} <span class="remove" onclick="removeFilter('lotid')">×</span></span>`;
|
||||
}
|
||||
if (state.filters.package) {
|
||||
html += `<span class="filter-tag">Pkg: ${state.filters.package} <span class="remove" onclick="removeFilter('package')">×</span></span>`;
|
||||
}
|
||||
if (state.filters.type) {
|
||||
html += `<span class="filter-tag">Type: ${state.filters.type} <span class="remove" onclick="removeFilter('type')">×</span></span>`;
|
||||
}
|
||||
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Render Functions
|
||||
// ============================================================
|
||||
function renderSummary(data) {
|
||||
if (!data) return;
|
||||
|
||||
updateElementWithTransition('totalLots', data.totalLots);
|
||||
updateElementWithTransition('totalQty', data.totalQtyPcs);
|
||||
|
||||
const ws = data.byWipStatus || {};
|
||||
const runLots = ws.run?.lots;
|
||||
const runQty = ws.run?.qtyPcs;
|
||||
const queueLots = ws.queue?.lots;
|
||||
const queueQty = ws.queue?.qtyPcs;
|
||||
const qualityHoldLots = ws.qualityHold?.lots;
|
||||
const qualityHoldQty = ws.qualityHold?.qtyPcs;
|
||||
const nonQualityHoldLots = ws.nonQualityHold?.lots;
|
||||
const nonQualityHoldQty = ws.nonQualityHold?.qtyPcs;
|
||||
|
||||
updateElementWithTransition(
|
||||
'runLots',
|
||||
runLots === null || runLots === undefined ? '-' : `${formatNumber(runLots)} lots`
|
||||
);
|
||||
updateElementWithTransition(
|
||||
'runQty',
|
||||
runQty === null || runQty === undefined ? '-' : formatNumber(runQty)
|
||||
);
|
||||
updateElementWithTransition(
|
||||
'queueLots',
|
||||
queueLots === null || queueLots === undefined ? '-' : `${formatNumber(queueLots)} lots`
|
||||
);
|
||||
updateElementWithTransition(
|
||||
'queueQty',
|
||||
queueQty === null || queueQty === undefined ? '-' : formatNumber(queueQty)
|
||||
);
|
||||
updateElementWithTransition(
|
||||
'qualityHoldLots',
|
||||
qualityHoldLots === null || qualityHoldLots === undefined ? '-' : `${formatNumber(qualityHoldLots)} lots`
|
||||
);
|
||||
updateElementWithTransition(
|
||||
'qualityHoldQty',
|
||||
qualityHoldQty === null || qualityHoldQty === undefined ? '-' : formatNumber(qualityHoldQty)
|
||||
);
|
||||
updateElementWithTransition(
|
||||
'nonQualityHoldLots',
|
||||
nonQualityHoldLots === null || nonQualityHoldLots === undefined ? '-' : `${formatNumber(nonQualityHoldLots)} lots`
|
||||
);
|
||||
updateElementWithTransition(
|
||||
'nonQualityHoldQty',
|
||||
nonQualityHoldQty === null || nonQualityHoldQty === undefined ? '-' : formatNumber(nonQualityHoldQty)
|
||||
);
|
||||
|
||||
if (data.dataUpdateDate) {
|
||||
document.getElementById('lastUpdate').textContent = `Last Update: ${data.dataUpdateDate}`;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Status Filter Functions
|
||||
// ============================================================
|
||||
function toggleStatusFilter(status) {
|
||||
if (activeStatusFilter === status) {
|
||||
// Deactivate filter
|
||||
activeStatusFilter = null;
|
||||
} else {
|
||||
// Activate new filter
|
||||
activeStatusFilter = status;
|
||||
}
|
||||
|
||||
updateCardStyles();
|
||||
updateMatrixTitle();
|
||||
loadMatrixOnly();
|
||||
}
|
||||
|
||||
function updateCardStyles() {
|
||||
const row = document.querySelector('.wip-status-row');
|
||||
document.querySelectorAll('.wip-status-card').forEach(card => {
|
||||
card.classList.remove('active');
|
||||
});
|
||||
|
||||
if (activeStatusFilter) {
|
||||
row.classList.add('filtering');
|
||||
const activeCard = document.querySelector(`.wip-status-card.${activeStatusFilter}`);
|
||||
if (activeCard) {
|
||||
activeCard.classList.add('active');
|
||||
}
|
||||
} else {
|
||||
row.classList.remove('filtering');
|
||||
}
|
||||
}
|
||||
|
||||
function updateMatrixTitle() {
|
||||
const titleEl = document.querySelector('.card-title');
|
||||
if (!titleEl) return;
|
||||
|
||||
const baseTitle = 'Workcenter x Package Matrix (QTY)';
|
||||
if (activeStatusFilter) {
|
||||
let statusLabel;
|
||||
if (activeStatusFilter === 'quality-hold') {
|
||||
statusLabel = '品質異常 Hold';
|
||||
} else if (activeStatusFilter === 'non-quality-hold') {
|
||||
statusLabel = '非品質異常 Hold';
|
||||
} else {
|
||||
statusLabel = activeStatusFilter.toUpperCase();
|
||||
}
|
||||
titleEl.textContent = `${baseTitle} - ${statusLabel} Only`;
|
||||
} else {
|
||||
titleEl.textContent = baseTitle;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMatrixOnly() {
|
||||
// Cancel any in-flight matrix request to prevent pile-up
|
||||
if (matrixAbortController) {
|
||||
matrixAbortController.abort();
|
||||
}
|
||||
matrixAbortController = new AbortController();
|
||||
|
||||
const container = document.getElementById('matrixContainer');
|
||||
container.innerHTML = '<div class="placeholder">Loading...</div>';
|
||||
|
||||
try {
|
||||
const matrix = await fetchMatrix(matrixAbortController.signal);
|
||||
state.matrix = matrix;
|
||||
renderMatrix(matrix);
|
||||
} catch (error) {
|
||||
// Ignore abort errors (expected when user clicks quickly)
|
||||
if (error.name === 'AbortError') {
|
||||
console.log('[WIP Overview] Matrix request cancelled (new filter selected)');
|
||||
return;
|
||||
}
|
||||
console.error('[WIP Overview] Matrix load failed:', error);
|
||||
container.innerHTML = '<div class="placeholder">Error loading data</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderMatrix(data) {
|
||||
const container = document.getElementById('matrixContainer');
|
||||
|
||||
if (!data || !data.workcenters || data.workcenters.length === 0) {
|
||||
container.innerHTML = '<div class="placeholder">No data available</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Limit packages to top 15 for display
|
||||
const displayPackages = data.packages.slice(0, 15);
|
||||
|
||||
let html = '<table class="matrix-table"><thead><tr>';
|
||||
html += '<th>Workcenter</th>';
|
||||
displayPackages.forEach(pkg => {
|
||||
html += `<th>${pkg}</th>`;
|
||||
});
|
||||
html += '<th class="total-col">Total</th>';
|
||||
html += '</tr></thead><tbody>';
|
||||
|
||||
// Data rows
|
||||
data.workcenters.forEach(wc => {
|
||||
html += '<tr>';
|
||||
html += `<td class="clickable" onclick="navigateToDetail('${wc.replace(/'/g, "\\'")}')">${wc}</td>`;
|
||||
|
||||
displayPackages.forEach(pkg => {
|
||||
const qty = data.matrix[wc]?.[pkg] || 0;
|
||||
html += `<td>${qty ? formatNumber(qty) : '-'}</td>`;
|
||||
});
|
||||
|
||||
html += `<td class="total-col">${formatNumber(data.workcenter_totals[wc] || 0)}</td>`;
|
||||
html += '</tr>';
|
||||
});
|
||||
|
||||
// Total row
|
||||
html += '<tr class="total-row">';
|
||||
html += '<td>Total</td>';
|
||||
displayPackages.forEach(pkg => {
|
||||
html += `<td>${formatNumber(data.package_totals[pkg] || 0)}</td>`;
|
||||
});
|
||||
html += `<td class="total-col">${formatNumber(data.grand_total || 0)}</td>`;
|
||||
html += '</tr>';
|
||||
|
||||
html += '</tbody></table>';
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Pareto Chart Functions
|
||||
// ============================================================
|
||||
let paretoCharts = {
|
||||
quality: null,
|
||||
nonQuality: null
|
||||
};
|
||||
|
||||
// Task 2.1: Split hold data by type
|
||||
function splitHoldByType(data) {
|
||||
if (!data || !data.items) {
|
||||
return { quality: [], nonQuality: [] };
|
||||
}
|
||||
const quality = data.items.filter(item => item.holdType === 'quality');
|
||||
const nonQuality = data.items.filter(item => item.holdType !== 'quality');
|
||||
return { quality, nonQuality };
|
||||
}
|
||||
|
||||
// Task 2.2: Prepare Pareto data (sort by QTY desc, calculate cumulative %)
|
||||
function prepareParetoData(items) {
|
||||
if (!items || items.length === 0) {
|
||||
return { reasons: [], qtys: [], lots: [], cumulative: [], totalQty: 0 };
|
||||
}
|
||||
|
||||
// Sort by QTY descending
|
||||
const sorted = [...items].sort((a, b) => (b.qty || 0) - (a.qty || 0));
|
||||
|
||||
const reasons = sorted.map(item => item.reason || '未知');
|
||||
const qtys = sorted.map(item => item.qty || 0);
|
||||
const lots = sorted.map(item => item.lots || 0);
|
||||
const totalQty = qtys.reduce((sum, q) => sum + q, 0);
|
||||
|
||||
// Calculate cumulative percentage
|
||||
const cumulative = [];
|
||||
let runningSum = 0;
|
||||
qtys.forEach(qty => {
|
||||
runningSum += qty;
|
||||
cumulative.push(totalQty > 0 ? Math.round((runningSum / totalQty) * 100) : 0);
|
||||
});
|
||||
|
||||
return { reasons, qtys, lots, cumulative, totalQty, items: sorted };
|
||||
}
|
||||
|
||||
// Task 3.1: Initialize Pareto charts
|
||||
function initParetoCharts() {
|
||||
const qualityEl = document.getElementById('qualityParetoChart');
|
||||
const nonQualityEl = document.getElementById('nonQualityParetoChart');
|
||||
|
||||
if (qualityEl && !paretoCharts.quality) {
|
||||
paretoCharts.quality = echarts.init(qualityEl);
|
||||
}
|
||||
if (nonQualityEl && !paretoCharts.nonQuality) {
|
||||
paretoCharts.nonQuality = echarts.init(nonQualityEl);
|
||||
}
|
||||
}
|
||||
|
||||
// Task 3.2: Render Pareto chart with ECharts
|
||||
function renderParetoChart(chart, paretoData, colorTheme) {
|
||||
if (!chart) return;
|
||||
|
||||
const barColor = colorTheme === 'quality' ? '#ef4444' : '#f97316';
|
||||
const lineColor = colorTheme === 'quality' ? '#991B1B' : '#9A3412';
|
||||
|
||||
const option = {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'cross' },
|
||||
formatter: function(params) {
|
||||
const reason = params[0].name;
|
||||
const qty = params[0].value;
|
||||
const cumPct = params[1] ? params[1].value : 0;
|
||||
return `<strong>${reason}</strong><br/>QTY: ${formatNumber(qty)}<br/>累計: ${cumPct}%`;
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '15%',
|
||||
top: '10%',
|
||||
containLabel: true
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: paretoData.reasons,
|
||||
axisLabel: {
|
||||
rotate: 30,
|
||||
interval: 0,
|
||||
fontSize: 10,
|
||||
formatter: function(value) {
|
||||
return value.length > 12 ? value.slice(0, 12) + '...' : value;
|
||||
}
|
||||
},
|
||||
axisTick: { alignWithLabel: true }
|
||||
},
|
||||
yAxis: [
|
||||
{
|
||||
type: 'value',
|
||||
name: 'QTY',
|
||||
position: 'left',
|
||||
axisLabel: {
|
||||
formatter: function(val) {
|
||||
return val >= 1000 ? (val / 1000).toFixed(0) + 'k' : val;
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'value',
|
||||
name: '累計%',
|
||||
position: 'right',
|
||||
min: 0,
|
||||
max: 100,
|
||||
axisLabel: { formatter: '{value}%' }
|
||||
}
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: 'QTY',
|
||||
type: 'bar',
|
||||
data: paretoData.qtys,
|
||||
itemStyle: { color: barColor },
|
||||
emphasis: {
|
||||
itemStyle: { color: barColor, opacity: 0.8 }
|
||||
}
|
||||
},
|
||||
{
|
||||
name: '累計%',
|
||||
type: 'line',
|
||||
yAxisIndex: 1,
|
||||
data: paretoData.cumulative,
|
||||
symbol: 'circle',
|
||||
symbolSize: 6,
|
||||
lineStyle: { color: lineColor, width: 2 },
|
||||
itemStyle: { color: lineColor }
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
chart.setOption(option);
|
||||
|
||||
// Task 3.3: Add click event for drill-down
|
||||
chart.off('click'); // Remove existing handlers
|
||||
chart.on('click', function(params) {
|
||||
if (params.componentType === 'series' && params.seriesType === 'bar') {
|
||||
const reason = paretoData.reasons[params.dataIndex];
|
||||
if (reason && reason !== '未知') {
|
||||
window.location.href = `/hold-detail?reason=${encodeURIComponent(reason)}`;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Task 4.1 & 4.2: Render Pareto table with drill-down links
|
||||
function renderParetoTable(containerId, paretoData) {
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) return;
|
||||
|
||||
if (!paretoData.items || paretoData.items.length === 0) {
|
||||
container.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<table class="pareto-table"><thead><tr>';
|
||||
html += '<th>Hold Reason</th>';
|
||||
html += '<th>Lots</th>';
|
||||
html += '<th>QTY</th>';
|
||||
html += '<th>累計%</th>';
|
||||
html += '</tr></thead><tbody>';
|
||||
|
||||
paretoData.items.forEach((item, idx) => {
|
||||
const reason = item.reason || '未知';
|
||||
const reasonLink = item.reason
|
||||
? `<a href="/hold-detail?reason=${encodeURIComponent(item.reason)}" class="reason-link">${reason}</a>`
|
||||
: reason;
|
||||
html += '<tr>';
|
||||
html += `<td>${reasonLink}</td>`;
|
||||
html += `<td>${formatNumber(item.lots)}</td>`;
|
||||
html += `<td>${formatNumber(item.qty)}</td>`;
|
||||
html += `<td class="cumulative">${paretoData.cumulative[idx]}%</td>`;
|
||||
html += '</tr>';
|
||||
});
|
||||
|
||||
html += '</tbody></table>';
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
// Task 3.4: Handle no data state
|
||||
function showParetoNoData(type, show) {
|
||||
const chartEl = document.getElementById(`${type}ParetoChart`);
|
||||
const noDataEl = document.getElementById(`${type}ParetoNoData`);
|
||||
if (chartEl) chartEl.style.display = show ? 'none' : 'block';
|
||||
if (noDataEl) noDataEl.style.display = show ? 'flex' : 'none';
|
||||
}
|
||||
|
||||
// Main render function for Hold data
|
||||
function renderHold(data) {
|
||||
initParetoCharts();
|
||||
|
||||
const { quality, nonQuality } = splitHoldByType(data);
|
||||
const qualityData = prepareParetoData(quality);
|
||||
const nonQualityData = prepareParetoData(nonQuality);
|
||||
|
||||
// Update counts in header
|
||||
document.getElementById('qualityHoldCount').textContent = `${quality.length} 項`;
|
||||
document.getElementById('nonQualityHoldCount').textContent = `${nonQuality.length} 項`;
|
||||
|
||||
// Quality Pareto
|
||||
if (quality.length > 0) {
|
||||
showParetoNoData('quality', false);
|
||||
renderParetoChart(paretoCharts.quality, qualityData, 'quality');
|
||||
renderParetoTable('qualityParetoTable', qualityData);
|
||||
} else {
|
||||
showParetoNoData('quality', true);
|
||||
if (paretoCharts.quality) paretoCharts.quality.clear();
|
||||
document.getElementById('qualityParetoTable').innerHTML = '';
|
||||
}
|
||||
|
||||
// Non-Quality Pareto
|
||||
if (nonQuality.length > 0) {
|
||||
showParetoNoData('nonQuality', false);
|
||||
renderParetoChart(paretoCharts.nonQuality, nonQualityData, 'non-quality');
|
||||
renderParetoTable('nonQualityParetoTable', nonQualityData);
|
||||
} else {
|
||||
showParetoNoData('nonQuality', true);
|
||||
if (paretoCharts.nonQuality) paretoCharts.nonQuality.clear();
|
||||
document.getElementById('nonQualityParetoTable').innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
// Task 5.3: Window resize handler for charts
|
||||
window.addEventListener('resize', function() {
|
||||
if (paretoCharts.quality) paretoCharts.quality.resize();
|
||||
if (paretoCharts.nonQuality) paretoCharts.nonQuality.resize();
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Navigation
|
||||
// ============================================================
|
||||
function navigateToDetail(workcenter) {
|
||||
const params = new URLSearchParams();
|
||||
params.append('workcenter', workcenter);
|
||||
if (state.filters.workorder) params.append('workorder', state.filters.workorder);
|
||||
if (state.filters.lotid) params.append('lotid', state.filters.lotid);
|
||||
if (state.filters.package) params.append('package', state.filters.package);
|
||||
if (state.filters.type) params.append('type', state.filters.type);
|
||||
window.location.href = `/wip-detail?${params.toString()}`;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Data Loading
|
||||
// ============================================================
|
||||
async function loadAllData(showOverlay = true) {
|
||||
// Cancel any in-flight request to prevent connection pile-up
|
||||
if (loadAllAbortController) {
|
||||
loadAllAbortController.abort();
|
||||
console.log('[WIP Overview] Previous request cancelled');
|
||||
}
|
||||
loadAllAbortController = new AbortController();
|
||||
const signal = loadAllAbortController.signal;
|
||||
|
||||
state.isLoading = true;
|
||||
console.log('[WIP Overview] Loading data...', showOverlay ? '(with overlay)' : '(background)');
|
||||
|
||||
if (showOverlay) {
|
||||
document.getElementById('loadingOverlay').style.display = 'flex';
|
||||
}
|
||||
|
||||
// Show refresh indicator
|
||||
document.getElementById('refreshIndicator').classList.add('active');
|
||||
document.getElementById('refreshError').classList.remove('active');
|
||||
document.getElementById('refreshSuccess').classList.remove('active');
|
||||
|
||||
try {
|
||||
const startTime = performance.now();
|
||||
const [summary, matrix, hold] = await Promise.all([
|
||||
fetchSummary(signal),
|
||||
fetchMatrix(signal),
|
||||
fetchHold(signal)
|
||||
]);
|
||||
const elapsed = Math.round(performance.now() - startTime);
|
||||
|
||||
state.summary = summary;
|
||||
state.matrix = matrix;
|
||||
state.hold = hold;
|
||||
state.lastError = false;
|
||||
|
||||
renderSummary(summary);
|
||||
renderMatrix(matrix);
|
||||
renderHold(hold);
|
||||
|
||||
console.log(`[WIP Overview] Data loaded successfully in ${elapsed}ms`);
|
||||
|
||||
// Show success indicator
|
||||
document.getElementById('refreshSuccess').classList.add('active');
|
||||
setTimeout(() => {
|
||||
document.getElementById('refreshSuccess').classList.remove('active');
|
||||
}, 1500);
|
||||
|
||||
} catch (error) {
|
||||
// Ignore abort errors (expected when user triggers new request)
|
||||
if (error.name === 'AbortError') {
|
||||
console.log('[WIP Overview] Request cancelled (new request started)');
|
||||
return;
|
||||
}
|
||||
console.error('[WIP Overview] Data load failed:', error);
|
||||
state.lastError = true;
|
||||
document.getElementById('refreshError').classList.add('active');
|
||||
} finally {
|
||||
state.isLoading = false;
|
||||
document.getElementById('loadingOverlay').style.display = 'none';
|
||||
document.getElementById('refreshIndicator').classList.remove('active');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Auto-refresh
|
||||
// ============================================================
|
||||
function startAutoRefresh() {
|
||||
if (state.refreshTimer) {
|
||||
clearInterval(state.refreshTimer);
|
||||
}
|
||||
console.log('[WIP Overview] Auto-refresh started, interval:', state.REFRESH_INTERVAL / 1000, 'seconds');
|
||||
state.refreshTimer = setInterval(() => {
|
||||
if (!document.hidden) {
|
||||
console.log('[WIP Overview] Auto-refresh triggered at', new Date().toLocaleTimeString());
|
||||
loadAllData(false); // Don't show overlay for auto-refresh
|
||||
} else {
|
||||
console.log('[WIP Overview] Auto-refresh skipped (tab hidden)');
|
||||
}
|
||||
}, state.REFRESH_INTERVAL);
|
||||
}
|
||||
|
||||
function manualRefresh() {
|
||||
// Reset timer on manual refresh
|
||||
startAutoRefresh();
|
||||
loadAllData(false);
|
||||
}
|
||||
|
||||
// Handle page visibility
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (!document.hidden) {
|
||||
// Page became visible - refresh immediately
|
||||
loadAllData(false);
|
||||
startAutoRefresh();
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Initialize
|
||||
// ============================================================
|
||||
window.onload = function() {
|
||||
setupAutocomplete('workorder');
|
||||
setupAutocomplete('lotid');
|
||||
setupAutocomplete('package');
|
||||
setupAutocomplete('type');
|
||||
loadAllData(true);
|
||||
startAutoRefresh();
|
||||
};
|
||||
|
||||
Object.assign(window, {
|
||||
applyFilters,
|
||||
clearFilters,
|
||||
toggleStatusFilter,
|
||||
selectAutocomplete,
|
||||
removeFilter,
|
||||
navigateToDetail,
|
||||
manualRefresh,
|
||||
loadAllData,
|
||||
startAutoRefresh
|
||||
});
|
||||
})();
|
||||
57
frontend/tests/autocomplete.test.js
Normal file
57
frontend/tests/autocomplete.test.js
Normal file
@@ -0,0 +1,57 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
buildWipAutocompleteParams,
|
||||
fetchWipAutocompleteItems,
|
||||
} from '../src/core/autocomplete.js';
|
||||
|
||||
test('buildWipAutocompleteParams keeps cross-filters except active field', () => {
|
||||
const params = buildWipAutocompleteParams('lotid', 'L123', {
|
||||
workorder: 'WO1',
|
||||
lotid: 'L999',
|
||||
package: 'PKG-A',
|
||||
type: 'QFN'
|
||||
});
|
||||
|
||||
assert.equal(params.field, 'lotid');
|
||||
assert.equal(params.q, 'L123');
|
||||
assert.equal(params.workorder, 'WO1');
|
||||
assert.equal(params.package, 'PKG-A');
|
||||
assert.equal(params.type, 'QFN');
|
||||
assert.equal(Object.prototype.hasOwnProperty.call(params, 'lotid'), false);
|
||||
});
|
||||
|
||||
test('buildWipAutocompleteParams returns null for short query', () => {
|
||||
const params = buildWipAutocompleteParams('workorder', 'a', {});
|
||||
assert.equal(params, null);
|
||||
});
|
||||
|
||||
test('fetchWipAutocompleteItems maps successful API response', async () => {
|
||||
const items = await fetchWipAutocompleteItems({
|
||||
searchType: 'workorder',
|
||||
query: 'WO',
|
||||
filters: {},
|
||||
request: async () => ({
|
||||
success: true,
|
||||
data: {
|
||||
items: ['WO1', 'WO2']
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
assert.deepEqual(items, ['WO1', 'WO2']);
|
||||
});
|
||||
|
||||
test('fetchWipAutocompleteItems swallows API errors and returns empty list', async () => {
|
||||
const items = await fetchWipAutocompleteItems({
|
||||
searchType: 'workorder',
|
||||
query: 'WO',
|
||||
filters: {},
|
||||
request: async () => {
|
||||
throw new Error('network down');
|
||||
}
|
||||
});
|
||||
|
||||
assert.deepEqual(items, []);
|
||||
});
|
||||
29
frontend/vite.config.js
Normal file
29
frontend/vite.config.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
export default defineConfig({
|
||||
publicDir: false,
|
||||
build: {
|
||||
outDir: '../src/mes_dashboard/static/dist',
|
||||
emptyOutDir: false,
|
||||
sourcemap: false,
|
||||
rollupOptions: {
|
||||
input: {
|
||||
portal: resolve(__dirname, 'src/portal/main.js'),
|
||||
'wip-overview': resolve(__dirname, 'src/wip-overview/main.js'),
|
||||
'wip-detail': resolve(__dirname, 'src/wip-detail/main.js'),
|
||||
'hold-detail': resolve(__dirname, 'src/hold-detail/main.js'),
|
||||
'resource-status': resolve(__dirname, 'src/resource-status/main.js'),
|
||||
'resource-history': resolve(__dirname, 'src/resource-history/main.js'),
|
||||
'job-query': resolve(__dirname, 'src/job-query/main.js'),
|
||||
'excel-query': resolve(__dirname, 'src/excel-query/main.js'),
|
||||
tables: resolve(__dirname, 'src/tables/main.js')
|
||||
},
|
||||
output: {
|
||||
entryFileNames: '[name].js',
|
||||
chunkFileNames: 'chunks/[name]-[hash].js',
|
||||
assetFileNames: '[name][extname]'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
2182
frontend_design/Hold_detail.pen
Normal file
2182
frontend_design/Hold_detail.pen
Normal file
File diff suppressed because it is too large
Load Diff
614
frontend_design/WIP_main.pen
Normal file
614
frontend_design/WIP_main.pen
Normal file
@@ -0,0 +1,614 @@
|
||||
{
|
||||
"version": "2.6",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "GIoPU",
|
||||
"x": 950,
|
||||
"y": 0,
|
||||
"name": "WIP Overview - Integrated",
|
||||
"width": 1200,
|
||||
"fill": "#F5F7FA",
|
||||
"layout": "vertical",
|
||||
"gap": 16,
|
||||
"padding": 20,
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "N2qxA",
|
||||
"name": "header",
|
||||
"width": "fill_container",
|
||||
"height": 64,
|
||||
"fill": {
|
||||
"type": "gradient",
|
||||
"gradientType": "linear",
|
||||
"enabled": true,
|
||||
"rotation": 135,
|
||||
"size": {
|
||||
"height": 1
|
||||
},
|
||||
"colors": [
|
||||
{
|
||||
"color": "#667eea",
|
||||
"position": 0
|
||||
},
|
||||
{
|
||||
"color": "#764ba2",
|
||||
"position": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
"cornerRadius": 10,
|
||||
"padding": [
|
||||
0,
|
||||
22
|
||||
],
|
||||
"justifyContent": "space_between",
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "7h8YC",
|
||||
"name": "headerTitle",
|
||||
"fill": "#FFFFFF",
|
||||
"content": "WIP Overview Dashboard",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 24,
|
||||
"fontWeight": "600"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "JyskI",
|
||||
"name": "headerRight",
|
||||
"gap": 16,
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "8rtgc",
|
||||
"name": "lastUpdate",
|
||||
"fill": "rgba(255,255,255,0.8)",
|
||||
"content": "Last Update: 2026-01-27 14:30",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 13,
|
||||
"fontWeight": "normal"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "ZH0PW",
|
||||
"name": "refreshBtn",
|
||||
"fill": "rgba(255,255,255,0.2)",
|
||||
"cornerRadius": 8,
|
||||
"padding": [
|
||||
9,
|
||||
20
|
||||
],
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "wlrMh",
|
||||
"name": "refreshText",
|
||||
"fill": "#FFFFFF",
|
||||
"content": "重新整理",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 13,
|
||||
"fontWeight": "600"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "aYXjP",
|
||||
"name": "Summary Section",
|
||||
"width": "fill_container",
|
||||
"layout": "vertical",
|
||||
"gap": 12,
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "pFof4",
|
||||
"name": "kpiRow",
|
||||
"width": "fill_container",
|
||||
"gap": 14,
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "0kWPh",
|
||||
"name": "kpi1",
|
||||
"width": "fill_container",
|
||||
"fill": "#FFFFFF",
|
||||
"cornerRadius": 10,
|
||||
"stroke": {
|
||||
"align": "inside",
|
||||
"thickness": 1,
|
||||
"fill": "#E2E6EF"
|
||||
},
|
||||
"layout": "vertical",
|
||||
"gap": 6,
|
||||
"padding": [
|
||||
16,
|
||||
20
|
||||
],
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "DTtUq",
|
||||
"name": "kpi1Label",
|
||||
"fill": "#666666",
|
||||
"content": "Total Lots",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12,
|
||||
"fontWeight": "normal"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "vdmq8",
|
||||
"name": "kpi1Value",
|
||||
"fill": "#667eea",
|
||||
"content": "1,234",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 28,
|
||||
"fontWeight": "700"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "wEupl",
|
||||
"name": "kpi2",
|
||||
"width": "fill_container",
|
||||
"fill": "#FFFFFF",
|
||||
"cornerRadius": 10,
|
||||
"stroke": {
|
||||
"align": "inside",
|
||||
"thickness": 1,
|
||||
"fill": "#E2E6EF"
|
||||
},
|
||||
"layout": "vertical",
|
||||
"gap": 6,
|
||||
"padding": [
|
||||
16,
|
||||
20
|
||||
],
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "59OHd",
|
||||
"name": "kpi2Label",
|
||||
"fill": "#666666",
|
||||
"content": "Total QTY",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12,
|
||||
"fontWeight": "normal"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "YkPVl",
|
||||
"name": "kpi2Value",
|
||||
"fill": "#667eea",
|
||||
"content": "56,789",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 28,
|
||||
"fontWeight": "700"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "g65nT",
|
||||
"name": "wipStatusRow",
|
||||
"width": "fill_container",
|
||||
"gap": 14,
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "sbKdU",
|
||||
"name": "runCard",
|
||||
"width": "fill_container",
|
||||
"fill": "#F0FDF4",
|
||||
"cornerRadius": 10,
|
||||
"stroke": {
|
||||
"align": "inside",
|
||||
"thickness": 2,
|
||||
"fill": "#22C55E"
|
||||
},
|
||||
"layout": "vertical",
|
||||
"gap": 8,
|
||||
"padding": [
|
||||
16,
|
||||
20
|
||||
],
|
||||
"justifyContent": "space_between",
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "EQzBo",
|
||||
"name": "runLeft",
|
||||
"width": "fill_container",
|
||||
"gap": 10,
|
||||
"justifyContent": "center",
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "rectangle",
|
||||
"cornerRadius": 5,
|
||||
"id": "m7Prk",
|
||||
"name": "runDot",
|
||||
"fill": "#22C55E",
|
||||
"width": 10,
|
||||
"height": 10
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "1DMEu",
|
||||
"name": "runLabel",
|
||||
"fill": "#166534",
|
||||
"content": "RUN",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14,
|
||||
"fontWeight": "600"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "ZVtRH",
|
||||
"name": "runRight",
|
||||
"width": "fill_container",
|
||||
"gap": 24,
|
||||
"justifyContent": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "OLwma",
|
||||
"name": "runLots",
|
||||
"fill": "#0D0D0D",
|
||||
"content": "500 lots",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 24,
|
||||
"fontWeight": "700"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "OI5f5",
|
||||
"name": "runQty",
|
||||
"fill": "#166534",
|
||||
"content": "30,000 pcs",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 24,
|
||||
"fontWeight": "700"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "uibRH",
|
||||
"name": "queueCard",
|
||||
"width": "fill_container",
|
||||
"fill": "#FFFBEB",
|
||||
"cornerRadius": 10,
|
||||
"stroke": {
|
||||
"align": "inside",
|
||||
"thickness": 2,
|
||||
"fill": "#F59E0B"
|
||||
},
|
||||
"layout": "vertical",
|
||||
"gap": 8,
|
||||
"padding": [
|
||||
16,
|
||||
20
|
||||
],
|
||||
"justifyContent": "space_between",
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "xeGDP",
|
||||
"name": "queueLeft",
|
||||
"width": "fill_container",
|
||||
"gap": 10,
|
||||
"justifyContent": "center",
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "rectangle",
|
||||
"cornerRadius": 5,
|
||||
"id": "KuAgl",
|
||||
"name": "queueDot",
|
||||
"fill": "#F59E0B",
|
||||
"width": 10,
|
||||
"height": 10
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "TsD9B",
|
||||
"name": "queueLabel",
|
||||
"fill": "#92400E",
|
||||
"content": "QUEUE",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14,
|
||||
"fontWeight": "600"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "41Db3",
|
||||
"name": "queueRight",
|
||||
"width": "fill_container",
|
||||
"gap": 24,
|
||||
"justifyContent": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "dtaqd",
|
||||
"name": "queueLots",
|
||||
"fill": "#0D0D0D",
|
||||
"content": "634 lots",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 24,
|
||||
"fontWeight": "700"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "BVusD",
|
||||
"name": "queueQty",
|
||||
"fill": "#92400E",
|
||||
"content": "21,789 pcs",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 24,
|
||||
"fontWeight": "700"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "Y5gLu",
|
||||
"name": "holdCard",
|
||||
"width": "fill_container",
|
||||
"fill": "#FEF2F2",
|
||||
"cornerRadius": 10,
|
||||
"stroke": {
|
||||
"align": "inside",
|
||||
"thickness": 2,
|
||||
"fill": "#EF4444"
|
||||
},
|
||||
"layout": "vertical",
|
||||
"gap": 8,
|
||||
"padding": [
|
||||
16,
|
||||
20
|
||||
],
|
||||
"justifyContent": "space_between",
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "juHZC",
|
||||
"name": "holdLeft",
|
||||
"width": "fill_container",
|
||||
"gap": 10,
|
||||
"justifyContent": "center",
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "rectangle",
|
||||
"cornerRadius": 5,
|
||||
"id": "FW9Vv",
|
||||
"name": "holdDot",
|
||||
"fill": "#EF4444",
|
||||
"width": 10,
|
||||
"height": 10
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "gEojA",
|
||||
"name": "holdLabel",
|
||||
"fill": "#991B1B",
|
||||
"content": "HOLD",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14,
|
||||
"fontWeight": "600"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "3imiS",
|
||||
"name": "holdRight",
|
||||
"width": "fill_container",
|
||||
"gap": 24,
|
||||
"justifyContent": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "AlTi3",
|
||||
"name": "holdLots",
|
||||
"fill": "#0D0D0D",
|
||||
"content": "100 lots",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 24,
|
||||
"fontWeight": "700"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "oKc0i",
|
||||
"name": "holdQty",
|
||||
"fill": "#991B1B",
|
||||
"content": "5,000 pcs",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 24,
|
||||
"fontWeight": "700"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "uRXyA",
|
||||
"name": "Content Grid",
|
||||
"width": "fill_container",
|
||||
"gap": 16,
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "7HMip",
|
||||
"name": "matrixCard",
|
||||
"width": "fill_container",
|
||||
"fill": "#FFFFFF",
|
||||
"cornerRadius": 10,
|
||||
"stroke": {
|
||||
"align": "inside",
|
||||
"thickness": 1,
|
||||
"fill": "#E2E6EF"
|
||||
},
|
||||
"layout": "vertical",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "pxsYm",
|
||||
"name": "matrixHeader",
|
||||
"width": "fill_container",
|
||||
"fill": "#FAFBFC",
|
||||
"stroke": {
|
||||
"align": "inside",
|
||||
"thickness": {
|
||||
"bottom": 1
|
||||
},
|
||||
"fill": "#E2E6EF"
|
||||
},
|
||||
"padding": [
|
||||
14,
|
||||
20
|
||||
],
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "JhSDl",
|
||||
"name": "matrixTitle",
|
||||
"fill": "#222222",
|
||||
"content": "Workcenter x Package Matrix (QTY)",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 15,
|
||||
"fontWeight": "600"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "4hQZP",
|
||||
"name": "matrixBody",
|
||||
"width": "fill_container",
|
||||
"height": 200,
|
||||
"layout": "vertical",
|
||||
"padding": 16,
|
||||
"justifyContent": "center",
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "lH6Yr",
|
||||
"name": "matrixPlaceholder",
|
||||
"fill": "#999999",
|
||||
"content": "[ Matrix Table ]",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14,
|
||||
"fontWeight": "normal"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "FOIFS",
|
||||
"name": "holdSummaryCard",
|
||||
"width": 320,
|
||||
"fill": "#FFFFFF",
|
||||
"cornerRadius": 10,
|
||||
"stroke": {
|
||||
"align": "inside",
|
||||
"thickness": 1,
|
||||
"fill": "#E2E6EF"
|
||||
},
|
||||
"layout": "vertical",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "uikVi",
|
||||
"name": "holdSummaryHeader",
|
||||
"width": "fill_container",
|
||||
"fill": "#FAFBFC",
|
||||
"stroke": {
|
||||
"align": "inside",
|
||||
"thickness": {
|
||||
"bottom": 1
|
||||
},
|
||||
"fill": "#E2E6EF"
|
||||
},
|
||||
"padding": [
|
||||
14,
|
||||
20
|
||||
],
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "VBWBv",
|
||||
"name": "holdSummaryTitle",
|
||||
"fill": "#222222",
|
||||
"content": "Hold Summary",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 15,
|
||||
"fontWeight": "600"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "cFEPm",
|
||||
"name": "holdSummaryBody",
|
||||
"width": "fill_container",
|
||||
"height": 200,
|
||||
"layout": "vertical",
|
||||
"padding": 16,
|
||||
"justifyContent": "center",
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "s7sa1",
|
||||
"name": "holdSummaryPlaceholder",
|
||||
"fill": "#999999",
|
||||
"content": "[ Hold Table ]",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14,
|
||||
"fontWeight": "normal"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
38
gunicorn.conf.py
Normal file
38
gunicorn.conf.py
Normal file
@@ -0,0 +1,38 @@
|
||||
import os
|
||||
|
||||
bind = os.getenv("GUNICORN_BIND", "0.0.0.0:8080")
|
||||
workers = int(os.getenv("GUNICORN_WORKERS", "2")) # 2 workers for redundancy
|
||||
threads = int(os.getenv("GUNICORN_THREADS", "4"))
|
||||
worker_class = "gthread"
|
||||
|
||||
# Timeout settings - critical for dashboard stability
|
||||
timeout = 65 # Worker timeout: must be > call_timeout (55s)
|
||||
graceful_timeout = 30 # Graceful shutdown timeout (enough for thread cleanup)
|
||||
keepalive = 5 # Keep-alive connections timeout
|
||||
|
||||
# Worker lifecycle management - prevent state accumulation
|
||||
max_requests = 1000 # Restart worker after N requests
|
||||
max_requests_jitter = 100 # Random jitter to prevent simultaneous restarts
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Worker Lifecycle Hooks
|
||||
# ============================================================
|
||||
|
||||
def worker_exit(server, worker):
|
||||
"""Clean up background threads and database connections when worker exits."""
|
||||
# Stop background sync threads first
|
||||
try:
|
||||
from mes_dashboard.services.realtime_equipment_cache import (
|
||||
stop_equipment_status_sync_worker
|
||||
)
|
||||
stop_equipment_status_sync_worker()
|
||||
except Exception as e:
|
||||
server.log.warning(f"Error stopping equipment sync worker: {e}")
|
||||
|
||||
# Then dispose database connections
|
||||
try:
|
||||
from mes_dashboard.core.database import dispose_engine
|
||||
dispose_engine()
|
||||
except Exception as e:
|
||||
server.log.warning(f"Error disposing database engine: {e}")
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-02-07
|
||||
@@ -0,0 +1,79 @@
|
||||
## Context
|
||||
|
||||
`DashBoard_vite` 已完成第一批根目錄重構,但仍有部分頁面維持大量 inline script、部分計算在後端實作且缺乏前後一致性驗證、欄位命名規則未全面治理。`DashBoard/` 目前仍作為結構與行為參考來源。此變更目標是完成最終遷移:以 `DashBoard_vite` 根目錄作為唯一開發/部署主體,並建立可持續的前端模組化、欄位契約、快取可觀測性與遷移門檻。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- 完成 root cutover,執行與維護流程完全以 `DashBoard_vite` 為主。
|
||||
- 將主要頁面前端腳本模組化至 Vite 管理,降低單檔模板複雜度。
|
||||
- 將可前端化的展示/聚合計算前移,並建立與既有輸出一致性驗證。
|
||||
- 建立 UI/API/Export 欄位契約與自動檢核機制。
|
||||
- 強化分層快取的健康指標與退化觀測。
|
||||
- 制定遷移驗收門檻、灰度與回退方案。
|
||||
|
||||
**Non-Goals:**
|
||||
- 不重寫所有頁面的視覺設計。
|
||||
- 不更換資料來源(Oracle schema 與核心資料表不變)。
|
||||
- 不改成前後端雙對外服務架構(維持單一 port)。
|
||||
|
||||
## Decisions
|
||||
|
||||
1. Canonical root ownership
|
||||
- Decision: `DashBoard_vite` 為唯一可執行主工程;`DashBoard/` 僅保留為對照基準直到遷移結案。
|
||||
- Why: 避免規格、程式碼、部署分散在不同根目錄。
|
||||
- Alternative: 長期雙根並行;放棄,因維運成本與錯誤率高。
|
||||
|
||||
2. Page-by-page Vite modularization
|
||||
- Decision: 以頁面為單位建立 Vite entry,先抽共用 core(API、toast、table/tree、field contract),再遷移頁面。
|
||||
- Why: 風險可控,便於逐頁回歸驗證。
|
||||
- Alternative: 一次性 SPA rewrite;放棄,風險高且不符合保持既有邏輯要求。
|
||||
|
||||
3. Compute-shift contract with parity checks
|
||||
- Decision: 後端保留原始資料查詢與必要彙整,前端承接展示層聚合/格式化;每個前移計算需有 parity fixture。
|
||||
- Why: 提升前端互動效率,同時避免行為偏移。
|
||||
- Alternative: 全留後端;放棄,無法達成前移目標。
|
||||
|
||||
4. Field contract registry
|
||||
- Decision: 建立欄位契約註冊檔(UI label / API key / export header / semantic type),頁面與匯出共用。
|
||||
- Why: 消除欄位語義不一致與下載對不上畫面的問題。
|
||||
- Alternative: 分頁分散維護;放棄,長期不可控。
|
||||
|
||||
5. Cache observability first-class
|
||||
- Decision: 延續 L1 memory + L2 Redis,新增命中率、資料新鮮度、降級狀態指標並在 health/deep-health 可見。
|
||||
- Why: 快取是效能與穩定核心,需可觀測才能穩定運維。
|
||||
- Alternative: 僅保留功能快取不加觀測;放棄,故障定位成本高。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Risk] 模組化拆分期間,舊 inline 與新 module 並存造成行為差異 → Mitigation: 對每頁保留 feature flag 或 fallback,逐頁切換。
|
||||
- [Risk] 前移計算造成數值差異(四捨五入、分母定義) → Mitigation: 建立固定測試資料與 snapshot 比對,未通過不得切換。
|
||||
- [Risk] 欄位契約改名影響下游報表流程 → Mitigation: 提供 alias 過渡期與變更公告。
|
||||
- [Risk] Redis/Oracle 不可用時測試訊號雜訊高 → Mitigation: 分離 unit/fallback 與 integration pipelines。
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. Baseline freeze
|
||||
- 凍結基線 API payload、頁面主要互動、匯出欄位,產生對照清單。
|
||||
|
||||
2. Cutover preparation
|
||||
- 補齊根目錄執行文件、CI 與腳本,確保不再依賴 `DashBoard/`。
|
||||
|
||||
3. Modularization waves
|
||||
- Wave A: Portal、resource history、job query。
|
||||
- Wave B: resource status、excel query、tables。
|
||||
- 每波完成後執行頁面回歸與欄位一致性檢核。
|
||||
|
||||
4. Compute-shift waves
|
||||
- 先移動展示層聚合與圖表資料整理,再評估進一步前移。
|
||||
- 每項前移需 parity 測試與效能比較。
|
||||
|
||||
5. Final cutover and cleanup
|
||||
- 滿足驗收門檻後將 `DashBoard/` 標記為 archived reference 或移除。
|
||||
- 完成回退文件與操作手冊更新。
|
||||
|
||||
## Open Questions
|
||||
|
||||
- `DashBoard/` 在結案後保留多久(短期備援或立即封存)?
|
||||
- 哪一頁的前移計算業務優先級最高(resource_history vs job_query)?
|
||||
- 是否要求在 cutover 前補齊端對端自動化下載欄位比對?
|
||||
@@ -0,0 +1,32 @@
|
||||
## Why
|
||||
|
||||
目前已完成第一批根目錄重構,但仍存在「部分頁面與邏輯尚未完整遷移」的階段性狀態。需要建立完整遷移提案,將 `DashBoard_vite` 根目錄收斂為唯一開發與運行主體,並完成前端模組化與欄位契約治理,避免長期雙結構維運風險。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 完成從參考結構到根目錄主工程的全面切換,消除對 `DashBoard/` 作為執行依賴。
|
||||
- 以 Vite 完整模組化 Portal 與主要業務頁面前端腳本,逐步移除大型 inline scripts。
|
||||
- 在不改變既有業務流程前提下,將可前端化的展示/聚合計算由後端移至前端。
|
||||
- 建立 UI/API/Export 欄位契約機制,對報表與查詢頁進行一致性治理。
|
||||
- 擴充快取與運維可觀測性,明確 Redis 與記憶體快取的行為、指標與退化策略。
|
||||
- 建立完整遷移驗收與回退規則,作為 cutover 與後續清理依據。
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `root-cutover-finalization`: 定義並完成根目錄主工程最終切換與遺留結構去依賴。
|
||||
- `full-vite-page-modularization`: 完成主要頁面腳本的 Vite 模組化與資產輸出治理。
|
||||
- `frontend-compute-shift`: 將展示層可前端化計算從後端搬移到前端,保持行為一致。
|
||||
- `field-contract-governance`: 建立並執行欄位契約(UI label / API key / export header)一致性規範。
|
||||
- `cache-observability-hardening`: 強化分層快取策略與健康指標,明確失效與退化行為。
|
||||
- `migration-gates-and-rollout`: 定義完整遷移的驗收門檻、灰度與回退流程。
|
||||
|
||||
### Modified Capabilities
|
||||
- None.
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected code: root `src/`, `frontend/`, `scripts/`, `tests/`, `docs/`。
|
||||
- Runtime/deploy: Conda + Node(Vite) build pipeline、Flask/Gunicorn 單一對外 port 模式。
|
||||
- APIs/pages: Portal、resource status、resource history、job query、excel query、tables 等頁面腳本與欄位輸出。
|
||||
- Ops: Redis 快取、記憶體快取、health/deep health 指標與告警解讀。
|
||||
@@ -0,0 +1,15 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Layered Cache SHALL Expose Operational State
|
||||
The route cache implementation SHALL expose layered cache operational state, including mode, freshness, and degradation status.
|
||||
|
||||
#### Scenario: Redis unavailable degradation state
|
||||
- **WHEN** Redis is unavailable
|
||||
- **THEN** health endpoints MUST indicate degraded cache mode while keeping L1 memory cache active
|
||||
|
||||
### Requirement: Cache Telemetry MUST be Queryable for Operations
|
||||
The system MUST provide cache telemetry suitable for operations diagnostics.
|
||||
|
||||
#### Scenario: Telemetry inspection
|
||||
- **WHEN** operators request deep health status
|
||||
- **THEN** cache-related metrics/state SHALL be present and interpretable for troubleshooting
|
||||
@@ -0,0 +1,19 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Field Contract Registry SHALL Define UI/API/Export Mapping
|
||||
The system SHALL maintain a field contract registry mapping UI labels, API keys, export headers, and semantic types.
|
||||
|
||||
#### Scenario: Contract lookup for page rendering
|
||||
- **WHEN** a page renders table headers and values
|
||||
- **THEN** it MUST resolve display labels and keys through the shared field contract definitions
|
||||
|
||||
#### Scenario: Contract lookup for export
|
||||
- **WHEN** export headers are generated
|
||||
- **THEN** header names MUST follow the same semantic mapping used by the page contract
|
||||
|
||||
### Requirement: Consistency Checks MUST Detect Contract Drift
|
||||
The system MUST provide automated checks that detect mismatches between UI, API response keys, and export field definitions.
|
||||
|
||||
#### Scenario: Drift detection failure
|
||||
- **WHEN** a page or export changes a field name without updating the contract
|
||||
- **THEN** consistency checks MUST report a failing result before release
|
||||
@@ -0,0 +1,15 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Display-Layer Computation SHALL be Shifted to Frontend Safely
|
||||
The system SHALL move eligible display-layer computations from backend to frontend while preserving existing business behavior.
|
||||
|
||||
#### Scenario: Equivalent metric output
|
||||
- **WHEN** frontend-computed metrics are produced for a supported page
|
||||
- **THEN** output values MUST match baseline backend results within defined rounding rules
|
||||
|
||||
### Requirement: Compute Shift MUST be Verifiable by Parity Fixtures
|
||||
Each migrated computation MUST have parity fixtures comparing baseline and migrated outputs.
|
||||
|
||||
#### Scenario: Parity test gating
|
||||
- **WHEN** a compute-shifted module is changed
|
||||
- **THEN** parity checks MUST run and fail the migration gate if output differs beyond tolerance
|
||||
@@ -0,0 +1,19 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Major Pages SHALL be Managed by Vite Modules
|
||||
The system SHALL provide Vite-managed module entries for major portal pages, replacing inline scripts in a phased manner.
|
||||
|
||||
#### Scenario: Portal module loading
|
||||
- **WHEN** the portal page is rendered
|
||||
- **THEN** it MUST load its behavior from a Vite-built module asset when available
|
||||
|
||||
#### Scenario: Page module fallback
|
||||
- **WHEN** a required Vite asset is unavailable
|
||||
- **THEN** the system MUST keep page behavior functional through explicit fallback logic
|
||||
|
||||
### Requirement: Build Pipeline SHALL Produce Backend-Served Assets
|
||||
Vite build output MUST be emitted into backend static paths and served by Flask/Gunicorn on the same origin.
|
||||
|
||||
#### Scenario: Build artifact placement
|
||||
- **WHEN** frontend build is executed
|
||||
- **THEN** generated JS/CSS files SHALL be written to the configured backend static dist directory
|
||||
@@ -0,0 +1,15 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Migration Gates SHALL Define Cutover Readiness
|
||||
The system SHALL define explicit migration gates for functional parity, build integrity, and operational health before final cutover.
|
||||
|
||||
#### Scenario: Gate evaluation before cutover
|
||||
- **WHEN** release is prepared for final cutover
|
||||
- **THEN** all required migration gates MUST pass or cutover SHALL be blocked
|
||||
|
||||
### Requirement: Rollout and Rollback Procedures MUST be Actionable
|
||||
The system SHALL document actionable rollout and rollback procedures for root migration.
|
||||
|
||||
#### Scenario: Rollback execution
|
||||
- **WHEN** post-cutover validation fails critical checks
|
||||
- **THEN** operators MUST be able to execute documented rollback steps to restore previous stable behavior
|
||||
@@ -0,0 +1,19 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Root Project SHALL be the Single Execution Target
|
||||
The system SHALL run all application startup, test, and deployment workflows from `DashBoard_vite` root without requiring nested `DashBoard/` paths.
|
||||
|
||||
#### Scenario: Root startup script execution
|
||||
- **WHEN** an operator runs start/deploy scripts from `DashBoard_vite` root
|
||||
- **THEN** all referenced source/config/script paths MUST resolve inside root project structure
|
||||
|
||||
#### Scenario: Root test execution
|
||||
- **WHEN** CI or local developer runs test commands from root
|
||||
- **THEN** tests SHALL execute against root source tree and root config files
|
||||
|
||||
### Requirement: Reference Directory MUST Remain Non-Authoritative
|
||||
`DashBoard/` SHALL be treated as reference-only and MUST NOT be required for production runtime.
|
||||
|
||||
#### Scenario: Runtime independence
|
||||
- **WHEN** root application is started in an environment without `DashBoard/`
|
||||
- **THEN** the application MUST remain functional for the defined migration scope
|
||||
@@ -0,0 +1,42 @@
|
||||
## 1. Root Cutover Finalization
|
||||
|
||||
- [x] 1.1 Inventory all remaining runtime/test/deploy references to nested `DashBoard/` paths.
|
||||
- [x] 1.2 Remove or replace nested-path dependencies so root scripts and app startup are self-contained.
|
||||
- [x] 1.3 Define and execute root-only smoke startup checks.
|
||||
|
||||
## 2. Vite Full Page Modularization
|
||||
|
||||
- [x] 2.1 Create/standardize Vite entries for Portal, Resource Status, Resource History, Job Query, Excel Query, and Tables.
|
||||
- [x] 2.2 Extract shared frontend core modules (API wrappers, table/tree helpers, field contract helpers).
|
||||
- [x] 2.3 Replace targeted inline scripts with module bootstraps while preserving fallback behavior.
|
||||
- [x] 2.4 Update template asset resolution to support per-page Vite bundles.
|
||||
|
||||
## 3. Frontend Compute Shift
|
||||
|
||||
- [x] 3.1 Identify display-layer computations eligible for frontend migration and document parity rules.
|
||||
- [x] 3.2 Migrate selected calculations page by page with deterministic helper functions.
|
||||
- [x] 3.3 Add parity fixtures/tests comparing baseline backend vs migrated frontend outputs.
|
||||
|
||||
## 4. Field Contract Governance
|
||||
|
||||
- [x] 4.1 Introduce shared field contract registry for UI/API/Export mapping.
|
||||
- [x] 4.2 Apply the registry to Job Query and Resource History completely (including headers and semantic types).
|
||||
- [x] 4.3 Extend consistency checks to additional pages and exports.
|
||||
|
||||
## 5. Cache Observability Hardening
|
||||
|
||||
- [x] 5.1 Expand cache telemetry fields in health/deep-health outputs.
|
||||
- [x] 5.2 Add explicit degraded-mode visibility when Redis is unavailable.
|
||||
- [x] 5.3 Validate cache behavior and telemetry under L1-only and L1+L2 modes.
|
||||
|
||||
## 6. Migration Gates and Rollout
|
||||
|
||||
- [x] 6.1 Define gate checklist for cutover readiness (tests, parity, build, health).
|
||||
- [x] 6.2 Document rollout steps and operator runbook for the final cutover.
|
||||
- [x] 6.3 Document rollback procedure and rehearse rollback validation.
|
||||
|
||||
## 7. Validation and Documentation
|
||||
|
||||
- [x] 7.1 Run focused unit/integration checks in root project and record evidence.
|
||||
- [x] 7.2 Record known environment-dependent gaps (Oracle/Redis) and mitigation plan.
|
||||
- [x] 7.3 Update README/docs to declare final root-first workflow and migration status.
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-02-07
|
||||
@@ -0,0 +1,64 @@
|
||||
## Context
|
||||
|
||||
現有程式碼主體在 `DashBoard/` 子目錄,OpenSpec/opsx 在 `DashBoard_vite` 根目錄,造成需求追蹤、實作與驗證分離。重構目標是以 `DashBoard/` 作為參考來源,將可執行專案落到根目錄,並在維持單一對外服務埠前提下導入 Vite 前端建置與模組化。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- 在 `DashBoard_vite` 根目錄建立可運行工程,與 OpenSpec artifacts 同層。
|
||||
- 維持 Flask/Gunicorn 單一對外 port,前端資產由 Flask static 提供。
|
||||
- 導覽改為抽屜分組,保持既有頁面與 drill-down 操作語意。
|
||||
- 導入分層快取(L1 memory + L2 Redis)取代 NoOp 預設。
|
||||
- 建立畫面欄位、API key、下載欄位的一致性規範。
|
||||
|
||||
**Non-Goals:**
|
||||
- 不在第一階段重寫所有頁面 UI。
|
||||
- 不更動核心商業資料來源(Oracle schema 與主要 SQL 邏輯)。
|
||||
- 不在第一階段導入多服務或多 port 架構。
|
||||
|
||||
## Decisions
|
||||
|
||||
1. Root-first migration(根目錄主工程)
|
||||
- Decision: 以 `DashBoard/` 為參考,將執行入口、`src/`、`scripts/`、前端建置等移到 `DashBoard_vite` 根目錄。
|
||||
- Rationale: 使 OpenSpec 與可執行程式在同一工作根,避免流程分裂。
|
||||
- Alternative considered: 繼續在 `DashBoard/` 開發,放棄;因與使用者要求衝突。
|
||||
|
||||
2. Single-port Vite integration
|
||||
- Decision: 使用 Vite build 輸出到 Flask static,僅在開發時可選擇 Vite dev server,不作對外正式服務。
|
||||
- Rationale: 保持現行部署模型與防火牆策略,降低切換風險。
|
||||
- Alternative considered: 分離前後端雙服務;放棄以符合單一 port 約束。
|
||||
|
||||
3. Layered route cache
|
||||
- Decision: 路由層快取採用 L1 memory TTL + L2 Redis JSON;Redis 不可用時仍有 L1。
|
||||
- Rationale: 改善響應速度與穩定性,避免 NoOp 導致的快取失效。
|
||||
- Alternative considered: Redis-only;放棄以避免 Redis 異常時退化過大。
|
||||
|
||||
4. Navigation IA by drawers
|
||||
- Decision: 將 portal 導覽分為「報表類、查詢類、開發工具類」抽屜,頁面內容維持原路由/iframe lazy load。
|
||||
- Rationale: 降低使用者認知負擔,同時避免一次性替換頁面內邏輯。
|
||||
- Alternative considered: 直接改成 SPA router;放棄以降低第一階段風險。
|
||||
|
||||
5. Field contract normalization
|
||||
- Decision: 建立欄位契約字典(UI label / API key / export header),並先修正已知不一致。
|
||||
- Rationale: 避免匯出與畫面解讀差異造成誤用。
|
||||
- Alternative considered: 每頁分散維護;放棄因長期不可維護。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Risk] 根目錄遷移時檔案基線混亂(舊目錄與新目錄並存) → Mitigation: 明確標註 `DashBoard/` 為 reference,新增 root 驗證與遷移清單。
|
||||
- [Risk] Redis/Oracle 在本機測試環境不可用導致測試波動 → Mitigation: 分離「單元測試通過」與「環境依賴測試」兩條驗證報告。
|
||||
- [Risk] Portal 抽屜調整影響既有 E2E selector → Mitigation: 保留原 tab class/data-target,先兼容再逐步更新測試。
|
||||
- [Risk] 欄位命名調整影響下游檔案流程 → Mitigation: 提供別名過渡期與欄位映射文件。
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. 建立根目錄主工程骨架(參照 `DashBoard/`),保留 `DashBoard/` 作為對照來源。
|
||||
2. 導入 Vite build 流程並接入 `deploy/start` 腳本。
|
||||
3. 套用 portal 抽屜導覽與快取 backend 重構。
|
||||
4. 執行欄位一致性第一批修正(job query / resource history)。
|
||||
5. 補齊根目錄測試與操作文件,確認單一 port 運作。
|
||||
|
||||
## Open Questions
|
||||
|
||||
- 根目錄最終是否保留 `DashBoard/` 作為長期參考,或在完成驗收後移除?
|
||||
- 第二階段前端運算前移的優先頁面順序(`resource_history` vs `job_query`)是否有業務優先級?
|
||||
@@ -0,0 +1,29 @@
|
||||
## Why
|
||||
|
||||
目前可執行程式碼位於 `DashBoard/` 子目錄,與 `DashBoard_vite` 根目錄的 OpenSpec/opsx 工作流分離,導致規格、實作與驗證不在同一專案根。需要以 `DashBoard` 為參考,將重構主體統一到 `DashBoard_vite` 根目錄,並同時導入 Vite 以改善前端可維護性與體驗。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 在 `DashBoard_vite` 根目錄建立可執行的重構專案骨架,參照既有 `DashBoard` 功能與路由。
|
||||
- 維持 Flask/Gunicorn 單一對外 port,導入 Vite 作為前端建置工具(build artifact 由 Flask 提供)。
|
||||
- 導覽由平鋪 tab 重構為功能抽屜(報表類、查詢類、開發工具類),保持既有業務操作路徑。
|
||||
- 快取策略改為可運作的分層快取(L1 記憶體 + L2 Redis),不再使用 NoOp 做為預設。
|
||||
- 建立前端顯示欄位與下載欄位的一致性規範,先修正已知不一致案例。
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `root-project-restructure`: 以 `DashBoard` 為參考,將可運行的重構工程落在 `DashBoard_vite` 根目錄。
|
||||
- `vite-single-port-integration`: Vite 建置結果整合進 Flask static,維持單一 server/port 對外。
|
||||
- `portal-drawer-navigation`: Portal 導覽改為抽屜分類且維持原頁面邏輯。
|
||||
- `layered-route-cache`: 路由層快取改為 L1 memory + L2 Redis 的可用實作。
|
||||
- `field-name-consistency`: 統一畫面欄位、API key 與匯出欄位命名/語義。
|
||||
|
||||
### Modified Capabilities
|
||||
- None.
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected codebase root: `DashBoard_vite`(新主工程落點)
|
||||
- Reference baseline: `DashBoard/`(保留作比對與遷移來源)
|
||||
- Affected systems: Flask app factory, templates, frontend build pipeline, deployment/start scripts, cache layer, export SQL/headers
|
||||
@@ -0,0 +1,12 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: UI and Export Fields SHALL Have a Consistent Contract
|
||||
The system SHALL define and apply a consistent contract among UI column labels, API keys, and export headers for report/query pages.
|
||||
|
||||
#### Scenario: Job query export naming consistency
|
||||
- **WHEN** job query exports include cause/repair/symptom values
|
||||
- **THEN** exported field names SHALL reflect semantic value type consistently (e.g., code name vs status name)
|
||||
|
||||
#### Scenario: Resource history field alignment
|
||||
- **WHEN** resource history detail table shows KPI columns
|
||||
- **THEN** columns required by export semantics (including Availability%) SHALL be present or explicitly mapped
|
||||
@@ -0,0 +1,19 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Route Cache SHALL Use Layered Storage
|
||||
The route cache SHALL use L1 in-memory TTL cache and L2 Redis JSON cache when Redis is available.
|
||||
|
||||
#### Scenario: L1 cache hit
|
||||
- **WHEN** a cached key exists in L1 and is unexpired
|
||||
- **THEN** the API response SHALL be returned from memory without querying Redis
|
||||
|
||||
#### Scenario: L2 fallback
|
||||
- **WHEN** a cached key is missing in L1 but exists in Redis
|
||||
- **THEN** the value SHALL be returned and warmed into L1
|
||||
|
||||
### Requirement: Cache SHALL Degrade Gracefully Without Redis
|
||||
The route cache SHALL remain functional with L1 cache when Redis is unavailable.
|
||||
|
||||
#### Scenario: Redis unavailable at startup
|
||||
- **WHEN** Redis health check fails during app initialization
|
||||
- **THEN** route cache operations SHALL continue using L1 cache without application failure
|
||||
@@ -0,0 +1,15 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Portal Navigation SHALL Group Entries by Functional Drawers
|
||||
The portal SHALL group navigation entries into functional drawers: reports, queries, and development tools.
|
||||
|
||||
#### Scenario: Drawer grouping visibility
|
||||
- **WHEN** users open the portal
|
||||
- **THEN** report pages and query pages SHALL appear in separate drawer groups
|
||||
|
||||
### Requirement: Existing Page Behavior SHALL Remain Compatible
|
||||
The portal navigation refactor SHALL preserve existing target routes and lazy-load behavior for content frames.
|
||||
|
||||
#### Scenario: Route continuity
|
||||
- **WHEN** a user selects an existing page entry from the new drawer
|
||||
- **THEN** the corresponding original route SHALL be loaded without changing page business logic behavior
|
||||
@@ -0,0 +1,12 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Root Directory SHALL be the Primary Executable Project
|
||||
The system SHALL treat `DashBoard_vite` root directory as the primary executable project, while `DashBoard/` remains reference-only during migration.
|
||||
|
||||
#### Scenario: Running app from root
|
||||
- **WHEN** a developer runs project scripts from `DashBoard_vite` root
|
||||
- **THEN** the application startup flow SHALL resolve code and config from root project files
|
||||
|
||||
#### Scenario: Reference directory preserved
|
||||
- **WHEN** migration is in progress
|
||||
- **THEN** `DashBoard/` SHALL remain available for structure comparison and behavior verification
|
||||
@@ -0,0 +1,15 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Frontend Build SHALL Use Vite With Flask Static Output
|
||||
The system SHALL use Vite to build frontend assets and output artifacts into Flask static directories served by the backend.
|
||||
|
||||
#### Scenario: Build asset generation
|
||||
- **WHEN** frontend build is executed
|
||||
- **THEN** Vite SHALL generate portal-related JS/CSS artifacts into the backend static output path
|
||||
|
||||
### Requirement: Deployment SHALL Preserve Single External Port
|
||||
The system SHALL preserve single-port external serving through Flask/Gunicorn.
|
||||
|
||||
#### Scenario: Production serving mode
|
||||
- **WHEN** the system runs in deployment mode
|
||||
- **THEN** frontend assets SHALL be served through Flask on the same external port as API/page routes
|
||||
@@ -0,0 +1,26 @@
|
||||
## 1. Root Migration Baseline
|
||||
|
||||
- [x] 1.1 Build root project baseline in `DashBoard_vite` by referencing `DashBoard/` structure while preserving `DashBoard/` as comparison source.
|
||||
- [x] 1.2 Ensure root-level Python entry/config/scripts can run without depending on nested `DashBoard/` paths.
|
||||
- [x] 1.3 Update root README and environment setup notes to make root-first workflow explicit.
|
||||
|
||||
## 2. Vite + Single-Port Integration
|
||||
|
||||
- [x] 2.1 Add root frontend Vite project and configure build output to backend static assets.
|
||||
- [x] 2.2 Integrate frontend build into deploy/start scripts with fallback behavior when npm build is unavailable.
|
||||
- [x] 2.3 Verify root app serves Vite-built assets through Flask on the same external port.
|
||||
|
||||
## 3. Portal Navigation Refactor
|
||||
|
||||
- [x] 3.1 Refactor root portal navigation to drawer groups (reports/queries/dev-tools) while keeping existing route targets.
|
||||
- [x] 3.2 Keep lazy-load frame behavior and health popup behavior compatible after navigation refactor.
|
||||
|
||||
## 4. Cache and Field Contract Updates
|
||||
|
||||
- [x] 4.1 Replace default NoOp route cache in root app with layered cache backend (L1 memory + optional Redis).
|
||||
- [x] 4.2 Align known field-name inconsistencies between UI and export (job query and resource history first batch).
|
||||
|
||||
## 5. Validation and Documentation
|
||||
|
||||
- [x] 5.1 Run focused root tests for app factory/cache/query modules and record results.
|
||||
- [x] 5.2 Document residual environment-dependent test gaps (Oracle/Redis dependent cases) and next actions.
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-02-07
|
||||
@@ -0,0 +1,50 @@
|
||||
## Context
|
||||
|
||||
目前主要報表頁多已採 `frontend_asset(...) + Vite module + inline fallback` 模式,但 `hold_detail` 仍停留在純 inline script。這造成:
|
||||
- 例外頁面無法受益於共用模組治理與 build pipeline。
|
||||
- 動態表格字串拼接保留 XSS 風險。
|
||||
- 長期維護出現「主流程已模組化、單頁特例未遷移」的不一致。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- 讓 `hold_detail` 與其他報表頁採同一套 Vite 載入模式。
|
||||
- 保留既有功能語意(篩選、分頁、刷新、導航)與 MesApi 呼叫契約。
|
||||
- 將高風險動態輸出改為 escape-safe 渲染。
|
||||
- 加上模板整合測試覆蓋 module/fallback 分支。
|
||||
|
||||
**Non-Goals:**
|
||||
- 不改後端資料模型與查詢邏輯。
|
||||
- 不重設 UI 視覺樣式與互動流程。
|
||||
- 不移除 fallback(本次仍保留回退能力)。
|
||||
|
||||
## Decisions
|
||||
|
||||
### Decision 1: 以「抽取 inline script 到 Vite entry」完成遷移
|
||||
- 選擇:新增 `frontend/src/hold-detail/main.js`,以既有邏輯為基礎遷移,模板改為 module 優先、fallback 次之。
|
||||
- 理由:最小風險完成頁面納管,避免一次性重寫行為。
|
||||
|
||||
### Decision 2: 保持全域 handler 相容
|
||||
- 選擇:module 內維持 `window` 介面供既有 `onclick` 使用。
|
||||
- 理由:降低模板 DOM 大改成本,優先保證 parity。
|
||||
|
||||
### Decision 3: 在 module 與 fallback 皆補 escape 防護
|
||||
- 選擇:對 workcenter/package/lot 資料動態輸出加入 escape/quoted-string 保護。
|
||||
- 理由:避免 fallback 成為安全漏洞旁路。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Risk] 複製遷移過程遺漏函式導致 runtime error → Mitigation: build + template test 覆蓋 module 路徑。
|
||||
- [Risk] fallback 與 module 雙軌造成維護成本 → Mitigation: 保持語意對齊並在後續階段評估移除 fallback。
|
||||
- [Risk] escape 導致個別顯示格式變化 → Mitigation: 僅防注入,不改原欄位值與排序/篩選語意。
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. 增加 `hold-detail` Vite entry 與 module 檔案。
|
||||
2. 調整 `hold_detail.html` scripts block 為 module/fallback 雙軌。
|
||||
3. 補強 module + fallback 的動態輸出 escape。
|
||||
4. build 與 pytest 驗證,更新 tasks。
|
||||
|
||||
## Open Questions
|
||||
|
||||
- 是否在下一階段移除 `hold_detail` fallback inline script,以降低雙路徑維運成本。
|
||||
@@ -0,0 +1,26 @@
|
||||
## Why
|
||||
|
||||
`hold_detail` 目前仍是大型 inline script,尚未納入 Vite 模組治理,且動態 HTML 字串拼接存在潛在注入風險。為了完成報表頁一致的現代化架構與安全基線,需要將該頁補齊至與其餘主要頁面相同的模組化與防護水位。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 新增 `hold-detail` Vite entry 並由模板透過 `frontend_asset(...)` 優先載入 module。
|
||||
- 保留現有 inline script 作為 asset 缺失時 fallback,維持既有操作語意不變。
|
||||
- 將 `hold_detail` 的動態表格/篩選渲染改為 escape-safe 輸出,避免不受信字串直接注入 DOM。
|
||||
- 補充模板整合測試,驗證 `hold_detail` 的 module/fallback 路徑。
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- None.
|
||||
|
||||
### Modified Capabilities
|
||||
- `full-vite-page-modularization`: 擴展 major page 模組化覆蓋到 hold-detail 報表頁。
|
||||
- `field-contract-governance`: 將動態渲染安全契約擴展到 hold-detail 報表內容。
|
||||
- `report-effects-parity`: 明確要求 hold-detail 的篩選、分頁、分佈互動在遷移後維持等效。
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected code: `frontend/src/`, `frontend/vite.config.js`, `src/mes_dashboard/templates/hold_detail.html`, `tests/test_template_integration.py`。
|
||||
- APIs/routes: `/hold-detail`, `/api/wip/hold-detail/*`(僅前端調用與渲染方式調整,不更動後端契約)。
|
||||
- Runtime behavior: 單一 port 與既有 MesApi/retry 行為不變。
|
||||
@@ -0,0 +1,8 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Hold Detail Dynamic Rendering MUST Sanitize Untrusted Values
|
||||
Dynamic table and distribution rendering in hold-detail SHALL sanitize untrusted text before injecting into HTML attributes or content.
|
||||
|
||||
#### Scenario: Hold reason distribution contains HTML-like payload
|
||||
- **WHEN** workcenter/package/lot fields include HTML-like text from upstream data
|
||||
- **THEN** the hold-detail page MUST render escaped text and MUST NOT execute embedded markup or scripts
|
||||
@@ -0,0 +1,12 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Hold Detail Page SHALL Be Served by a Vite Module
|
||||
The system SHALL provide a dedicated Vite entry bundle for the hold-detail report page.
|
||||
|
||||
#### Scenario: Hold-detail module asset exists
|
||||
- **WHEN** `/hold-detail` is rendered and `hold-detail.js` exists in static dist
|
||||
- **THEN** the page MUST load behavior from the Vite module entry
|
||||
|
||||
#### Scenario: Hold-detail module asset missing
|
||||
- **WHEN** `/hold-detail` is rendered and the module asset is unavailable
|
||||
- **THEN** the page MUST remain operational through explicit inline fallback logic
|
||||
@@ -0,0 +1,8 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Hold Detail Interaction Semantics SHALL Remain Equivalent After Modularization
|
||||
Migrating hold-detail to a Vite module SHALL preserve existing filter, pagination, and refresh behavior.
|
||||
|
||||
#### Scenario: User applies filters and paginates on hold-detail
|
||||
- **WHEN** users toggle age/workcenter/package filters and navigate pages
|
||||
- **THEN** returned lots, distribution highlights, and pagination state MUST remain behaviorally equivalent to baseline inline behavior
|
||||
@@ -0,0 +1,17 @@
|
||||
## 1. Hold Detail Vite Modularization
|
||||
|
||||
- [x] 1.1 Add `hold-detail` entry to Vite build configuration.
|
||||
- [x] 1.2 Create `frontend/src/hold-detail/main.js` by migrating existing page script while preserving behavior.
|
||||
- [x] 1.3 Update `hold_detail.html` to prefer `frontend_asset('hold-detail.js')` with inline fallback retention.
|
||||
|
||||
## 2. Security and Parity Hardening
|
||||
|
||||
- [x] 2.1 Sanitize dynamic HTML/attribute interpolation in hold-detail module rendering paths.
|
||||
- [x] 2.2 Apply equivalent sanitization in inline fallback logic to avoid security bypass.
|
||||
- [x] 2.3 Preserve legacy global handler compatibility for existing inline event hooks.
|
||||
|
||||
## 3. Validation
|
||||
|
||||
- [x] 3.1 Build frontend and verify `hold-detail.js` output in static dist.
|
||||
- [x] 3.2 Extend template integration tests for hold-detail module/fallback rendering.
|
||||
- [x] 3.3 Run focused pytest suite for template/frontend regressions.
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-02-07
|
||||
@@ -0,0 +1,101 @@
|
||||
## Context
|
||||
|
||||
目前根目錄 `DashBoard_vite` 已完成單一埠 Vite 整合與主要頁面模組化,但運行層仍有三類風險:
|
||||
1. 韌性參數未完全生效(例如 DB pool 參數在設定層存在、engine 層未完全採用)。
|
||||
2. 故障語意未完全標準化(pool 耗盡/熔斷開啟/降級回應仍有泛化 500)。
|
||||
3. 效能優化尚未形成一致策略(快取資料結構與全量 merge 路徑可再降低 CPU 與記憶體負載)。
|
||||
|
||||
本設計在不改變業務邏輯與頁面流程前提下,推進 P0/P1/P2:
|
||||
- P0:穩定性與退避
|
||||
- P1:查詢效率與資料結構
|
||||
- P2:運維一致性與自癒
|
||||
|
||||
約束條件:
|
||||
- `resource`(設備主檔)與 `wip`(即時狀態)維持全表快取,因資料規模可接受且可換取查詢一致性與延遲穩定。
|
||||
- Vite 架構持續以「前端可複用元件 + 前端運算前移」為核心方向。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- 讓 DB pool / timeout / circuit breaker 形成可配置且可驗證的穩定性基線。
|
||||
- 在 pool 耗盡與服務降級時,提供可辨識錯誤碼、HTTP 狀態與前端退避策略。
|
||||
- 保留全表快取前提下,優化快取資料形狀與索引路徑,降低每次請求全量合併成本。
|
||||
- 對齊 conda + systemd + watchdog 運行模型,讓 worker 自癒與重啟流程可操作、可觀測。
|
||||
- 持續擴大前端運算前移範圍,並以 parity 驗證保證結果一致。
|
||||
|
||||
**Non-Goals:**
|
||||
- 不改變既有頁面資訊架構、分頁/鑽取邏輯與核心業務規則。
|
||||
- 不將 `resource/wip` 改為分片快取或拆分多來源讀取。
|
||||
- 不引入多埠部署或拆分為前後端不同網域。
|
||||
- 不在本次變更中重寫所有歷史 SQL 或全面替換資料來源。
|
||||
|
||||
## Decisions
|
||||
|
||||
### Decision 1: 以「配置即行為」收斂 DB 連線與保護策略(P0)
|
||||
- 決策:`database.py` 的 engine 建立必須直接採用 settings/.env 的 pool 與 timeout 參數,並在 `/health/deep` 輸出實際生效值。
|
||||
- 原因:目前存在設定值與實際 engine 參數可能分離,導致調參無效。
|
||||
- 替代方案:
|
||||
- 保留硬編碼參數,僅調整 `.env.example`(拒絕,無法保證生效)。
|
||||
- 完全改為每環境不同程式碼分支(拒絕,維運成本高)。
|
||||
|
||||
### Decision 2: 標準化「退避可判讀」錯誤語意(P0)
|
||||
- 決策:新增/明確化 pool exhausted、circuit open、service degraded 的錯誤碼與 HTTP 映射,並在前端 `MesApi` 依狀態碼與錯誤碼進行退避。
|
||||
- 原因:泛化 500 導致前端無法做差異化重試與提示。
|
||||
- 替代方案:
|
||||
- 維持所有 5xx 同一重試邏輯(拒絕,會加劇擁塞)。
|
||||
- 僅靠文字訊息判斷(拒絕,不穩定且難國際化)。
|
||||
|
||||
### Decision 3: 在「全表快取不變」前提下做索引化與增量化(P1)
|
||||
- 決策:保留 `resource/wip` 全表快取資料來源,但額外建立 process/redis 層索引(如 RESOURCEID → record index)與預聚合中間結果,減少每請求全量 merge。
|
||||
- 原因:資料量雖不大,但高併發下重複全量轉換與合併會累積 CPU 成本。
|
||||
- 替代方案:
|
||||
- 改為分片快取(拒絕,破壞已確認的資料一致性策略)。
|
||||
- 完全回 Oracle 即時計算(拒絕,增加 DB 壓力與延遲波動)。
|
||||
|
||||
### Decision 4: 前端運算前移採「可驗證前移」策略(P1)
|
||||
- 決策:優先前移展示層聚合/比率/圖表資料整理,並為每個前移計算建立 parity fixture 與容差規則。
|
||||
- 原因:符合 Vite 架構目的,減輕後端負擔,同時避免靜默偏差。
|
||||
- 替代方案:
|
||||
- 一次性大量前移(拒絕,驗證風險高)。
|
||||
- 完全不前移(拒絕,無法達成改造目標)。
|
||||
|
||||
### Decision 5: 運維流程統一以 conda + systemd + watchdog(P2)
|
||||
- 決策:部署與監控路徑統一到 conda 環境;systemd 服務模板、啟停腳本、watchdog PID/flag 路徑統一;加入自癒與告警門檻。
|
||||
- 原因:避免 `venv`/`conda` 混用造成重啟失效或定位困難。
|
||||
- 替代方案:
|
||||
- 保持雙系統共存(拒絕,長期不一致風險高)。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Risk] 調整錯誤碼與狀態碼可能影響既有前端假設 → Mitigation:先以向後相容 envelope 保留既有 `success/error` 結構,再新增標準化 code/meta 欄位。
|
||||
- [Risk] 啟用 circuit breaker 後短時間內可能增加 503 可見度 → Mitigation:設定合理門檻與 recovery timeout,並提供管理頁可觀測狀態與手動恢復流程。
|
||||
- [Risk] 新索引/預聚合增加記憶體占用 → Mitigation:設 TTL、大小監控與健康檢查輸出,必要時可透過配置關閉特定索引層。
|
||||
- [Risk] 前端運算前移可能出現精度差異 → Mitigation:定義 rounding/tolerance 並在 CI gate 執行 parity 測試。
|
||||
- [Risk] systemd 與腳本改動可能影響部署流程 → Mitigation:提供 rollout/rollback 演練步驟與 smoke check。
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. P0 先行(穩定性)
|
||||
- 讓 DB pool/call timeout/circuit breaker 參數化且生效。
|
||||
- 新增 pool exhausted 與 degraded 錯誤語意;前端 `MesApi` 加入對應退避策略。
|
||||
- 補充 health/deep 與 admin status 的可觀測欄位。
|
||||
|
||||
2. P1 續行(效率)
|
||||
- 保留 `resource/wip` 全表快取資料源。
|
||||
- 加入索引化/預聚合路徑與增量更新鉤子,降低全量 merge 次數。
|
||||
- 擴充前端 compute-shift,補 parity fixtures。
|
||||
|
||||
3. P2 收斂(運維)
|
||||
- 統一 conda + systemd + watchdog 服務定義與文件。
|
||||
- 設定 worker 自癒與告警門檻(重啟頻率、pool 飽和、降級持續時間)。
|
||||
- 完成壓測與重啟演練 gate 後放行。
|
||||
|
||||
4. Rollback
|
||||
- 任一 gate 失敗即回退到前一穩定版本(腳本 + artifacts + 服務模板)。
|
||||
- 保留向後相容錯誤回應欄位以降低回退期間前端風險。
|
||||
|
||||
## Open Questions
|
||||
|
||||
- pool exhausted 的最終 HTTP 語意是否固定為 `503`(含 `Retry-After`)或在部分查詢端點使用 `429`?
|
||||
- 告警通道是否先落地在 log + health gate,或直接接既有監控平台(若有)?
|
||||
- 前端計算容差的全域預設值是否統一(如 1e-6 / 小數 1 位),或按指標分類?
|
||||
@@ -0,0 +1,39 @@
|
||||
## Why
|
||||
|
||||
目前根目錄遷移與 Vite 架構已完成可用性與功能對齊,但「穩定性、退避、自癒、查詢效率」仍未被完整定義為可驗收的規格。現在需要在不改變既有業務邏輯的前提下,將運行韌性與前端運算前移策略正式化,避免 cutover 後在高負載或故障情境下出現不一致行為。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 以三階段推進非破壞式優化:
|
||||
- P0(先救穩定):讓 DB pool 參數真正生效、在生產基線啟用 circuit breaker、補齊 pool exhausted 的專用錯誤語意與前後端退避行為。
|
||||
- P1(再拚效率):重整快取資料結構與查詢路徑(索引化/增量化),降低每次請求的全量 merge 成本。
|
||||
- P2(運維收斂):統一 conda + systemd 執行模型,補齊 worker 自癒與告警門檻,讓 watchdog/restart 流程可操作且可觀測。
|
||||
- 明確保留既有架構原則:
|
||||
- `resource`(設備基礎資料)與 `wip`(線上即時狀況)維持全表快取策略,不改成分片或拆表快取。
|
||||
- Vite 架構持續以「元件複用(圖表/查詢/抽屜)」與「運算前移至瀏覽器」為主軸,前端承接可前移的聚合與呈現計算。
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `runtime-resilience-recovery`: 定義 DB pool 耗盡、worker 異常、服務降級時的標準退避、恢復與熱重啟流程。
|
||||
- `conda-systemd-runtime-alignment`: 定義 conda 環境、systemd 服務、watchdog 與啟停腳本的一致部署契約與驗收門檻。
|
||||
|
||||
### Modified Capabilities
|
||||
- `frontend-compute-shift`: 擴充前端運算前移邊界與 parity 驗證,確保前端計算結果與後端契約一致。
|
||||
- `full-vite-page-modularization`: 強化跨頁可複用元件與共用核心模組(圖表、查詢、抽屜、欄位契約)的要求。
|
||||
- `layered-route-cache`: 明確要求保留 `resource/wip` 全表快取,並在此基礎上優化索引與資料形狀。
|
||||
- `cache-observability-hardening`: 擴充快取/連線池/熔斷器的可觀測欄位、降級訊號與告警閾值。
|
||||
- `migration-gates-and-rollout`: 新增穩定性壓測、pool 壓力、worker 重啟演練等遷移門檻。
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected code:
|
||||
- Backend: `src/mes_dashboard/core/database.py`, `src/mes_dashboard/core/circuit_breaker.py`, `src/mes_dashboard/core/cache.py`, `src/mes_dashboard/routes/*.py`, `src/mes_dashboard/services/resource_cache.py`, `src/mes_dashboard/services/realtime_equipment_cache.py`。
|
||||
- Frontend: `frontend/src/core/*` 與各頁 entry 模組,持續抽取可複用圖表/查詢邏輯。
|
||||
- Ops: `scripts/start_server.sh`, `scripts/worker_watchdog.py`, `deploy/mes-dashboard-watchdog.service`, `.env.example`, `README.md`。
|
||||
- API/behavior:
|
||||
- 新增或標準化故障語意(含 pool exhausted / circuit open / degraded)與對應退避策略。
|
||||
- Dependencies/systems:
|
||||
- 維持單一埠服務模型;持續使用 conda + gunicorn + redis + systemd/watchdog。
|
||||
- Validation:
|
||||
- 增加 resilience/performance 測試與 rollout gate,驗證降級、恢復、快取一致性與前後端計算一致性。
|
||||
@@ -0,0 +1,22 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Health Endpoints SHALL Expose Pool Saturation and Degradation Reason Codes
|
||||
Operational health endpoints MUST report connection pool saturation indicators and explicit degradation reason codes.
|
||||
|
||||
#### Scenario: Pool saturation observed
|
||||
- **WHEN** checked-out connections and overflow approach configured limits
|
||||
- **THEN** deep health output MUST expose saturation metrics and degraded reason classification
|
||||
|
||||
### Requirement: Degraded Responses MUST Be Correlatable Across API and Health Telemetry
|
||||
Error responses for degraded states SHALL include stable codes that can be mapped to health telemetry and operational dashboards.
|
||||
|
||||
#### Scenario: Degraded API response correlation
|
||||
- **WHEN** an API request fails due to circuit-open or pool-exhausted conditions
|
||||
- **THEN** operators MUST be able to match the response code to current health telemetry state
|
||||
|
||||
### Requirement: Operational Alert Thresholds SHALL Be Explicitly Defined
|
||||
The system MUST define alert thresholds for sustained degraded state, repeated worker recovery, and abnormal retry pressure.
|
||||
|
||||
#### Scenario: Sustained degradation threshold exceeded
|
||||
- **WHEN** degraded status persists beyond configured duration
|
||||
- **THEN** the monitoring contract MUST classify the service as alert-worthy with actionable context
|
||||
@@ -0,0 +1,22 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Production Service Runtime SHALL Use Conda-Aligned Execution Paths
|
||||
Service units and operational scripts MUST run with a consistent conda-managed Python runtime.
|
||||
|
||||
#### Scenario: Service unit starts application
|
||||
- **WHEN** systemd starts the dashboard service and watchdog
|
||||
- **THEN** both processes MUST execute using the configured conda environment binaries and paths
|
||||
|
||||
### Requirement: Watchdog and Runtime Paths MUST Be Operationally Consistent
|
||||
PID files, restart flag paths, state files, and worker control interfaces SHALL be consistent across scripts, environment variables, and systemd units.
|
||||
|
||||
#### Scenario: Watchdog handles restart flag
|
||||
- **WHEN** a restart flag is written by admin control endpoints
|
||||
- **THEN** watchdog MUST read the same configured path set and signal the correct Gunicorn master process
|
||||
|
||||
### Requirement: Deployment Documentation MUST Match Runtime Contract
|
||||
Runbooks and deployment documentation MUST describe the same conda/systemd/watchdog contract used by the deployed system.
|
||||
|
||||
#### Scenario: Operator follows deployment runbook
|
||||
- **WHEN** an operator performs deploy, health check, and rollback from documentation
|
||||
- **THEN** documented commands and paths MUST work without requiring venv-specific assumptions
|
||||
@@ -0,0 +1,22 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Compute-Shifted Logic SHALL Be Exposed as Reusable Frontend Core Modules
|
||||
Frontend-computed metrics and transformations MUST be implemented as reusable, testable modules instead of page-local inline logic.
|
||||
|
||||
#### Scenario: Multiple pages consume shared compute logic
|
||||
- **WHEN** two or more pages require the same metric transformation or aggregation
|
||||
- **THEN** they MUST import a shared frontend core module and produce consistent outputs
|
||||
|
||||
### Requirement: Frontend Compute Parity MUST Include Tolerance Contracts Per Metric
|
||||
Parity verification SHALL define explicit tolerance and rounding contracts per migrated metric.
|
||||
|
||||
#### Scenario: Parity check for migrated metric
|
||||
- **WHEN** migrated frontend computation is validated against baseline output
|
||||
- **THEN** parity tests MUST evaluate the metric against its declared tolerance and fail when outside bounds
|
||||
|
||||
### Requirement: Compute Shift MUST Preserve Existing User-Facing Logic
|
||||
Frontend compute migration MUST preserve existing filter semantics, drill-down behavior, and displayed totals.
|
||||
|
||||
#### Scenario: Existing dashboard interactions after compute shift
|
||||
- **WHEN** users apply filters and navigate drill-down flows on migrated pages
|
||||
- **THEN** interaction results MUST remain behaviorally equivalent to the pre-shift baseline
|
||||
@@ -0,0 +1,22 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Vite Page Modules SHALL Reuse Shared Chart and Query Building Blocks
|
||||
Page entry modules MUST consume shared chart/query/drawer utilities for common behaviors.
|
||||
|
||||
#### Scenario: Common chart behavior across pages
|
||||
- **WHEN** multiple report pages render equivalent chart interactions
|
||||
- **THEN** the behavior MUST be provided by shared Vite modules rather than duplicated page-local implementations
|
||||
|
||||
### Requirement: Modularization MUST Preserve Established Navigation and Drill-Down Semantics
|
||||
Refactoring into Vite modules SHALL not alter existing page transitions, independent tabs, and drill-down entry points.
|
||||
|
||||
#### Scenario: User follows existing drill-down path
|
||||
- **WHEN** the user navigates from summary page to detail views
|
||||
- **THEN** the resulting flow and parameter semantics MUST match the established baseline behavior
|
||||
|
||||
### Requirement: Module Boundaries SHALL Support Frontend Compute Expansion
|
||||
Vite module structure MUST keep compute logic decoupled from DOM wiring so additional backend-to-frontend computation shifts can be added safely.
|
||||
|
||||
#### Scenario: Adding a new frontend-computed metric
|
||||
- **WHEN** a new metric is migrated from backend to frontend
|
||||
- **THEN** the metric logic MUST be integrated through shared compute modules without rewriting page routing structure
|
||||
@@ -0,0 +1,22 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Resource and WIP Full-Table Cache SHALL Remain the Authoritative Cached Dataset
|
||||
The system MUST keep `resource` and `wip` full-table cache datasets as the canonical cached source for downstream route queries.
|
||||
|
||||
#### Scenario: Route query reads cached baseline
|
||||
- **WHEN** an endpoint requires resource or wip data
|
||||
- **THEN** it MUST read from the corresponding full-table cache baseline before applying derived filters or aggregations
|
||||
|
||||
### Requirement: Cache Access Paths SHALL Support Index-Based Lookup and Derived Views
|
||||
The caching layer SHALL support index and derived-view access paths to reduce per-request full-table merge and transformation overhead.
|
||||
|
||||
#### Scenario: Lookup by key under concurrent load
|
||||
- **WHEN** requests query by high-cardinality keys such as RESOURCEID
|
||||
- **THEN** the system MUST serve lookups via indexed cache access instead of repeated full-array scans
|
||||
|
||||
### Requirement: Full-Table Cache Refresh MUST Support Incremental Derivation Updates
|
||||
Derived cache indices and aggregates MUST be refreshed consistently when the underlying full-table cache version changes.
|
||||
|
||||
#### Scenario: Cache version update
|
||||
- **WHEN** full-table cache is refreshed to a new version
|
||||
- **THEN** dependent indices and derived views MUST be rebuilt or updated before being exposed for reads
|
||||
@@ -0,0 +1,22 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Migration Gates SHALL Include Runtime Resilience Validation
|
||||
Cutover readiness gates MUST include resilience checks for pool exhaustion handling, circuit-breaker fail-fast behavior, and recovery flow.
|
||||
|
||||
#### Scenario: Resilience gate evaluation
|
||||
- **WHEN** migration gates are executed before release
|
||||
- **THEN** resilience tests MUST pass for degraded-response semantics and recovery path validation
|
||||
|
||||
### Requirement: Migration Gates SHALL Include Frontend Compute Parity Validation
|
||||
Cutover readiness MUST include parity validation for metrics shifted from backend to frontend computation.
|
||||
|
||||
#### Scenario: Compute parity gate
|
||||
- **WHEN** a release includes additional frontend-computed metrics
|
||||
- **THEN** gate execution MUST verify parity fixtures and fail if tolerance contracts are violated
|
||||
|
||||
### Requirement: Rollout Procedure MUST Include Conda-Systemd-Watchdog Rehearsal
|
||||
Rollout and rollback runbooks SHALL include an operational rehearsal for service start, watchdog-triggered reload, and post-restart health checks under the conda/systemd runtime contract.
|
||||
|
||||
#### Scenario: Pre-cutover rehearsal
|
||||
- **WHEN** operators execute pre-cutover rehearsal
|
||||
- **THEN** they MUST successfully complete conda-based start, worker reload, and health verification steps documented in the runbook
|
||||
@@ -0,0 +1,29 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Database Pool Runtime Configuration SHALL Be Enforced
|
||||
The system SHALL apply database pool and timeout parameters from runtime configuration to the active SQLAlchemy engine used by request handling.
|
||||
|
||||
#### Scenario: Runtime pool configuration takes effect
|
||||
- **WHEN** operators set pool and timeout values via environment configuration and start the service
|
||||
- **THEN** the active engine MUST use those values for pool size, overflow, wait timeout, and query call timeout
|
||||
|
||||
### Requirement: Pool Exhaustion MUST Return Retry-Aware Degraded Responses
|
||||
The system MUST return explicit degraded responses for connection pool exhaustion and include machine-readable metadata for retry/backoff behavior.
|
||||
|
||||
#### Scenario: Pool exhausted under load
|
||||
- **WHEN** concurrent requests exceed available database connections and pool wait timeout is reached
|
||||
- **THEN** the API MUST return a dedicated error code and retry guidance instead of a generic 500 failure
|
||||
|
||||
### Requirement: Runtime Degradation MUST Integrate Circuit Breaker State
|
||||
Database-facing API behavior SHALL distinguish circuit-breaker-open degradation from transient query failures.
|
||||
|
||||
#### Scenario: Circuit breaker is open
|
||||
- **WHEN** the circuit breaker transitions to OPEN state
|
||||
- **THEN** database-backed endpoints MUST fail fast with a stable degradation response contract
|
||||
|
||||
### Requirement: Worker Recovery SHALL Support Hot Reload and Watchdog-Assisted Recovery
|
||||
The runtime MUST support graceful worker hot reload and watchdog-triggered recovery without requiring a port change or full system reboot.
|
||||
|
||||
#### Scenario: Worker restart requested
|
||||
- **WHEN** an authorized operator requests worker restart during degraded operation
|
||||
- **THEN** the service MUST trigger graceful reload and preserve single-port availability
|
||||
@@ -0,0 +1,36 @@
|
||||
## 1. P0 Runtime Resilience Baseline
|
||||
|
||||
- [x] 1.1 Make `database.py` read and enforce runtime pool/timeouts from settings/env instead of hardcoded constants.
|
||||
- [x] 1.2 Add explicit degraded error mapping for pool exhaustion and circuit-open states (stable error codes + retry metadata).
|
||||
- [x] 1.3 Update API response handling so degraded errors are returned consistently across WIP/Resource/Dashboard endpoints.
|
||||
- [x] 1.4 Extend frontend `MesApi` retry/backoff policy to respect degraded error codes and avoid aggressive retries under pool exhaustion.
|
||||
|
||||
## 2. P0 Observability and Recovery Controls
|
||||
|
||||
- [x] 2.1 Extend `/health` and `/health/deep` payloads with pool configuration, saturation indicators, and degradation reason classification.
|
||||
- [x] 2.2 Expose runtime-resilience diagnostics in admin status API for operations triage.
|
||||
- [x] 2.3 Ensure hot-reload/restart controls preserve single-port availability and return actionable status for watchdog-driven recovery.
|
||||
|
||||
## 3. P1 Cache and Query Efficiency (Keep Full-Table Cache)
|
||||
|
||||
- [x] 3.1 Preserve `resource/wip` full-table cache as authoritative baseline while introducing indexed lookup helpers for high-frequency access paths.
|
||||
- [x] 3.2 Reduce repeated full-array merge cost in resource status composition by using prebuilt lookup/index structures.
|
||||
- [x] 3.3 Add cache version-coupled rebuild/update flow for derived indices and expose telemetry for index freshness.
|
||||
|
||||
## 4. P1 Frontend Compute Shift Expansion
|
||||
|
||||
- [x] 4.1 Refactor compute-heavy display transformations into reusable frontend core modules.
|
||||
- [x] 4.2 Add parity fixtures/tests for newly shifted computations with explicit tolerance contracts.
|
||||
- [x] 4.3 Ensure migrated pages preserve existing tab/drill-down behavior while consuming shared Vite modules.
|
||||
|
||||
## 5. P2 Conda/Systemd/Watchdog Runtime Alignment
|
||||
|
||||
- [x] 5.1 Align systemd service templates and runtime paths with conda-based execution model.
|
||||
- [x] 5.2 Align startup/deploy scripts, watchdog config, and documentation to a single runtime contract.
|
||||
- [x] 5.3 Define and document alert thresholds for sustained degraded state, restart churn, and retry pressure.
|
||||
|
||||
## 6. Validation and Migration Gates
|
||||
|
||||
- [x] 6.1 Add/extend tests for pool exhaustion semantics, circuit-breaker fail-fast behavior, and degraded response contracts.
|
||||
- [x] 6.2 Add/extend tests for indexed cache access and frontend compute parity.
|
||||
- [x] 6.3 Update migration gate/runbook docs to include resilience checks, conda-systemd rehearsal, and rollback verification.
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-02-07
|
||||
@@ -0,0 +1,65 @@
|
||||
## Context
|
||||
|
||||
`DashBoard_vite` 已完成主體搬遷,但報表頁仍處於混合狀態:
|
||||
- `resource-status`、`resource-history` 等頁面已有 Vite 版本,卻存在實際行為缺陷。
|
||||
- `wip_overview`、`wip_detail` 仍以 inline script 為主,尚未納入 Vite entry 與共用模組治理。
|
||||
- 部分頁面仍有直接字串拼接輸出與原生 `fetch` 路徑,無法完整承接既有 `MesApi` 的降級重試契約。
|
||||
|
||||
此變更是「遷移後硬化」階段:不改變既有業務操作語意,但將效果對齊、模組化覆蓋與前端複用一起完成。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- 讓 WIP 報表頁進入 Vite entry 管理,並保留目前 tab/drill-down 與 `onclick` 操作語意。
|
||||
- 修復已遷移模組中會影響報表可用性的缺陷(初始化、KPI、矩陣選取、API 呼叫路徑)。
|
||||
- 強化共用路徑(escape、欄位契約、MesApi/backoff)以支撐後續前端運算擴展。
|
||||
- 用測試明確覆蓋「asset exists -> module」、「asset missing -> fallback」的模板行為。
|
||||
|
||||
**Non-Goals:**
|
||||
- 不改動後端路由設計與單一 port 服務模型。
|
||||
- 不重寫 UI 視覺風格或更動既有商業邏輯判斷規則。
|
||||
- 不在本次引入新的大型前端框架(維持 Vanilla + Vite entry 模式)。
|
||||
|
||||
## Decisions
|
||||
|
||||
### Decision 1: 採用「模板雙軌載入」完成 WIP 遷移
|
||||
- 選擇:在 `wip_overview.html`、`wip_detail.html` 加入 `frontend_asset()` module 載入,保留既有 inline script 作 fallback。
|
||||
- 理由:可在不破壞現場可用性的前提下,讓 Vite bundle 成為預設執行路徑,符合先前頁面遷移模式。
|
||||
- 替代方案:直接刪除 inline script。
|
||||
- 未採用原因:回退能力不足,且無法快速比對 parity。
|
||||
|
||||
### Decision 2: 模組保持全域 handler 相容層
|
||||
- 選擇:Vite entry 內對舊有 `onclick` 所需函式維持 `window` 綁定,避免模板同步大改。
|
||||
- 理由:降低一次性改動範圍,先確保行為完全對齊,再逐步收斂事件綁定方式。
|
||||
- 替代方案:全面改為 addEventListener 並移除 inline `onclick`。
|
||||
- 未採用原因:本次目標是 parity hardening,不是互動模型重寫。
|
||||
|
||||
### Decision 3: 前端 API 路徑統一走 MesApi
|
||||
- 選擇:JSON API 優先走 `MesApi.get/post`(或 core api bridge),僅 blob/download 等必要場景保留原生 fetch。
|
||||
- 理由:沿用既有降級錯誤碼與 retry/backoff 策略,避免 pool exhausted 時前端重試失控。
|
||||
- 替代方案:維持頁面各自 `fetch`。
|
||||
- 未採用原因:會破壞 resilience contract,一致性不足。
|
||||
|
||||
### Decision 4: 字串輸出與欄位命名同步納入治理
|
||||
- 選擇:針對動態 HTML 內容補 escape,並對照 field contract 驗證表格欄位與下載標頭語意一致。
|
||||
- 理由:遷移期間常見 XSS/欄位漂移問題,必須和模組化同時收斂。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Risk] 大型 inline script 搬入 module 時可能出現作用域差異 → Mitigation: 先保留 fallback,並針對 `window` handler 做顯式綁定。
|
||||
- [Risk] 模組與 fallback 並存造成測試分支增加 → Mitigation: 以 template integration 測試固定兩條路徑行為。
|
||||
- [Risk] escape 補強可能改變少數欄位原始顯示格式 → Mitigation: 僅針對 HTML 注入風險欄位處理,保留 NULL/日期等既有顯示語意。
|
||||
- [Risk] 前端改走 MesApi 使錯誤提示型態改變 → Mitigation: 保持原錯誤訊息文案,僅替換底層請求路徑。
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. 先完成 OpenSpec task 分解與可執行順序。
|
||||
2. 新增 WIP Vite entries,更新 vite config,模板加上 module/fallback 雙軌。
|
||||
3. 修復 `resource-history`、`resource-status` 關鍵缺陷並補安全性修正。
|
||||
4. Build + pytest 驗證,更新 task 勾選。
|
||||
5. 交付變更摘要與剩餘風險,供後續 archive。
|
||||
|
||||
## Open Questions
|
||||
|
||||
- 是否需要在下一階段移除 WIP fallback inline script(目前先保留作為回退機制)。
|
||||
- 是否要擴充前端單元測試(Vitest)覆蓋更多 DOM 互動,而不只依賴後端模板整合測試。
|
||||
@@ -0,0 +1,28 @@
|
||||
## Why
|
||||
|
||||
目前仍有部分報表頁維持大型 inline script,且已遷移的 Vite 模組存在實際行為缺口(例如 KPI 0% 呈現、矩陣篩選選取、模組作用域匯出失敗)。這造成「舊版 Jinja 報表效果」與「新架構模組化」之間存在落差,無法完全發揮 Vite 在複用、可維護性與前端運算轉移的優勢。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 將 WIP Overview / WIP Detail 的報表互動完整納入 Vite entry,保留既有頁面操作語意與 drill-down 路徑。
|
||||
- 修復已遷移頁面的核心行為缺陷(Resource History 模組初始化、Resource Status KPI 與矩陣交互)。
|
||||
- 統一報表前端 API 呼叫路徑,優先透過 `MesApi` 以承接既有 retry/backoff 與降級錯誤契約。
|
||||
- 補強報表頁字串輸出安全與欄位契約一致性,確保畫面欄位、查詢結果與下載欄位名稱一致。
|
||||
- 新增/調整模板整合驗證,確保 Vite 模組載入與 fallback 行為在報表頁完整覆蓋。
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `report-effects-parity`: 定義舊版 Jinja 報表在新 Vite 架構下的效果對齊要求(圖表、篩選、表格、KPI、互動與下載語意)。
|
||||
|
||||
### Modified Capabilities
|
||||
- `full-vite-page-modularization`: 擴展到 WIP 報表頁完整模組化與 fallback 覆蓋。
|
||||
- `frontend-compute-shift`: 擴大前端運算承載並修復前端計算與呈現邏輯缺陷。
|
||||
- `field-contract-governance`: 強化欄位名稱與匯出標頭一致性及頁面渲染安全。
|
||||
- `runtime-resilience-recovery`: 明確要求前端呼叫在降級/壓力情境下遵循退避契約。
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected code: `frontend/src/`, `frontend/vite.config.js`, `src/mes_dashboard/templates/`, `tests/test_template_integration.py`。
|
||||
- Affected runtime behavior: 報表頁 JS 載入模式、矩陣/篩選互動、KPI 顯示與下載欄位對齊。
|
||||
- Affected operations: 單一對外 port 架構不變,仍由 Flask/Gunicorn 提供頁面與 Vite build 輸出資產。
|
||||
@@ -0,0 +1,15 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Dynamic Report Rendering MUST Sanitize Untrusted Values
|
||||
Dynamic table/list rendering in report and query pages SHALL sanitize untrusted text before injecting HTML.
|
||||
|
||||
#### Scenario: HTML-like payload in query result
|
||||
- **WHEN** an API result field contains HTML-like text payload
|
||||
- **THEN** the rendered page MUST display escaped text and MUST NOT execute embedded script content
|
||||
|
||||
### Requirement: UI Table and Download Headers SHALL Follow the Same Field Contract
|
||||
Page table headers and exported file headers SHALL map to the same field contract definition for the same dataset.
|
||||
|
||||
#### Scenario: Header consistency check
|
||||
- **WHEN** users view a report table and then export the corresponding data
|
||||
- **THEN** header labels MUST remain semantically aligned and avoid conflicting naming for identical fields
|
||||
@@ -0,0 +1,15 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Frontend Compute Paths MUST Handle Zero and Boundary Values Correctly
|
||||
Frontend-computed report metrics SHALL preserve valid zero values and boundary conditions in user-visible KPI and summary components.
|
||||
|
||||
#### Scenario: Zero-value KPI rendering
|
||||
- **WHEN** OU% or availability metrics are computed as `0`
|
||||
- **THEN** the page MUST render `0%` (or configured numeric format) instead of placeholder values
|
||||
|
||||
### Requirement: Hierarchical Filter Compute Logic SHALL Be Deterministic Across Levels
|
||||
Frontend matrix/filter computations SHALL produce deterministic selection and filtering outcomes for group, family, and resource levels.
|
||||
|
||||
#### Scenario: Matrix selection at multiple hierarchy levels
|
||||
- **WHEN** users toggle matrix cells across group, family, and resource rows
|
||||
- **THEN** selected-state rendering and filtered equipment result sets MUST remain level-correct and reversible
|
||||
@@ -0,0 +1,19 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: WIP Report Pages SHALL Be Served by Vite Modules
|
||||
The system SHALL provide Vite entry bundles for WIP overview and WIP detail pages, with template-level asset resolution.
|
||||
|
||||
#### Scenario: WIP module asset available
|
||||
- **WHEN** the built asset exists in backend static dist
|
||||
- **THEN** the page MUST load behavior from the corresponding Vite module entry
|
||||
|
||||
#### Scenario: WIP module asset unavailable
|
||||
- **WHEN** the built asset is not present
|
||||
- **THEN** the page MUST retain equivalent behavior through explicit inline fallback logic
|
||||
|
||||
### Requirement: Vite Modules MUST Preserve Legacy Handler Compatibility
|
||||
Vite report modules SHALL expose required global handlers for existing inline entry points until event wiring is fully migrated.
|
||||
|
||||
#### Scenario: Inline-triggered handler compatibility
|
||||
- **WHEN** a template control invokes existing global handler names
|
||||
- **THEN** the migrated module MUST provide compatible callable handlers without runtime scope errors
|
||||
@@ -0,0 +1,19 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Report Effect Parity SHALL Be Preserved During Vite Migration
|
||||
The system SHALL preserve existing Jinja-era report interactions when report pages are served by Vite modules.
|
||||
|
||||
#### Scenario: WIP overview interactions remain equivalent
|
||||
- **WHEN** users operate WIP overview filters, KPI cards, chart refresh, and drill-down entry
|
||||
- **THEN** the resulting state transitions and navigation parameters MUST remain behaviorally equivalent to the baseline page logic
|
||||
|
||||
#### Scenario: WIP detail interactions remain equivalent
|
||||
- **WHEN** users operate WIP detail filters, pagination, lot detail popup, and back-to-overview transitions
|
||||
- **THEN** the resulting data scope and interaction behavior MUST match baseline semantics
|
||||
|
||||
### Requirement: Report Visual Semantics MUST Remain Consistent
|
||||
Report pages SHALL keep established status color semantics, KPI display rules, and table/chart synchronization behavior after migration.
|
||||
|
||||
#### Scenario: KPI and matrix state consistency
|
||||
- **WHEN** metric values are zero or filters target specific matrix levels
|
||||
- **THEN** KPI values and selected-state highlights MUST render correctly without collapsing valid zero values or losing selection state
|
||||
@@ -0,0 +1,8 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Report Frontend API Access SHALL Honor Degraded Retry Contracts
|
||||
Report pages SHALL use retry-aware API access paths for JSON endpoints so degraded backend responses propagate retry metadata to UI behavior.
|
||||
|
||||
#### Scenario: Pool exhaustion or circuit-open response
|
||||
- **WHEN** report API endpoints return degraded error codes with retry hints
|
||||
- **THEN** frontend calls MUST flow through MesApi-compatible behavior and avoid aggressive uncontrolled retry loops
|
||||
@@ -0,0 +1,28 @@
|
||||
## 1. OpenSpec Scope and Parity Baseline
|
||||
|
||||
- [x] 1.1 Confirm report parity target pages and interaction scope (WIP overview/detail, resource status/history, query pages).
|
||||
- [x] 1.2 Capture concrete parity defects in current Vite modules (runtime errors, KPI/matrix mismatch, API path inconsistency).
|
||||
|
||||
## 2. WIP Pages Vite Modularization
|
||||
|
||||
- [x] 2.1 Add Vite entries for `wip-overview` and `wip-detail`.
|
||||
- [x] 2.2 Update templates to load `frontend_asset(...)` module bundles with inline fallback retention.
|
||||
- [x] 2.3 Preserve legacy global handler compatibility for existing inline-triggered actions.
|
||||
|
||||
## 3. Report Behavior and Compute Fixes
|
||||
|
||||
- [x] 3.1 Fix `resource-history` module initialization/export scope error.
|
||||
- [x] 3.2 Fix `resource-status` matrix selection logic and KPI zero-value rendering parity.
|
||||
- [x] 3.3 Align report JSON API calls to MesApi-compatible paths for degraded retry behavior.
|
||||
|
||||
## 4. Field Contract and Rendering Hardening
|
||||
|
||||
- [x] 4.1 Patch dynamic table/query rendering to escape untrusted values.
|
||||
- [x] 4.2 Verify UI table headers and export header naming consistency for touched report flows.
|
||||
- [x] 4.3 Fix missing report style tokens affecting visual consistency.
|
||||
|
||||
## 5. Validation and Regression Guard
|
||||
|
||||
- [x] 5.1 Build frontend bundles and ensure new entries are emitted into backend static dist.
|
||||
- [x] 5.2 Extend/update template integration tests for WIP module/fallback behavior.
|
||||
- [x] 5.3 Run focused pytest suite for template/frontend/report regressions and record outcomes.
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-02-07
|
||||
@@ -0,0 +1,67 @@
|
||||
## Context
|
||||
|
||||
`DashBoard_vite` 已完成單一 port 的 Flask + Vite 架構整併,並具備降級回應、circuit breaker、watchdog 熱重啟與多層快取。
|
||||
目前主要缺口不是功能不存在,而是「運維可操作性」與「前端治理粒度」:
|
||||
|
||||
1. health/admin 雖有狀態,但缺少門檻與建議動作,值班時仍需人工判讀。
|
||||
2. watchdog 僅保留最後一次重啟紀錄,無法直接判斷短時間 churn。
|
||||
3. WIP overview/detail 仍有 autocomplete/filter 搜尋邏輯重複,後續擴展成本高。
|
||||
4. README 需要明確反映最新架構契約與改善策略,避免文件落後於實作。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- 提供可操作的韌性診斷輸出(thresholds、churn、recovery recommendation)。
|
||||
- 保持既有單 port 與手動重啟控制模型,不引入高風險自動重啟風暴。
|
||||
- 抽離 WIP 頁面共用 autocomplete/filter 查詢邏輯到 Vite core,降低重複。
|
||||
- 新增對應測試與文件更新,讓 gate 與 README 可驗證。
|
||||
|
||||
**Non-Goals:**
|
||||
- 不做整站 SPA rewrite。
|
||||
- 不改動既有 drill-down 路徑與使用者操作語意。
|
||||
- 不預設啟用「條件達成即自動重啟 worker」的強制策略。
|
||||
|
||||
## Decisions
|
||||
|
||||
1. 韌性診斷採「可觀測 + 建議」而非預設自動重啟
|
||||
- Decision: 在 `/health`、`/health/deep`、`/admin/api/system-status`、`/admin/api/worker/status` 增加 thresholds/churn/recommendation。
|
||||
- Rationale: 目前已具備 degraded response + backoff + admin restart;先提升判讀與操作性,避免未設防的自動重啟造成抖動。
|
||||
- Alternative considered: 直接在 pool exhausted 時自動重啟 worker;未採用,因 root cause 多為慢查詢/瞬時壅塞,重啟治標不治本且有風暴風險。
|
||||
|
||||
2. watchdog state 擴充最近重啟歷史
|
||||
- Decision: 在 state 檔保留 bounded restart history 並計算 churn summary。
|
||||
- Rationale: 提供運維端可觀測的重啟密度訊號,支援告警與 runbook 決策。
|
||||
- Alternative considered: 僅依日誌分析;未採用,因 API 需要機器可讀狀態。
|
||||
|
||||
3. WIP autocomplete/filter 抽共用核心模組
|
||||
- Decision: 新增 `frontend/src/core/autocomplete.js`,由 `wip-overview` / `wip-detail` 共用。
|
||||
- Rationale: 保留既有 API 與頁面互動語意,同時降低重複與 bug 修補成本。
|
||||
- Alternative considered: 全量頁面元件化框架重寫;未採用,因超出本次風險與範圍。
|
||||
|
||||
4. README 架構契約同步
|
||||
- Decision: 更新 README(並提供 `README.mdj` 鏡像)記錄新的韌性診斷與前端共用模組策略。
|
||||
- Rationale: 交付後文件應可直接支援運維與交接。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Risk] 韌性輸出欄位增加可能影響依賴固定 schema 的外部腳本
|
||||
- Mitigation: 採向後相容擴充,不移除既有欄位。
|
||||
|
||||
- [Risk] 共用 autocomplete 模組抽離後可能引入搜尋參數差異
|
||||
- Mitigation: 保持原有欄位映射與 cross-filter 規則,並補單元測試覆蓋。
|
||||
|
||||
- [Risk] restart history 持久化不當可能造成 state 膨脹
|
||||
- Mitigation: 使用 bounded history(固定上限)與窗口彙總。
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. 實作 resilience diagnostics(thresholds/churn/recommendation)與 watchdog state 擴充。
|
||||
2. 更新 health/admin API 輸出並補測試。
|
||||
3. 抽離前端 autocomplete 共用模組,更新 WIP 頁面引用並執行 Vite build。
|
||||
4. 更新 README/README.mdj 與 runbook 對應段落。
|
||||
5. 執行 focused pytest + frontend build 驗證,確認單 port 契約不變。
|
||||
|
||||
## Open Questions
|
||||
|
||||
- 是否在下一階段將 recommendation 與告警 webhook(Slack/Teams)直接整合?
|
||||
- 是否要把 restart churn 門檻與 UI 告警顏色標準化到 admin/performance 頁?
|
||||
@@ -0,0 +1,40 @@
|
||||
## Why
|
||||
|
||||
Vite migration已完成主要功能遷移,但目前仍有兩個可見風險:一是運維端缺少「可操作」的韌性判斷(僅有狀態,缺少建議動作與重啟 churn 訊號);二是前端主要報表頁仍存在可抽離的重複互動邏輯,會增加後續維護成本。現在補齊這兩塊,可在不改變既有使用流程下提高穩定性與可演進性。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 擴充 runtime resilience 診斷契約:在 health/admin payload 提供門檻設定、重啟 churn 與可行動建議。
|
||||
- 強化 watchdog state:保留最近重啟歷史,支持 churn 計算與觀測。
|
||||
- 將 WIP overview/detail 重複的 autocomplete/filter 查詢邏輯抽成共用 Vite core 模組。
|
||||
- 增加前端核心模組與韌性診斷的測試覆蓋。
|
||||
- 更新專案說明文件(README)反映最新架構、治理策略與操作準則。
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- None.
|
||||
|
||||
### Modified Capabilities
|
||||
- `runtime-resilience-recovery`: 新增重啟 churn 與復原建議契約,讓降級狀態具備可操作的 runbook 訊號。
|
||||
- `full-vite-page-modularization`: 新增 WIP 報表共用 autocomplete/filter building blocks 要求,降低頁面重複實作。
|
||||
- `migration-gates-and-rollout`: 新增文件與前端治理 gate,確保架構說明與實際部署契約一致。
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected code:
|
||||
- `src/mes_dashboard/routes/health_routes.py`
|
||||
- `src/mes_dashboard/routes/admin_routes.py`
|
||||
- `scripts/worker_watchdog.py`
|
||||
- `frontend/src/core/`
|
||||
- `frontend/src/wip-overview/main.js`
|
||||
- `frontend/src/wip-detail/main.js`
|
||||
- `tests/`
|
||||
- `README.md`(以及使用者要求的 README.mdj)
|
||||
- APIs:
|
||||
- `/health`
|
||||
- `/health/deep`
|
||||
- `/admin/api/system-status`
|
||||
- `/admin/api/worker/status`
|
||||
- Operational behavior:
|
||||
- 保持單一 port 與既有手動重啟流程;新增觀測與建議,不預設啟用自動重啟風暴風險。
|
||||
@@ -0,0 +1,12 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: WIP Modules SHALL Reuse Shared Autocomplete and Filter Query Utilities
|
||||
WIP overview and WIP detail Vite entry modules SHALL use shared frontend core utilities for autocomplete request construction and cross-filter behavior.
|
||||
|
||||
#### Scenario: Cross-filter autocomplete parity across WIP pages
|
||||
- **WHEN** users type in workorder/lot/package/type filters on either WIP overview or WIP detail pages
|
||||
- **THEN** both pages MUST generate equivalent autocomplete request parameters and return behaviorally consistent dropdown results
|
||||
|
||||
#### Scenario: Shared utility change propagates across both pages
|
||||
- **WHEN** autocomplete mapping rules are updated in the shared core module
|
||||
- **THEN** both WIP overview and WIP detail modules MUST consume the updated behavior without duplicated page-local logic edits
|
||||
@@ -0,0 +1,12 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Migration Gates SHALL Enforce Architecture Documentation Consistency
|
||||
Cutover governance MUST include verification that runtime architecture contracts documented for operators match implemented deployment and resilience behavior.
|
||||
|
||||
#### Scenario: Documentation gate before release
|
||||
- **WHEN** release gates are executed for a migration or hardening change
|
||||
- **THEN** project README artifacts MUST be updated to reflect current single-port runtime contract, resilience diagnostics, and frontend modularization strategy
|
||||
|
||||
#### Scenario: Gate fails on stale architecture contract
|
||||
- **WHEN** implementation introduces resilience or module-governance changes but README architecture section remains outdated
|
||||
- **THEN** release governance MUST treat the gate as failed until documentation is aligned
|
||||
@@ -0,0 +1,12 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Runtime Resilience Diagnostics MUST Expose Actionable Signals
|
||||
The system MUST expose machine-readable resilience thresholds, restart-churn indicators, and operator action recommendations so degraded states can be triaged consistently.
|
||||
|
||||
#### Scenario: Health payload includes resilience diagnostics
|
||||
- **WHEN** clients call `/health` or `/health/deep`
|
||||
- **THEN** responses MUST include resilience thresholds and a recommendation field describing whether to observe, throttle, or trigger controlled worker recovery
|
||||
|
||||
#### Scenario: Admin status includes restart churn summary
|
||||
- **WHEN** operators call `/admin/api/system-status` or `/admin/api/worker/status`
|
||||
- **THEN** responses MUST include bounded restart history summary within a configured time window and indicate whether churn threshold is exceeded
|
||||
@@ -0,0 +1,23 @@
|
||||
## 1. Runtime Resilience Diagnostics Hardening
|
||||
|
||||
- [x] 1.1 Add shared resilience threshold/recommendation helpers for health/admin payloads.
|
||||
- [x] 1.2 Extend watchdog restart state to include bounded restart history and churn summary.
|
||||
- [x] 1.3 Expose thresholds/churn/recommendation fields in `/health`, `/health/deep`, `/admin/api/system-status`, and `/admin/api/worker/status`.
|
||||
|
||||
## 2. Frontend WIP Module Reuse
|
||||
|
||||
- [x] 2.1 Add shared Vite core autocomplete/filter utility module.
|
||||
- [x] 2.2 Refactor WIP overview/detail modules to consume shared autocomplete utilities while preserving behavior.
|
||||
- [x] 2.3 Verify Vite build output remains valid for single-port backend delivery.
|
||||
|
||||
## 3. Validation Coverage
|
||||
|
||||
- [x] 3.1 Add backend tests for resilience diagnostics and restart churn telemetry contracts.
|
||||
- [x] 3.2 Add frontend tests for shared autocomplete request parameter behavior.
|
||||
- [x] 3.3 Run focused backend/frontend validation commands and record pass results.
|
||||
|
||||
## 4. Documentation Alignment
|
||||
|
||||
- [x] 4.1 Update `README.md` architecture/operations sections to reflect latest resilience and frontend-governance model.
|
||||
- [x] 4.2 Add/update `README.mdj` to mirror latest architecture contract for your requested documentation path.
|
||||
- [x] 4.3 Update migration runbook notes to include documentation-alignment gate.
|
||||
20
openspec/config.yaml
Normal file
20
openspec/config.yaml
Normal file
@@ -0,0 +1,20 @@
|
||||
schema: spec-driven
|
||||
|
||||
# Project context (optional)
|
||||
# This is shown to AI when creating artifacts.
|
||||
# Add your tech stack, conventions, style guides, domain knowledge, etc.
|
||||
# Example:
|
||||
# context: |
|
||||
# Tech stack: TypeScript, React, Node.js
|
||||
# We use conventional commits
|
||||
# Domain: e-commerce platform
|
||||
|
||||
# Per-artifact rules (optional)
|
||||
# Add custom rules for specific artifacts.
|
||||
# Example:
|
||||
# rules:
|
||||
# proposal:
|
||||
# - Keep proposals under 500 words
|
||||
# - Always include a "Non-goals" section
|
||||
# tasks:
|
||||
# - Break tasks into chunks of max 2 hours
|
||||
38
openspec/specs/cache-observability-hardening/spec.md
Normal file
38
openspec/specs/cache-observability-hardening/spec.md
Normal file
@@ -0,0 +1,38 @@
|
||||
## Purpose
|
||||
Define stable requirements for cache-observability-hardening.
|
||||
## Requirements
|
||||
### Requirement: Layered Cache SHALL Expose Operational State
|
||||
The route cache implementation SHALL expose layered cache operational state, including mode, freshness, and degradation status.
|
||||
|
||||
#### Scenario: Redis unavailable degradation state
|
||||
- **WHEN** Redis is unavailable
|
||||
- **THEN** health endpoints MUST indicate degraded cache mode while keeping L1 memory cache active
|
||||
|
||||
### Requirement: Cache Telemetry MUST be Queryable for Operations
|
||||
The system MUST provide cache telemetry suitable for operations diagnostics.
|
||||
|
||||
#### Scenario: Telemetry inspection
|
||||
- **WHEN** operators request deep health status
|
||||
- **THEN** cache-related metrics/state SHALL be present and interpretable for troubleshooting
|
||||
|
||||
### Requirement: Health Endpoints SHALL Expose Pool Saturation and Degradation Reason Codes
|
||||
Operational health endpoints MUST report connection pool saturation indicators and explicit degradation reason codes.
|
||||
|
||||
#### Scenario: Pool saturation observed
|
||||
- **WHEN** checked-out connections and overflow approach configured limits
|
||||
- **THEN** deep health output MUST expose saturation metrics and degraded reason classification
|
||||
|
||||
### Requirement: Degraded Responses MUST Be Correlatable Across API and Health Telemetry
|
||||
Error responses for degraded states SHALL include stable codes that can be mapped to health telemetry and operational dashboards.
|
||||
|
||||
#### Scenario: Degraded API response correlation
|
||||
- **WHEN** an API request fails due to circuit-open or pool-exhausted conditions
|
||||
- **THEN** operators MUST be able to match the response code to current health telemetry state
|
||||
|
||||
### Requirement: Operational Alert Thresholds SHALL Be Explicitly Defined
|
||||
The system MUST define alert thresholds for sustained degraded state, repeated worker recovery, and abnormal retry pressure.
|
||||
|
||||
#### Scenario: Sustained degradation threshold exceeded
|
||||
- **WHEN** degraded status persists beyond configured duration
|
||||
- **THEN** the monitoring contract MUST classify the service as alert-worthy with actionable context
|
||||
|
||||
26
openspec/specs/conda-systemd-runtime-alignment/spec.md
Normal file
26
openspec/specs/conda-systemd-runtime-alignment/spec.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# conda-systemd-runtime-alignment Specification
|
||||
|
||||
## Purpose
|
||||
TBD - created by archiving change stability-and-frontend-compute-shift. Update Purpose after archive.
|
||||
## Requirements
|
||||
### Requirement: Production Service Runtime SHALL Use Conda-Aligned Execution Paths
|
||||
Service units and operational scripts MUST run with a consistent conda-managed Python runtime.
|
||||
|
||||
#### Scenario: Service unit starts application
|
||||
- **WHEN** systemd starts the dashboard service and watchdog
|
||||
- **THEN** both processes MUST execute using the configured conda environment binaries and paths
|
||||
|
||||
### Requirement: Watchdog and Runtime Paths MUST Be Operationally Consistent
|
||||
PID files, restart flag paths, state files, and worker control interfaces SHALL be consistent across scripts, environment variables, and systemd units.
|
||||
|
||||
#### Scenario: Watchdog handles restart flag
|
||||
- **WHEN** a restart flag is written by admin control endpoints
|
||||
- **THEN** watchdog MUST read the same configured path set and signal the correct Gunicorn master process
|
||||
|
||||
### Requirement: Deployment Documentation MUST Match Runtime Contract
|
||||
Runbooks and deployment documentation MUST describe the same conda/systemd/watchdog contract used by the deployed system.
|
||||
|
||||
#### Scenario: Operator follows deployment runbook
|
||||
- **WHEN** an operator performs deploy, health check, and rollback from documentation
|
||||
- **THEN** documented commands and paths MUST work without requiring venv-specific assumptions
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user