feat(qc-gate): add QC-GATE real-time LOT status report as first pure Vue 3 + Vite page

Introduce QC-GATE station monitoring with stacked bar chart and filterable LOT table,
using Vue 3 SFC + ECharts via npm. Establishes the pure Vite page architecture pattern
(no Jinja2) for future page migration. Also removes stale design files and README.mdj.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
egg
2026-02-09 13:06:59 +08:00
parent 9b1d2edc52
commit bf7285fb51
29 changed files with 2366 additions and 3020 deletions

View File

@@ -1,153 +0,0 @@
# MES DashboardREADME.mdj
本文件為 `README.md` 的精簡技術同步版,聚焦目前可運行架構與運維契約。
## 1. 架構摘要2026-02-08
- 後端Flask + Gunicorn單一 port
- 前端Vite build 輸出到 `src/mes_dashboard/static/dist`
- 快取Redis + process-level cache + indexed selection telemetry
- 資料OracleQueuePool
- 運維watchdog + admin worker restart API + guarded-mode policy
- 環境設定:開發與正式環境統一使用專案根目錄同一份 `.env`
- 啟動腳本:`./scripts/start_server.sh start` 會同時啟動 Gunicorn 與 `worker_watchdog.py`
## 2. 既有設計原則(保留)
- `resource`(設備基礎資料)與 `wip`(線上即時狀況)維持全表快取策略。
- 前端頁面邏輯與 drill-down 操作語意維持不變。
- 系統維持單一 port 服務模式(前後端同源)。
## 3. P0 Runtime Hardening已完成
- Production 強制 `SECRET_KEY`:未設定或使用不安全預設值時,啟動直接失敗。
- CSRF 防護:
- `/admin/login` 表單需 token
- `/admin/api/*` 的 `POST/PUT/PATCH/DELETE` 需 `X-CSRF-Token`
- Session hardening登入成功後 `session.clear()` + CSRF token rotation。
- Health probe isolation`/health` DB 連通檢查使用獨立 health pool。
- Shutdown cleanup統一停止 cache updater、equipment sync worker並關閉 Redis 與 DB engine。
- XSS hardening`hold_detail` fallback script 的 `reason` 改用 `tojson`。
## 4. P1 Cache/Query Efficiency已完成
- `resource` / `wip` 仍維持全表快取策略(業務約束不變)。
- WIP 查詢改走 indexed selection並加入增量同步watermark/version與 drift fallback。
- `/health`、`/health/deep`、`/admin/api/system-status` 提供 cache memory amplification/index telemetry。
- 新增 benchmark harness`scripts/run_cache_benchmarks.py --enforce`。
## 5. P2 Ops Self-Healing已完成
- runtime contract 共用化app/start_server/watchdog/systemd 使用同一組 watchdog/conda 路徑契約。
- 啟動 fail-fastconda/runtime path drift 時拒絕啟動並輸出可操作診斷。
- worker restart policycooldown + retry budget + churn guarded mode。
- manual override需 admin 身分 + `manual_override` + `override_acknowledged` + `override_reason`,且寫入 audit log。
- health/admin payload 提供 policy state`allowed` / `cooldown` / `blocked`。
## 6. Round-3 Residual Hardening已完成
- WIP cache publish 改為 staged publish更新失敗不覆寫舊快照。
- WIP process cache slow-path parse 移到鎖外,降低 lock contention。
- realtime equipment process cache 補齊 bounded LRU含 `EQUIPMENT_PROCESS_CACHE_MAX_SIZE`)。
- `_clean_nan_values` 改為 depth-safe 迭代式清理(避免深層遞迴風險)。
- WIP/Hold/Resource bool query parser 共用化(`core/utils.py`)。
- filter cache source view 可由 env 覆寫(便於環境切換與測試)。
- `/health`、`/health/deep` 增加 5 秒 memotesting 模式自動關閉)。
- 高成本 API 增加輕量 in-process rate limit超限回傳一致 429 結構。
- DB 連線字串記錄加上敏感欄位遮罩(密碼 redaction
## 7. Round-4 Residual Consolidation已完成
- Resource derived index 改為 row-position representation不再在 process 內保存 full records 複本。
- Resource / Realtime Equipment 共用 Oracle SQL fragments避免查詢定義重複漂移。
- `resource_cache` / `realtime_equipment_cache` 型別註記風格與高頻常數命名收斂。
- `page_registry` 寫檔改為 atomic replace降低設定檔半寫入風險。
- 新增測試覆蓋 shared SQL fragment 與 bool parser 不重複定義治理。
## 8. 重要環境變數
```bash
FLASK_ENV=production
SECRET_KEY=<required-in-production>
CSRF_ENABLED=true
LDAP_API_URL=https://ldap-api.example.com
LDAP_ALLOWED_HOSTS=ldap-api.example.com,ldap-api-dr.example.com
DB_POOL_SIZE=10
DB_MAX_OVERFLOW=20
DB_POOL_TIMEOUT=30
DB_POOL_RECYCLE=1800
DB_CALL_TIMEOUT_MS=55000
DB_POOL_EXHAUSTED_RETRY_AFTER_SECONDS=5
DB_HEALTH_POOL_SIZE=1
DB_HEALTH_MAX_OVERFLOW=0
DB_HEALTH_POOL_TIMEOUT=2
CONDA_BIN=/opt/miniconda3/bin/conda
CONDA_ENV_NAME=mes-dashboard
RUNTIME_CONTRACT_VERSION=2026.02-p2
RUNTIME_CONTRACT_ENFORCE=true
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
WORKER_RESTART_COOLDOWN=60
WORKER_RESTART_RETRY_BUDGET=3
WORKER_RESTART_WINDOW_SECONDS=600
WORKER_RESTART_CHURN_THRESHOLD=3
WORKER_GUARDED_MODE_ENABLED=true
PROCESS_CACHE_MAX_SIZE=32
WIP_PROCESS_CACHE_MAX_SIZE=32
RESOURCE_PROCESS_CACHE_MAX_SIZE=32
EQUIPMENT_PROCESS_CACHE_MAX_SIZE=32
FILTER_CACHE_WIP_VIEW=DWH.DW_MES_LOT_V
FILTER_CACHE_SPEC_WORKCENTER_VIEW=DWH.DW_MES_SPEC_WORKCENTER_V
HEALTH_MEMO_TTL_SECONDS=5
WIP_MATRIX_RATE_LIMIT_MAX_REQUESTS=120
WIP_MATRIX_RATE_LIMIT_WINDOW_SECONDS=60
WIP_DETAIL_RATE_LIMIT_MAX_REQUESTS=90
WIP_DETAIL_RATE_LIMIT_WINDOW_SECONDS=60
HOLD_LOTS_RATE_LIMIT_MAX_REQUESTS=90
HOLD_LOTS_RATE_LIMIT_WINDOW_SECONDS=60
RESOURCE_DETAIL_RATE_LIMIT_MAX_REQUESTS=60
RESOURCE_DETAIL_RATE_LIMIT_WINDOW_SECONDS=60
RESOURCE_STATUS_RATE_LIMIT_MAX_REQUESTS=90
RESOURCE_STATUS_RATE_LIMIT_WINDOW_SECONDS=60
```
## 9. 驗證命令(建議)
```bash
# 後端conda
conda run -n mes-dashboard python -m pytest -q tests/test_runtime_hardening.py
# 前端
npm --prefix frontend test
npm --prefix frontend run build
# P1 benchmark gate
conda run -n mes-dashboard python scripts/run_cache_benchmarks.py --enforce
# P2 runtime contract check
RUNTIME_CONTRACT_ENFORCE=true ./scripts/start_server.sh check
```
## 10. 開發歷史Vite 專案)
- 2026-02-07完成 Vite 根目錄重構與舊版切除。
- 2026-02-08完成 resilience 診斷治理與前端共用模組化。
- 2026-02-08完成 P0 安全/穩定性硬化(本次更新)。
- 2026-02-08完成 P1 快取查詢效率重構index + benchmark gate
- 2026-02-08完成 P2 運維自癒治理guarded mode + manual override + runtime contract
- 2026-02-08完成 round-2 hardeningLDAP URL 驗證、bounded LRU cache、circuit breaker 鎖外日誌、安全標頭、分頁邊界)。
- 2026-02-08完成 round-3 residual hardeningstaged publish、health memo、API rate limit、DB redaction、filter view env 化)。
- 2026-02-08完成 round-4 residual consolidationresource index 表示正規化、shared SQL fragments、型別與常數治理、atomic page status 寫入)。

View File

@@ -29,6 +29,13 @@
"drawer_id": "reports", "drawer_id": "reports",
"order": 3 "order": 3
}, },
{
"route": "/qc-gate",
"name": "QC-GATE 狀態",
"status": "released",
"drawer_id": "reports",
"order": 4
},
{ {
"route": "/tables", "route": "/tables",
"name": "表格總覽", "name": "表格總覽",
@@ -113,4 +120,4 @@
"admin_only": true "admin_only": true
} }
] ]
} }

View File

@@ -7,10 +7,62 @@
"": { "": {
"name": "mes-dashboard-frontend", "name": "mes-dashboard-frontend",
"version": "0.1.0", "version": "0.1.0",
"dependencies": {
"@vitejs/plugin-vue": "^6.0.4",
"echarts": "^6.0.0",
"vue": "^3.5.27",
"vue-echarts": "^8.0.1"
},
"devDependencies": { "devDependencies": {
"vite": "^6.3.0" "vite": "^6.3.0"
} }
}, },
"node_modules/@babel/helper-string-parser": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz",
"integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==",
"license": "MIT",
"dependencies": {
"@babel/types": "^7.29.0"
},
"bin": {
"parser": "bin/babel-parser.js"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@babel/types": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
"@babel/helper-validator-identifier": "^7.28.5"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12", "version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
@@ -18,7 +70,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -35,7 +86,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -52,7 +102,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -69,7 +118,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -86,7 +134,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -103,7 +150,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -120,7 +166,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -137,7 +182,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -154,7 +198,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -171,7 +214,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -188,7 +230,6 @@
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -205,7 +246,6 @@
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -222,7 +262,6 @@
"cpu": [ "cpu": [
"mips64el" "mips64el"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -239,7 +278,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -256,7 +294,6 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -273,7 +310,6 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -290,7 +326,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -307,7 +342,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -324,7 +358,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -341,7 +374,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -358,7 +390,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -375,7 +406,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -392,7 +422,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -409,7 +438,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -426,7 +454,6 @@
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -443,7 +470,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -453,6 +479,18 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"license": "MIT"
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.2",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz",
"integrity": "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==",
"license": "MIT"
},
"node_modules/@rollup/rollup-android-arm-eabi": { "node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.57.1", "version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz",
@@ -460,7 +498,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -474,7 +511,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -488,7 +524,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -502,7 +537,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -516,7 +550,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -530,7 +563,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -544,7 +576,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -558,7 +589,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -572,7 +602,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -586,7 +615,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -600,7 +628,6 @@
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -614,7 +641,6 @@
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -628,7 +654,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -642,7 +667,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -656,7 +680,6 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -670,7 +693,6 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -684,7 +706,6 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -698,7 +719,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -712,7 +732,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -726,7 +745,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -740,7 +758,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -754,7 +771,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -768,7 +784,6 @@
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -782,7 +797,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -796,7 +810,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -807,14 +820,156 @@
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@vitejs/plugin-vue": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.4.tgz",
"integrity": "sha512-uM5iXipgYIn13UUQCZNdWkYk+sysBeA97d5mHsAoAt1u/wpN3+zxOmsVJWosuzX+IMGRzeYUNytztrYznboIkQ==",
"license": "MIT",
"dependencies": {
"@rolldown/pluginutils": "1.0.0-rc.2"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"peerDependencies": {
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0",
"vue": "^3.2.25"
}
},
"node_modules/@vue/compiler-core": {
"version": "3.5.27",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.27.tgz",
"integrity": "sha512-gnSBQjZA+//qDZen+6a2EdHqJ68Z7uybrMf3SPjEGgG4dicklwDVmMC1AeIHxtLVPT7sn6sH1KOO+tS6gwOUeQ==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.28.5",
"@vue/shared": "3.5.27",
"entities": "^7.0.0",
"estree-walker": "^2.0.2",
"source-map-js": "^1.2.1"
}
},
"node_modules/@vue/compiler-dom": {
"version": "3.5.27",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.27.tgz",
"integrity": "sha512-oAFea8dZgCtVVVTEC7fv3T5CbZW9BxpFzGGxC79xakTr6ooeEqmRuvQydIiDAkglZEAd09LgVf1RoDnL54fu5w==",
"license": "MIT",
"dependencies": {
"@vue/compiler-core": "3.5.27",
"@vue/shared": "3.5.27"
}
},
"node_modules/@vue/compiler-sfc": {
"version": "3.5.27",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.27.tgz",
"integrity": "sha512-sHZu9QyDPeDmN/MRoshhggVOWE5WlGFStKFwu8G52swATgSny27hJRWteKDSUUzUH+wp+bmeNbhJnEAel/auUQ==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.28.5",
"@vue/compiler-core": "3.5.27",
"@vue/compiler-dom": "3.5.27",
"@vue/compiler-ssr": "3.5.27",
"@vue/shared": "3.5.27",
"estree-walker": "^2.0.2",
"magic-string": "^0.30.21",
"postcss": "^8.5.6",
"source-map-js": "^1.2.1"
}
},
"node_modules/@vue/compiler-ssr": {
"version": "3.5.27",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.27.tgz",
"integrity": "sha512-Sj7h+JHt512fV1cTxKlYhg7qxBvack+BGncSpH+8vnN+KN95iPIcqB5rsbblX40XorP+ilO7VIKlkuu3Xq2vjw==",
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.5.27",
"@vue/shared": "3.5.27"
}
},
"node_modules/@vue/reactivity": {
"version": "3.5.27",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.27.tgz",
"integrity": "sha512-vvorxn2KXfJ0nBEnj4GYshSgsyMNFnIQah/wczXlsNXt+ijhugmW+PpJ2cNPe4V6jpnBcs0MhCODKllWG+nvoQ==",
"license": "MIT",
"dependencies": {
"@vue/shared": "3.5.27"
}
},
"node_modules/@vue/runtime-core": {
"version": "3.5.27",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.27.tgz",
"integrity": "sha512-fxVuX/fzgzeMPn/CLQecWeDIFNt3gQVhxM0rW02Tvp/YmZfXQgcTXlakq7IMutuZ/+Ogbn+K0oct9J3JZfyk3A==",
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.5.27",
"@vue/shared": "3.5.27"
}
},
"node_modules/@vue/runtime-dom": {
"version": "3.5.27",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.27.tgz",
"integrity": "sha512-/QnLslQgYqSJ5aUmb5F0z0caZPGHRB8LEAQ1s81vHFM5CBfnun63rxhvE/scVb/j3TbBuoZwkJyiLCkBluMpeg==",
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.5.27",
"@vue/runtime-core": "3.5.27",
"@vue/shared": "3.5.27",
"csstype": "^3.2.3"
}
},
"node_modules/@vue/server-renderer": {
"version": "3.5.27",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.27.tgz",
"integrity": "sha512-qOz/5thjeP1vAFc4+BY3Nr6wxyLhpeQgAE/8dDtKo6a6xdk+L4W46HDZgNmLOBUDEkFXV3G7pRiUqxjX0/2zWA==",
"license": "MIT",
"dependencies": {
"@vue/compiler-ssr": "3.5.27",
"@vue/shared": "3.5.27"
},
"peerDependencies": {
"vue": "3.5.27"
}
},
"node_modules/@vue/shared": {
"version": "3.5.27",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.27.tgz",
"integrity": "sha512-dXr/3CgqXsJkZ0n9F3I4elY8wM9jMJpP3pvRG52r6m0tu/MsAFIe6JpXVGeNMd/D9F4hQynWT8Rfuj0bdm9kFQ==",
"license": "MIT"
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
"node_modules/echarts": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.0.0.tgz",
"integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "2.3.0",
"zrender": "6.0.0"
}
},
"node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.25.12", "version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
"dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"bin": { "bin": {
@@ -852,11 +1007,16 @@
"@esbuild/win32-x64": "0.25.12" "@esbuild/win32-x64": "0.25.12"
} }
}, },
"node_modules/estree-walker": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"license": "MIT"
},
"node_modules/fdir": { "node_modules/fdir": {
"version": "6.5.0", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=12.0.0" "node": ">=12.0.0"
@@ -874,7 +1034,6 @@
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
@@ -885,11 +1044,19 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0" "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
} }
}, },
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.11", "version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -908,16 +1075,13 @@
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/picomatch": { "node_modules/picomatch": {
"version": "4.0.3", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -929,7 +1093,6 @@
"version": "8.5.6", "version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@@ -958,7 +1121,6 @@
"version": "4.57.1", "version": "4.57.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz",
"integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/estree": "1.0.8" "@types/estree": "1.0.8"
@@ -1003,7 +1165,6 @@
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@@ -1013,7 +1174,6 @@
"version": "0.2.15", "version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"fdir": "^6.5.0", "fdir": "^6.5.0",
@@ -1026,11 +1186,16 @@
"url": "https://github.com/sponsors/SuperchupuDev" "url": "https://github.com/sponsors/SuperchupuDev"
} }
}, },
"node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
},
"node_modules/vite": { "node_modules/vite": {
"version": "6.4.1", "version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
@@ -1100,6 +1265,46 @@
"optional": true "optional": true
} }
} }
},
"node_modules/vue": {
"version": "3.5.27",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.27.tgz",
"integrity": "sha512-aJ/UtoEyFySPBGarREmN4z6qNKpbEguYHMmXSiOGk69czc+zhs0NF6tEFrY8TZKAl8N/LYAkd4JHVd5E/AsSmw==",
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.5.27",
"@vue/compiler-sfc": "3.5.27",
"@vue/runtime-dom": "3.5.27",
"@vue/server-renderer": "3.5.27",
"@vue/shared": "3.5.27"
},
"peerDependencies": {
"typescript": "*"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/vue-echarts": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/vue-echarts/-/vue-echarts-8.0.1.tgz",
"integrity": "sha512-23rJTFLu1OUEGRWjJGmdGt8fP+8+ja1gVgzMYPIPaHWpXegcO1viIAaeu2H4QHESlVeHzUAHIxKXGrwjsyXAaA==",
"license": "MIT",
"peerDependencies": {
"echarts": "^6.0.0",
"vue": "^3.3.0"
}
},
"node_modules/zrender": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.0.0.tgz",
"integrity": "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==",
"license": "BSD-3-Clause",
"dependencies": {
"tslib": "2.3.0"
}
} }
} }
} }

View File

@@ -5,10 +5,16 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host", "dev": "vite --host",
"build": "vite build", "build": "vite build && cp ../src/mes_dashboard/static/dist/src/qc-gate/index.html ../src/mes_dashboard/static/dist/qc-gate.html",
"test": "node --test tests/*.test.js" "test": "node --test tests/*.test.js"
}, },
"devDependencies": { "devDependencies": {
"vite": "^6.3.0" "vite": "^6.3.0"
},
"dependencies": {
"@vitejs/plugin-vue": "^6.0.4",
"echarts": "^6.0.0",
"vue": "^3.5.27",
"vue-echarts": "^8.0.1"
} }
} }

View File

@@ -0,0 +1,170 @@
<script setup>
import { computed, ref } from 'vue';
import QcGateChart from './components/QcGateChart.vue';
import LotTable from './components/LotTable.vue';
import { useQcGateData } from './composables/useQcGateData.js';
const {
stations,
cacheTime,
loading,
refreshing,
errorMessage,
allLots,
refreshNow,
} = useQcGateData();
const activeFilter = ref(null);
const BUCKET_LABELS = {
lt_6h: '<6hr',
'6h_12h': '6-12hr',
'12h_24h': '12-24hr',
gt_24h: '>24hr',
};
const hasStations = computed(() => stations.value.length > 0);
const totalLots = computed(() => {
return stations.value.reduce((sum, station) => sum + Number(station.total || 0), 0);
});
const filteredLots = computed(() => {
if (!activeFilter.value) {
return allLots.value;
}
return allLots.value.filter((lot) => {
return (
lot.step === activeFilter.value.station &&
lot.bucket === activeFilter.value.bucket
);
});
});
const formattedCacheTime = computed(() => {
if (!cacheTime.value) {
return '--';
}
const date = new Date(cacheTime.value);
if (Number.isNaN(date.getTime())) {
return String(cacheTime.value);
}
return date.toLocaleString('zh-TW', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
});
const activeFilterLabel = computed(() => {
if (!activeFilter.value) {
return '';
}
const bucketLabel = BUCKET_LABELS[activeFilter.value.bucket] || activeFilter.value.bucket;
return `${activeFilter.value.station} / ${bucketLabel}`;
});
function handleChartSelect(filter) {
if (!filter?.station || !filter?.bucket) {
return;
}
if (
activeFilter.value &&
activeFilter.value.station === filter.station &&
activeFilter.value.bucket === filter.bucket
) {
activeFilter.value = null;
return;
}
activeFilter.value = filter;
}
function clearFilter() {
activeFilter.value = null;
}
function handleManualRefresh() {
void refreshNow();
}
</script>
<template>
<div class="qc-gate-page">
<header class="qc-gate-header">
<div>
<h1>QC-GATE 狀態</h1>
<p class="header-subtitle">即時監控各 QC-GATE 站點的在製 LOT 與等待時間</p>
</div>
<div class="header-meta">
<div class="meta-row">
<span class="meta-label">快照時間</span>
<span class="meta-value">{{ formattedCacheTime }}</span>
</div>
<div class="meta-row">
<span class="meta-label"> LOT</span>
<span class="meta-value">{{ totalLots.toLocaleString('zh-TW') }}</span>
</div>
<button
type="button"
class="refresh-button"
:disabled="loading || refreshing"
@click="handleManualRefresh"
>
{{ refreshing ? '更新中...' : '重新整理' }}
</button>
</div>
</header>
<div v-if="errorMessage" class="error-banner">{{ errorMessage }}</div>
<main class="qc-gate-content">
<section class="panel chart-panel">
<div class="panel-header">
<h2>站點等待時間分布</h2>
<span class="panel-hint">點擊圖表區段可篩選下方 LOT 清單</span>
</div>
<div v-if="loading" class="loading-state">資料載入中...</div>
<template v-else>
<QcGateChart
:stations="stations"
:active-filter="activeFilter"
@select-segment="handleChartSelect"
/>
<div v-if="!hasStations" class="empty-state">目前無 QC-GATE LOT</div>
</template>
</section>
<section class="panel table-panel">
<div class="panel-header">
<h2>LOT 明細</h2>
<button
v-if="activeFilter"
type="button"
class="filter-indicator"
@click="clearFilter"
>
篩選中{{ activeFilterLabel }}點擊清除
</button>
</div>
<LotTable
:lots="filteredLots"
:active-filter="activeFilter"
@clear-filter="clearFilter"
/>
</section>
</main>
</div>
</template>

View File

@@ -0,0 +1,176 @@
<script setup>
import { computed, ref } from 'vue';
const props = defineProps({
lots: {
type: Array,
default: () => [],
},
activeFilter: {
type: Object,
default: null,
},
});
const emit = defineEmits(['clear-filter']);
const sortKey = ref('wait_hours');
const sortDirection = ref('desc');
const BUCKET_LABELS = {
lt_6h: '<6hr',
'6h_12h': '6-12hr',
'12h_24h': '12-24hr',
gt_24h: '>24hr',
};
const HEADERS = [
{ key: 'lot_id', label: 'LOT ID' },
{ key: 'product', label: 'Product' },
{ key: 'qty', label: 'QTY' },
{ key: 'step', label: '站點' },
{ key: 'workorder', label: 'Workorder' },
{ key: 'move_in_time', label: 'Move In' },
{ key: 'wait_hours', label: 'Wait (hr)' },
{ key: 'bucket', label: '區間' },
{ key: 'status', label: '狀態' },
];
function normalizeForSort(value, key) {
if (key === 'qty' || key === 'wait_hours') {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
if (value == null) {
return '';
}
return String(value).toUpperCase();
}
function toggleSort(key) {
if (sortKey.value === key) {
sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc';
return;
}
sortKey.value = key;
sortDirection.value = key === 'wait_hours' ? 'desc' : 'asc';
}
const sortedLots = computed(() => {
const rows = Array.isArray(props.lots) ? [...props.lots] : [];
const direction = sortDirection.value === 'asc' ? 1 : -1;
const key = sortKey.value;
rows.sort((left, right) => {
const leftValue = normalizeForSort(left?.[key], key);
const rightValue = normalizeForSort(right?.[key], key);
if (leftValue < rightValue) return -1 * direction;
if (leftValue > rightValue) return 1 * direction;
return 0;
});
return rows;
});
function formatValue(value, fallback = '-') {
if (value == null || value === '') {
return fallback;
}
return String(value);
}
function formatQty(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
return '-';
}
return parsed.toLocaleString('zh-TW');
}
function formatWait(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
return '-';
}
return parsed.toFixed(1);
}
function formatTime(value) {
if (!value) {
return '-';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return String(value);
}
return date.toLocaleString('zh-TW', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
function bucketLabel(value) {
return BUCKET_LABELS[value] || value || '-';
}
function bucketClass(value) {
return `bucket-${String(value || '').replace(/[^a-z0-9]+/gi, '-')}`;
}
function currentSortLabel(columnKey) {
if (sortKey.value !== columnKey) {
return '';
}
return sortDirection.value === 'asc' ? '▲' : '▼';
}
</script>
<template>
<div class="lot-table-wrap">
<div v-if="activeFilter" class="filter-chip" @click="emit('clear-filter')">
已套用圖表篩選點擊清除
</div>
<div class="lot-table-scroll">
<table class="lot-table">
<thead>
<tr>
<th v-for="header in HEADERS" :key="header.key">
<button
type="button"
class="sort-button"
@click="toggleSort(header.key)"
>
{{ header.label }}
<span class="sort-indicator">{{ currentSortLabel(header.key) }}</span>
</button>
</th>
</tr>
</thead>
<tbody>
<tr v-for="lot in sortedLots" :key="`${lot.lot_id}-${lot.step}-${lot.move_in_time}`">
<td>{{ formatValue(lot.lot_id) }}</td>
<td>{{ formatValue(lot.product) }}</td>
<td class="cell-number">{{ formatQty(lot.qty) }}</td>
<td>{{ formatValue(lot.step) }}</td>
<td>{{ formatValue(lot.workorder) }}</td>
<td>{{ formatTime(lot.move_in_time) }}</td>
<td class="cell-number">{{ formatWait(lot.wait_hours) }}</td>
<td>
<span class="bucket-pill" :class="bucketClass(lot.bucket)">
{{ bucketLabel(lot.bucket) }}
</span>
</td>
<td>{{ formatValue(lot.status) }}</td>
</tr>
<tr v-if="sortedLots.length === 0">
<td class="table-empty" :colspan="HEADERS.length">目前無符合條件的 LOT</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>

View File

@@ -0,0 +1,150 @@
<script setup>
import { computed } from 'vue';
import VChart from 'vue-echarts';
import { use } from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
import { BarChart } from 'echarts/charts';
import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components';
use([CanvasRenderer, BarChart, GridComponent, LegendComponent, TooltipComponent]);
const props = defineProps({
stations: {
type: Array,
default: () => [],
},
activeFilter: {
type: Object,
default: null,
},
});
const emit = defineEmits(['select-segment']);
const BUCKETS = [
{ key: 'lt_6h', label: '<6hr', color: '#22c55e' },
{ key: '6h_12h', label: '6-12hr', color: '#facc15' },
{ key: '12h_24h', label: '12-24hr', color: '#fb923c' },
{ key: 'gt_24h', label: '>24hr', color: '#ef4444' },
];
function isSelected(stationName, bucketKey) {
return (
props.activeFilter &&
props.activeFilter.station === stationName &&
props.activeFilter.bucket === bucketKey
);
}
function hasActiveFilter() {
return Boolean(props.activeFilter?.station && props.activeFilter?.bucket);
}
const chartOption = computed(() => {
const stationNames = props.stations.map((station) => station.specname);
return {
animationDuration: 350,
animationDurationUpdate: 300,
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
},
legend: {
top: 4,
icon: 'roundRect',
itemHeight: 10,
textStyle: {
color: '#334155',
},
},
grid: {
left: 24,
right: 16,
bottom: 18,
top: 46,
containLabel: true,
},
xAxis: {
type: 'category',
data: stationNames,
axisLabel: {
color: '#334155',
fontSize: 11,
interval: 0,
rotate: stationNames.length > 8 ? 30 : 0,
},
axisTick: {
show: false,
},
axisLine: {
lineStyle: {
color: '#cbd5e1',
},
},
},
yAxis: {
type: 'value',
name: 'LOT 數',
nameTextStyle: {
color: '#64748b',
padding: [0, 16, 0, 0],
},
axisLabel: {
color: '#475569',
},
splitLine: {
lineStyle: {
color: '#e2e8f0',
type: 'dashed',
},
},
},
series: BUCKETS.map((bucket) => ({
id: bucket.key,
name: bucket.label,
type: 'bar',
stack: 'lots',
color: bucket.color,
barMaxWidth: 40,
emphasis: {
focus: 'series',
},
data: props.stations.map((station) => {
const count = Number(station?.buckets?.[bucket.key] || 0);
const opacity = hasActiveFilter() && !isSelected(station.specname, bucket.key) ? 0.25 : 1;
return {
value: count,
itemStyle: { opacity },
};
}),
itemStyle: {
borderColor: '#ffffff',
borderWidth: 1,
},
})),
};
});
function handleChartClick(params) {
if (!params?.name || !params?.seriesId) {
return;
}
emit('select-segment', {
station: String(params.name),
bucket: String(params.seriesId),
});
}
</script>
<template>
<div class="qc-gate-chart">
<VChart
v-if="stations.length"
class="chart-canvas"
:option="chartOption"
autoresize
@click="handleChartClick"
/>
</div>
</template>

View File

@@ -0,0 +1,167 @@
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { apiGet } from '../../core/api.js';
const REFRESH_INTERVAL_MS = 10 * 60 * 1000;
const API_TIMEOUT_MS = 60000;
const BUCKET_KEYS = ['lt_6h', '6h_12h', '12h_24h', 'gt_24h'];
function normalizeBuckets(rawBuckets) {
const buckets = {};
for (const key of BUCKET_KEYS) {
const value = Number(rawBuckets?.[key]);
buckets[key] = Number.isFinite(value) ? value : 0;
}
return buckets;
}
function normalizeStation(rawStation) {
const lots = Array.isArray(rawStation?.lots) ? rawStation.lots : [];
return {
specname: String(rawStation?.specname ?? '').trim(),
spec_order: Number(rawStation?.spec_order ?? 999999),
buckets: normalizeBuckets(rawStation?.buckets),
total: Number(rawStation?.total ?? lots.length),
lots,
};
}
function normalizePayload(payload) {
const stations = Array.isArray(payload?.stations)
? payload.stations.map(normalizeStation).filter((station) => station.specname)
: [];
return {
cache_time: payload?.cache_time ?? null,
stations,
};
}
function toComparableWaitHours(value) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
export function useQcGateData() {
const stations = ref([]);
const cacheTime = ref(null);
const loading = ref(true);
const refreshing = ref(false);
const errorMessage = ref('');
const lastFetchedAt = ref(null);
let refreshTimer = null;
let currentRequest = null;
const allLots = computed(() => {
const merged = [];
for (const station of stations.value) {
const stationLots = Array.isArray(station.lots) ? station.lots : [];
merged.push(...stationLots);
}
return merged.sort(
(left, right) => toComparableWaitHours(right.wait_hours) - toComparableWaitHours(left.wait_hours)
);
});
const fetchData = async ({ background = false } = {}) => {
if (currentRequest) {
currentRequest.abort();
}
currentRequest = new AbortController();
if (background) {
refreshing.value = true;
} else {
loading.value = true;
}
errorMessage.value = '';
try {
const response = await apiGet('/api/qc-gate/summary', {
timeout: API_TIMEOUT_MS,
signal: currentRequest.signal,
});
const payload = response?.success ? response.data : response;
const normalized = normalizePayload(payload || {});
stations.value = normalized.stations;
cacheTime.value = normalized.cache_time;
lastFetchedAt.value = new Date();
return true;
} catch (error) {
if (error?.name === 'AbortError') {
return false;
}
errorMessage.value = error?.message || '載入 QC-GATE 資料失敗';
return false;
} finally {
loading.value = false;
refreshing.value = false;
}
};
const stopAutoRefresh = () => {
if (refreshTimer) {
clearInterval(refreshTimer);
refreshTimer = null;
}
};
const startAutoRefresh = () => {
stopAutoRefresh();
refreshTimer = setInterval(() => {
if (!document.hidden) {
void fetchData({ background: true });
}
}, REFRESH_INTERVAL_MS);
};
const resetAutoRefresh = () => {
startAutoRefresh();
};
const refreshNow = async () => {
resetAutoRefresh();
await fetchData({ background: true });
};
const handleVisibilityChange = () => {
if (document.hidden) {
return;
}
void fetchData({ background: true });
resetAutoRefresh();
};
onMounted(() => {
void fetchData({ background: false });
startAutoRefresh();
document.addEventListener('visibilitychange', handleVisibilityChange);
});
onBeforeUnmount(() => {
stopAutoRefresh();
if (currentRequest) {
currentRequest.abort();
currentRequest = null;
}
document.removeEventListener('visibilitychange', handleVisibilityChange);
});
return {
stations,
cacheTime,
loading,
refreshing,
errorMessage,
lastFetchedAt,
allLots,
fetchData,
refreshNow,
resetAutoRefresh,
};
}

View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-Hant">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>QC-GATE 狀態</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="./main.js"></script>
</body>
</html>

View File

@@ -0,0 +1,6 @@
import { createApp } from 'vue';
import App from './App.vue';
import './style.css';
createApp(App).mount('#app');

View File

@@ -0,0 +1,318 @@
:root {
--bg: #f5f7fa;
--card-bg: #ffffff;
--text: #1f2937;
--muted: #64748b;
--border: #dbe3ef;
--header-from: #667eea;
--header-to: #764ba2;
--success: #22c55e;
--warning: #facc15;
--danger: #ef4444;
--shadow: 0 2px 10px rgba(15, 23, 42, 0.08);
--shadow-strong: 0 6px 24px rgba(102, 126, 234, 0.24);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: 'Microsoft JhengHei', Arial, sans-serif;
background: var(--bg);
color: var(--text);
}
.qc-gate-page {
min-height: 100vh;
padding: 20px;
max-width: 1900px;
margin: 0 auto;
}
.qc-gate-header {
background: linear-gradient(135deg, var(--header-from) 0%, var(--header-to) 100%);
color: #ffffff;
border-radius: 12px;
box-shadow: var(--shadow-strong);
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
padding: 20px 24px;
margin-bottom: 16px;
}
.qc-gate-header h1 {
margin: 0;
font-size: 28px;
line-height: 1.2;
}
.header-subtitle {
margin: 8px 0 0;
font-size: 14px;
color: rgba(255, 255, 255, 0.85);
}
.header-meta {
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
justify-content: flex-end;
}
.meta-row {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 140px;
}
.meta-label {
font-size: 11px;
color: rgba(255, 255, 255, 0.76);
text-transform: uppercase;
letter-spacing: 0.4px;
}
.meta-value {
font-size: 14px;
font-weight: 600;
}
.refresh-button {
border: 1px solid rgba(255, 255, 255, 0.35);
background: rgba(255, 255, 255, 0.16);
color: #ffffff;
border-radius: 8px;
padding: 8px 14px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.refresh-button:hover {
background: rgba(255, 255, 255, 0.26);
}
.refresh-button:disabled {
cursor: not-allowed;
opacity: 0.7;
}
.error-banner {
margin: 0 0 16px;
padding: 10px 12px;
border-radius: 8px;
border: 1px solid rgba(239, 68, 68, 0.25);
background: #fee2e2;
color: #991b1b;
font-size: 13px;
}
.qc-gate-content {
display: grid;
gap: 16px;
}
.panel {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: var(--shadow);
overflow: hidden;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 14px 18px;
border-bottom: 1px solid var(--border);
background: #f8fafc;
}
.panel-header h2 {
margin: 0;
font-size: 16px;
}
.panel-hint {
font-size: 12px;
color: var(--muted);
}
.loading-state,
.empty-state {
display: flex;
align-items: center;
justify-content: center;
min-height: 280px;
color: var(--muted);
font-size: 14px;
}
.chart-panel {
padding-bottom: 8px;
}
.qc-gate-chart {
min-height: 320px;
padding: 10px 14px 2px;
}
.chart-canvas {
width: 100%;
height: 340px;
}
.filter-indicator {
border: 1px solid #93c5fd;
background: #dbeafe;
color: #1d4ed8;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
padding: 6px 12px;
cursor: pointer;
}
.filter-indicator:hover {
background: #bfdbfe;
}
.lot-table-wrap {
padding: 12px 14px 16px;
}
.filter-chip {
display: inline-flex;
align-items: center;
margin-bottom: 10px;
border: 1px solid #93c5fd;
background: #eff6ff;
color: #1e40af;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
padding: 5px 10px;
cursor: pointer;
}
.filter-chip:hover {
background: #dbeafe;
}
.lot-table-scroll {
overflow: auto;
max-height: 520px;
border: 1px solid var(--border);
border-radius: 8px;
}
.lot-table {
width: 100%;
border-collapse: collapse;
min-width: 1280px;
}
.lot-table th,
.lot-table td {
padding: 8px 10px;
border-bottom: 1px solid #e2e8f0;
font-size: 12px;
text-align: left;
white-space: nowrap;
}
.lot-table th {
background: #f1f5f9;
position: sticky;
top: 0;
z-index: 1;
}
.sort-button {
border: none;
background: transparent;
padding: 0;
display: inline-flex;
align-items: center;
gap: 4px;
color: #334155;
font-size: 12px;
font-weight: 700;
cursor: pointer;
}
.sort-indicator {
color: #64748b;
font-size: 11px;
min-width: 10px;
}
.lot-table tbody tr:hover {
background: #f8fafc;
}
.cell-number {
text-align: right;
}
.table-empty {
text-align: center;
color: var(--muted);
padding: 24px 0;
}
.bucket-pill {
display: inline-flex;
align-items: center;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
padding: 3px 8px;
}
.bucket-lt-6h {
background: #dcfce7;
color: #166534;
}
.bucket-6h-12h {
background: #fef9c3;
color: #854d0e;
}
.bucket-12h-24h {
background: #ffedd5;
color: #9a3412;
}
.bucket-gt-24h {
background: #fee2e2;
color: #991b1b;
}
@media (max-width: 1100px) {
.qc-gate-page {
padding: 12px;
}
.qc-gate-header {
flex-direction: column;
align-items: stretch;
}
.header-meta {
justify-content: flex-start;
}
.chart-canvas {
height: 300px;
}
}

View File

@@ -1,7 +1,10 @@
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
import vue from '@vitejs/plugin-vue';
export default defineConfig(({ mode }) => ({ export default defineConfig(({ mode }) => ({
base: '/static/dist/',
plugins: [vue()],
publicDir: false, publicDir: false,
build: { build: {
outDir: '../src/mes_dashboard/static/dist', outDir: '../src/mes_dashboard/static/dist',
@@ -19,19 +22,31 @@ export default defineConfig(({ mode }) => ({
'excel-query': resolve(__dirname, 'src/excel-query/main.js'), 'excel-query': resolve(__dirname, 'src/excel-query/main.js'),
tables: resolve(__dirname, 'src/tables/main.js'), tables: resolve(__dirname, 'src/tables/main.js'),
'query-tool': resolve(__dirname, 'src/query-tool/main.js'), 'query-tool': resolve(__dirname, 'src/query-tool/main.js'),
'tmtt-defect': resolve(__dirname, 'src/tmtt-defect/main.js') 'tmtt-defect': resolve(__dirname, 'src/tmtt-defect/main.js'),
'qc-gate': resolve(__dirname, 'src/qc-gate/index.html')
}, },
output: { output: {
entryFileNames: '[name].js', entryFileNames: '[name].js',
chunkFileNames: 'chunks/[name]-[hash].js', chunkFileNames: 'chunks/[name]-[hash].js',
assetFileNames: '[name][extname]', assetFileNames: '[name][extname]',
manualChunks(id) { manualChunks(id) {
if (!id.includes('node_modules')) { const normalizedId = id.replace(/\\/g, '/');
if (!normalizedId.includes('node_modules')) {
return; return;
} }
if (id.includes('echarts')) { if (
normalizedId.includes('/node_modules/echarts/') ||
normalizedId.includes('/node_modules/zrender/') ||
normalizedId.includes('/node_modules/vue-echarts/')
) {
return 'vendor-echarts'; return 'vendor-echarts';
} }
if (
normalizedId.includes('/node_modules/vue/') ||
normalizedId.includes('/node_modules/@vue/')
) {
return 'vendor-vue';
}
return 'vendor'; return 'vendor';
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,614 +0,0 @@
{
"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"
}
]
}
]
}
]
}
]
}
]
}

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-02-09

View File

@@ -0,0 +1,180 @@
## Context
目前系統有 11 個頁面,全部使用 Jinja2 shell + Vite JS 混合模式。前端無 UI 框架(純 vanilla JSECharts 以靜態 minified 檔案引入(非 npm。WIP 資料透過 Redis 快取,每 10 分鐘從 Oracle DWH.DW_MES_LOT_V 同步。
QC-GATE 頁面將是第一個採用 Vue 3 + Vite 純前端架構的頁面,建立後續遷移模式。
## Goals / Non-Goals
**Goals:**
- 提供 QC-GATE 站點即時 LOT 狀態報表(條圖 + 篩選清單)
- 建立純 Vue 3 + Vite 頁面架構模式(不依賴 Jinja2
- 與現有 portal iframe 機制無縫整合
- 複用現有 WIP Redis 快取,不增加 Oracle 查詢負擔
**Non-Goals:**
- 不遷移現有頁面到 Vue 3本次僅建立模式
- 不引入 Vue Router 或 Pinia單頁報表不需要
- 不修改現有 CSP 或安全策略
- 不改動 WIP 快取更新機制
## Decisions
### D1: UI 框架選型 — Vue 3
**選擇**: Vue 3 (Composition API + SFC)
**替代方案**: React (JSX 轉換成本較高), Svelte (生態系較小)
**理由**: Vite 原生支援、template 語法接近 Jinja 降低遷移門檻、vue-echarts 整合成熟、支持漸進式頁面遷移
### D2: 頁面服務方式 — Vite MPA HTML entry + Flask 靜態服務
**選擇**: Vite build 產出完整 HTML (`frontend/src/qc-gate/index.html`)Flask 以 `send_from_directory` 服務
**替代方案**: 最小 Jinja shell仍有 Jinja 依賴)
**理由**:
- 完全脫離 Jinja2建立乾淨的遷移模式
- 此頁面為唯讀報表,只有 GET 請求,不需要 CSRF token 注入
- Toast 功能由 Vue component 自行實作(不依賴 `_base.html` 的全域 toast
- Vite config 已有 `manualChunks` 設定,只需新增 HTML entry
### D3: ECharts 引入方式 — npm + tree-shaking
**選擇**: `npm install echarts vue-echarts`,使用 tree-shaking 只引入 bar chart 相關模組
**替代方案**: 繼續使用靜態 minified 檔案(無法 tree-shake~1MB
**理由**: 既有 Vite config 已有 `vendor-echarts` chunk split 邏輯npm 引入後可 tree-shake 到只需 bar chart 模組(~200KB gzipped
### D4: API 設計 — 新增 `/api/qc-gate/summary` 端點
**選擇**: 新建 `qc_gate_routes.py` blueprint + `qc_gate_service.py` 服務
**替代方案**: 擴展現有 wip_routes不符合 SRP
**理由**:
- 從 WIP Redis 快取中讀取,篩選 `SPECNAME LIKE '%QC%GATE%'`
- 使用 `DW_MES_SPEC_WORKCENTER_V`(已在 filter_cache.py 中快取)取得站點排序
- 在後端完成 6HR 分級計算,前端只負責渲染
- 回傳結構包含 summary條圖資料和 lots清單資料
### D5: QC-GATE 站點識別與排序
**選擇**: 從 WIP 快取篩選 `SPECNAME` 包含 "QC" 和 "GATE" 的 LOT站點排序從 `DW_MES_SPEC_WORKCENTER_V``SPEC` 欄位匹配取得 `SPEC_ORDER`
**理由**: SPECNAME 是 LOT 層級的製程步驟名稱DW_MES_SPEC_WORKCENTER_V 是維度主表提供排序資訊
### D6: 等待時間分級
**選擇**: 四級分組
- `< 6hr` — 正常(綠色)
- `6-12hr` — 注意(黃色)
- `12-24hr` — 警告(橙色)
- `> 24hr` — 超時(紅色)
**計算**: `wait_hours = (SYS_DATE - MOVEINTIMESTAMP)` 以小時為單位,在後端計算
### D7: 自動刷新 — 複用 wip-overview 模式
**選擇**: 10 分鐘 `setInterval` + `visibilitychange` 即時刷新
**理由**: 與 WIP 快取同步週期一致避免無效請求tab 隱藏時跳過刷新
## Architecture
```
┌─ Vite Build ──────────────────────────────┐
│ frontend/src/qc-gate/ │
│ index.html ← HTML entry (no Jinja)│
│ main.js ← createApp, mount │
│ App.vue ← root layout │
│ components/ │
│ QcGateChart.vue ← ECharts stacked bar│
│ LotTable.vue ← filterable table │
│ composables/ │
│ useQcGateData.js ← fetch + transform │
│ useAutoRefresh.js← 10min refresh logic│
│ style.css ← page styles │
│ │
│ Build output → static/dist/qc-gate.html │
│ static/dist/qc-gate.js │
│ static/dist/qc-gate.css │
└───────────────────────────────────────────┘
┌─ Flask Backend ───────────────────────────┐
│ routes/qc_gate_routes.py │
│ GET /api/qc-gate/summary │
│ │
│ services/qc_gate_service.py │
│ get_qc_gate_summary() │
│ ├─ get_cached_wip_data() (Redis) │
│ ├─ filter SPECNAME %QC%GATE% │
│ ├─ compute wait_hours per LOT │
│ └─ group by SPECNAME + bucket │
│ │
│ app.py │
│ GET /qc-gate → send_from_directory() │
└───────────────────────────────────────────┘
```
## API Response Shape
```json
{
"cache_time": "2026-02-09T14:30:00",
"stations": [
{
"specname": "QC-GATE-DB",
"spec_order": "030",
"buckets": {
"lt_6h": 12,
"6h_12h": 5,
"12h_24h": 3,
"gt_24h": 1
},
"total": 21,
"lots": [
{
"lot_id": "L001",
"container_id": "C001",
"product": "PKG-A",
"qty": 5000,
"step": "QC-GATE-DB",
"workorder": "WO123",
"move_in_time": "2026-02-09T08:30:00",
"wait_hours": 6.0,
"bucket": "6h_12h",
"status": "QUEUE",
"equipment": null
}
]
}
]
}
```
## Vite Config Changes
```js
// vite.config.js additions
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
build: {
rollupOptions: {
input: {
// ... existing entries ...
'qc-gate': resolve(__dirname, 'src/qc-gate/index.html') // HTML entry
},
output: {
manualChunks(id) {
if (!id.includes('node_modules')) return;
if (id.includes('echarts')) return 'vendor-echarts';
if (id.includes('vue')) return 'vendor-vue'; // 新增 Vue chunk
return 'vendor';
}
}
}
}
});
```
## Risks / Trade-offs
- **[Vue plugin 影響既有 build]** → `@vitejs/plugin-vue` 只處理 `.vue` 檔案,不影響現有 `.js` entry points。已驗證 Vite plugin 系統為 additive。
- **[ECharts npm vs 靜態檔案共存]** → 新頁面用 npm echarts舊頁面繼續用靜態檔案。`vendor-echarts` chunk 只被 qc-gate 引用,不影響舊頁面 bundle size。
- **[SPECNAME pattern 可能變動]** → 篩選邏輯集中在 `qc_gate_service.py` 單一位置,易於調整。
- **[純靜態 HTML 無法使用 Flask template context]** → 此頁面為唯讀報表,不需要 CSRF、不需要 session 資料。認證由 portal iframe 外層處理。

View File

@@ -0,0 +1,32 @@
## Why
目前系統缺乏 QC-GATE 站點的即時 LOT 狀態監控。QC-GATE 是製程中的品質關卡LOT 在此站點的等待時間直接影響生產效率。需要一個視覺化報表即時呈現各 QC-GATE 站點的 LOT 分佈與等待時間,讓管理者快速識別瓶頸。
此頁面同時作為前端架構遷移的起點 — 第一個完全脫離 Jinja2 的純 Vue 3 + Vite 頁面,為後續頁面遷移建立模式。
## What Changes
- 新增 QC-GATE 即時狀態報表頁面(`/qc-gate`),使用 Vue 3 + ECharts 實作
- 新增後端 API endpoint`/api/qc-gate/summary`),從 WIP Redis cache 篩選 QC-GATE 相關 LOT
- 前端引入 Vue 3 和 ECharts npm 套件,建立純 Vite 頁面架構模式
- 頁面以 Vite HTML entry 方式建置,完全不使用 Jinja2 template
- 頁面註冊至「報表類」drawer狀態為 released
- 使用 `DW_MES_SPEC_WORKCENTER_V` 取得 QC-GATE 站點清單與排序
- 等待時間以 6 小時為基準分為四級:<6hr, 6-12hr, 12-24hr, >24hr
- 支援 10 分鐘自動刷新與 visibilitychange 即時刷新
## Capabilities
### New Capabilities
- `qc-gate-status-report`: QC-GATE 站點即時 LOT 狀態報表 — 包含 API 端點、Vue 3 前端頁面、圖表互動、清單篩選
- `vue-vite-page-architecture`: 純 Vue 3 + Vite 頁面架構模式 — 脫離 Jinja2 的前端建置模式、CSRF/auth 處理、與 portal iframe 整合
### Modified Capabilities
- `page-drawer-assignment`: 新增 qc-gate 頁面至報表類 drawer 的 page_status.json 配置
## Impact
- **前端**: 引入 `vue``echarts``vue-echarts` npm 依賴;修改 `vite.config.js` 加入 Vue plugin 和新 entry point
- **後端**: 新增 `qc_gate_routes.py` blueprint 和 `qc_gate_service.py` 服務;新增 Flask route serving 純靜態 HTML
- **配置**: `page_status.json` 新增 qc-gate 頁面定義
- **建置**: Vite config 需加入 `@vitejs/plugin-vue` 和 HTML entry

View File

@@ -0,0 +1,80 @@
## ADDED Requirements
### Requirement: System SHALL provide QC-GATE LOT status API
The system SHALL provide an API endpoint that returns real-time LOT status for all QC-GATE stations, with wait time classification.
#### Scenario: Retrieve QC-GATE summary
- **WHEN** user sends GET `/api/qc-gate/summary`
- **THEN** the system SHALL return all LOTs whose `SPECNAME` contains both "QC" and "GATE" (case-insensitive)
- **THEN** each LOT SHALL include `wait_hours` calculated as `(SYS_DATE - MOVEINTIMESTAMP)` in hours
- **THEN** each LOT SHALL be classified into a time bucket: `lt_6h`, `6h_12h`, `12h_24h`, or `gt_24h`
- **THEN** the response SHALL include per-station bucket counts and the full lot list
#### Scenario: QC-GATE data sourced from WIP cache
- **WHEN** the API is called
- **THEN** the system SHALL read from the existing WIP Redis cache (not direct Oracle query)
- **THEN** the response SHALL include `cache_time` indicating the WIP snapshot timestamp
#### Scenario: No QC-GATE lots in cache
- **WHEN** no LOTs match the QC-GATE SPECNAME pattern
- **THEN** the system SHALL return an empty `stations` array with `cache_time`
### Requirement: QC-GATE stations SHALL be ordered by spec sequence
The system SHALL order QC-GATE stations according to the manufacturing flow sequence from `DW_MES_SPEC_WORKCENTER_V`.
#### Scenario: Station ordering
- **WHEN** the API returns multiple QC-GATE stations
- **THEN** the stations SHALL be sorted by `SPEC_ORDER` from `DW_MES_SPEC_WORKCENTER_V` where `SPEC` matches the SPECNAME
#### Scenario: Station not found in spec dimension table
- **WHEN** a QC-GATE SPECNAME is not found in `DW_MES_SPEC_WORKCENTER_V`
- **THEN** the station SHALL appear at the end of the list with a high default sort order
### Requirement: QC-GATE report page SHALL display stacked bar chart
The page SHALL display a stacked bar chart showing LOT counts per QC-GATE station, grouped by wait time bucket.
#### Scenario: Bar chart rendering
- **WHEN** the page loads and data is available
- **THEN** the X-axis SHALL show QC-GATE station names
- **THEN** the Y-axis SHALL show LOT counts
- **THEN** each bar SHALL be stacked with four color-coded segments: <6hr (green), 6-12hr (yellow), 12-24hr (orange), >24hr (red)
#### Scenario: Empty state
- **WHEN** no QC-GATE LOTs exist
- **THEN** the chart area SHALL display a "目前無 QC-GATE LOT" message
### Requirement: QC-GATE report page SHALL display filterable LOT table
The page SHALL display a table listing individual LOTs, with click-to-filter interaction from the bar chart.
#### Scenario: Default table display
- **WHEN** the page loads
- **THEN** the table SHALL show all QC-GATE LOTs sorted by wait time descending
#### Scenario: Click bar chart to filter
- **WHEN** user clicks a specific segment of a bar (e.g., QC-GATE-DB's 6-12hr segment)
- **THEN** the table SHALL filter to show only LOTs matching that station AND time bucket
- **THEN** a filter indicator SHALL be visible showing the active filter
#### Scenario: Clear filter
- **WHEN** user clicks the active filter indicator or clicks the same bar segment again
- **THEN** the table SHALL return to showing all QC-GATE LOTs
### Requirement: QC-GATE report page SHALL auto-refresh
The page SHALL automatically refresh data at the same interval as the WIP cache update cycle.
#### Scenario: Auto-refresh while visible
- **WHEN** the page is visible and 10 minutes have elapsed since last refresh
- **THEN** the page SHALL fetch new data from the API without showing a full loading overlay
- **THEN** the chart and table SHALL update with new data
#### Scenario: Auto-refresh while hidden
- **WHEN** the page tab/iframe is hidden (document.hidden === true)
- **THEN** the auto-refresh SHALL be skipped
#### Scenario: Page becomes visible after being hidden
- **WHEN** the page becomes visible after being hidden
- **THEN** the page SHALL immediately refresh data
#### Scenario: Manual refresh
- **WHEN** user clicks the refresh button
- **THEN** the page SHALL fetch new data and reset the auto-refresh timer

View File

@@ -0,0 +1,41 @@
## ADDED Requirements
### Requirement: Pure Vite pages SHALL be served as static HTML
The system SHALL support serving Vite-built HTML pages directly via Flask without Jinja2 rendering.
#### Scenario: Serve pure Vite page
- **WHEN** user navigates to a pure Vite page route (e.g., `/qc-gate`)
- **THEN** Flask SHALL serve the pre-built HTML file from `static/dist/` via `send_from_directory`
- **THEN** the HTML SHALL NOT pass through Jinja2 template rendering
#### Scenario: Page works in portal iframe
- **WHEN** the pure Vite page is loaded inside the portal iframe
- **THEN** the page SHALL render correctly within the iframe context
- **THEN** CSP `frame-ancestors 'self'` SHALL allow the embedding
### Requirement: Vite config SHALL support Vue SFC and HTML entry points
The Vite build configuration SHALL support Vue Single File Components alongside existing vanilla JS entries.
#### Scenario: Vue plugin coexistence
- **WHEN** `vite build` is executed
- **THEN** Vue SFC (`.vue` files) SHALL be compiled by `@vitejs/plugin-vue`
- **THEN** existing vanilla JS entry points SHALL continue to build without modification
#### Scenario: HTML entry point
- **WHEN** a page uses an HTML file as its Vite entry point
- **THEN** Vite SHALL process the HTML and its referenced JS/CSS into `static/dist/`
- **THEN** the output SHALL include `<page-name>.html`, `<page-name>.js`, and `<page-name>.css`
#### Scenario: Chunk splitting
- **WHEN** Vite builds the project
- **THEN** Vue runtime SHALL be split into a `vendor-vue` chunk
- **THEN** ECharts modules SHALL be split into the existing `vendor-echarts` chunk
- **THEN** chunk splitting SHALL NOT affect existing page bundles
### Requirement: Pure Vite pages SHALL handle API calls without legacy MesApi
Pure Vite pages SHALL use the existing `frontend/src/core/api.js` module for API communication without depending on the global `window.MesApi` object from `_base.html`.
#### Scenario: API GET request from pure Vite page
- **WHEN** a pure Vite page makes a GET API call
- **THEN** the call SHALL use the `apiGet` function from `core/api.js`
- **THEN** the call SHALL work without `window.MesApi` being present

View File

@@ -0,0 +1,31 @@
## 1. Frontend Toolchain Setup
- [x] 1.1 Install npm dependencies: `vue`, `@vitejs/plugin-vue`, `echarts`, `vue-echarts`
- [x] 1.2 Update `vite.config.js`: add Vue plugin, add `qc-gate` HTML entry point, add `vendor-vue` manual chunk
## 2. Backend API
- [x] 2.1 Create `services/qc_gate_service.py`: read WIP cache, filter SPECNAME by QC/GATE pattern, compute wait_hours and bucket classification, sort stations by SPEC_ORDER from filter_cache
- [x] 2.2 Create `routes/qc_gate_routes.py`: blueprint with `GET /api/qc-gate/summary` endpoint
- [x] 2.3 Register blueprint in `routes/__init__.py` and add Flask route `GET /qc-gate` serving static HTML via `send_from_directory`
## 3. Vue Frontend Page
- [x] 3.1 Create `frontend/src/qc-gate/index.html`: standalone HTML entry with Vue app mount point
- [x] 3.2 Create `frontend/src/qc-gate/main.js`: Vue app bootstrap with createApp and mount
- [x] 3.3 Create `frontend/src/qc-gate/App.vue`: root layout with header (title, cache time, refresh button), chart area, and table area
- [x] 3.4 Create `frontend/src/qc-gate/composables/useQcGateData.js`: data fetching, 10min auto-refresh with visibilitychange, reactive state management
- [x] 3.5 Create `frontend/src/qc-gate/components/QcGateChart.vue`: ECharts stacked bar chart (x=station, y=count, stacked by 4 time buckets with color coding)
- [x] 3.6 Create `frontend/src/qc-gate/components/LotTable.vue`: sortable lot table with click-to-filter from chart, filter indicator, clear filter
- [x] 3.7 Create `frontend/src/qc-gate/style.css`: page styling consistent with existing dashboard aesthetic
## 4. Page Registration & Integration
- [x] 4.1 Register qc-gate page in `page_status.json`: route `/qc-gate`, name `QC-GATE 狀態`, drawer_id `reports`, status `released`
- [x] 4.2 Build frontend (`npm run build`) and verify output files exist in `static/dist/`
## 5. Verification
- [x] 5.1 Verify API endpoint returns correct data structure with QC-GATE filtered lots, wait time buckets, and station ordering
- [x] 5.2 Verify page renders in portal iframe: chart displays, table populates, click-to-filter works, auto-refresh fires
- [x] 5.3 Verify existing pages still build and function correctly (Vue plugin does not break vanilla JS entries)

View File

@@ -0,0 +1,84 @@
## Purpose
Define stable requirements for qc-gate-status-report.
## Requirements
### Requirement: System SHALL provide QC-GATE LOT status API
The system SHALL provide an API endpoint that returns real-time LOT status for all QC-GATE stations, with wait time classification.
#### Scenario: Retrieve QC-GATE summary
- **WHEN** user sends GET `/api/qc-gate/summary`
- **THEN** the system SHALL return all LOTs whose `SPECNAME` contains both "QC" and "GATE" (case-insensitive)
- **THEN** each LOT SHALL include `wait_hours` calculated as `(SYS_DATE - MOVEINTIMESTAMP)` in hours
- **THEN** each LOT SHALL be classified into a time bucket: `lt_6h`, `6h_12h`, `12h_24h`, or `gt_24h`
- **THEN** the response SHALL include per-station bucket counts and the full lot list
#### Scenario: QC-GATE data sourced from WIP cache
- **WHEN** the API is called
- **THEN** the system SHALL read from the existing WIP Redis cache (not direct Oracle query)
- **THEN** the response SHALL include `cache_time` indicating the WIP snapshot timestamp
#### Scenario: No QC-GATE lots in cache
- **WHEN** no LOTs match the QC-GATE SPECNAME pattern
- **THEN** the system SHALL return an empty `stations` array with `cache_time`
### Requirement: QC-GATE stations SHALL be ordered by spec sequence
The system SHALL order QC-GATE stations according to the manufacturing flow sequence from `DW_MES_SPEC_WORKCENTER_V`.
#### Scenario: Station ordering
- **WHEN** the API returns multiple QC-GATE stations
- **THEN** the stations SHALL be sorted by `SPEC_ORDER` from `DW_MES_SPEC_WORKCENTER_V` where `SPEC` matches the SPECNAME
#### Scenario: Station not found in spec dimension table
- **WHEN** a QC-GATE SPECNAME is not found in `DW_MES_SPEC_WORKCENTER_V`
- **THEN** the station SHALL appear at the end of the list with a high default sort order
### Requirement: QC-GATE report page SHALL display stacked bar chart
The page SHALL display a stacked bar chart showing LOT counts per QC-GATE station, grouped by wait time bucket.
#### Scenario: Bar chart rendering
- **WHEN** the page loads and data is available
- **THEN** the X-axis SHALL show QC-GATE station names
- **THEN** the Y-axis SHALL show LOT counts
- **THEN** each bar SHALL be stacked with four color-coded segments: <6hr (green), 6-12hr (yellow), 12-24hr (orange), >24hr (red)
#### Scenario: Empty state
- **WHEN** no QC-GATE LOTs exist
- **THEN** the chart area SHALL display a "目前無 QC-GATE LOT" message
### Requirement: QC-GATE report page SHALL display filterable LOT table
The page SHALL display a table listing individual LOTs, with click-to-filter interaction from the bar chart.
#### Scenario: Default table display
- **WHEN** the page loads
- **THEN** the table SHALL show all QC-GATE LOTs sorted by wait time descending
#### Scenario: Click bar chart to filter
- **WHEN** user clicks a specific segment of a bar (e.g., QC-GATE-DB's 6-12hr segment)
- **THEN** the table SHALL filter to show only LOTs matching that station AND time bucket
- **THEN** a filter indicator SHALL be visible showing the active filter
#### Scenario: Clear filter
- **WHEN** user clicks the active filter indicator or clicks the same bar segment again
- **THEN** the table SHALL return to showing all QC-GATE LOTs
### Requirement: QC-GATE report page SHALL auto-refresh
The page SHALL automatically refresh data at the same interval as the WIP cache update cycle.
#### Scenario: Auto-refresh while visible
- **WHEN** the page is visible and 10 minutes have elapsed since last refresh
- **THEN** the page SHALL fetch new data from the API without showing a full loading overlay
- **THEN** the chart and table SHALL update with new data
#### Scenario: Auto-refresh while hidden
- **WHEN** the page tab/iframe is hidden (document.hidden === true)
- **THEN** the auto-refresh SHALL be skipped
#### Scenario: Page becomes visible after being hidden
- **WHEN** the page becomes visible after being hidden
- **THEN** the page SHALL immediately refresh data
#### Scenario: Manual refresh
- **WHEN** user clicks the refresh button
- **THEN** the page SHALL fetch new data and reset the auto-refresh timer

View File

@@ -0,0 +1,45 @@
## Purpose
Define stable requirements for vue-vite-page-architecture.
## Requirements
### Requirement: Pure Vite pages SHALL be served as static HTML
The system SHALL support serving Vite-built HTML pages directly via Flask without Jinja2 rendering.
#### Scenario: Serve pure Vite page
- **WHEN** user navigates to a pure Vite page route (e.g., `/qc-gate`)
- **THEN** Flask SHALL serve the pre-built HTML file from `static/dist/` via `send_from_directory`
- **THEN** the HTML SHALL NOT pass through Jinja2 template rendering
#### Scenario: Page works in portal iframe
- **WHEN** the pure Vite page is loaded inside the portal iframe
- **THEN** the page SHALL render correctly within the iframe context
- **THEN** CSP `frame-ancestors 'self'` SHALL allow the embedding
### Requirement: Vite config SHALL support Vue SFC and HTML entry points
The Vite build configuration SHALL support Vue Single File Components alongside existing vanilla JS entries.
#### Scenario: Vue plugin coexistence
- **WHEN** `vite build` is executed
- **THEN** Vue SFC (`.vue` files) SHALL be compiled by `@vitejs/plugin-vue`
- **THEN** existing vanilla JS entry points SHALL continue to build without modification
#### Scenario: HTML entry point
- **WHEN** a page uses an HTML file as its Vite entry point
- **THEN** Vite SHALL process the HTML and its referenced JS/CSS into `static/dist/`
- **THEN** the output SHALL include `<page-name>.html`, `<page-name>.js`, and `<page-name>.css`
#### Scenario: Chunk splitting
- **WHEN** Vite builds the project
- **THEN** Vue runtime SHALL be split into a `vendor-vue` chunk
- **THEN** ECharts modules SHALL be split into the existing `vendor-echarts` chunk
- **THEN** chunk splitting SHALL NOT affect existing page bundles
### Requirement: Pure Vite pages SHALL handle API calls without legacy MesApi
Pure Vite pages SHALL use the existing `frontend/src/core/api.js` module for API communication without depending on the global `window.MesApi` object from `_base.html`.
#### Scenario: API GET request from pure Vite page
- **WHEN** a pure Vite page makes a GET API call
- **THEN** the call SHALL use the `apiGet` function from `core/api.js`
- **THEN** the call SHALL work without `window.MesApi` being present

View File

@@ -9,7 +9,7 @@ import os
import sys import sys
import threading import threading
from flask import Flask, jsonify, redirect, render_template, request, session, url_for from flask import Flask, jsonify, redirect, render_template, request, send_from_directory, session, url_for
from mes_dashboard.config.tables import TABLES_CONFIG from mes_dashboard.config.tables import TABLES_CONFIG
from mes_dashboard.config.settings import get_config from mes_dashboard.config.settings import get_config
@@ -413,6 +413,12 @@ def create_app(config_name: str | None = None) -> Flask:
"""TMTT printing & lead form defect analysis page.""" """TMTT printing & lead form defect analysis page."""
return render_template('tmtt_defect.html') return render_template('tmtt_defect.html')
@app.route('/qc-gate')
def qc_gate_page():
"""QC-GATE status report served as pure Vite HTML output."""
dist_dir = os.path.join(app.static_folder or "", "dist")
return send_from_directory(dist_dir, 'qc-gate.html')
# ======================================================== # ========================================================
# Table Query APIs (for table_data_viewer) # Table Query APIs (for table_data_viewer)
# ======================================================== # ========================================================

View File

@@ -15,6 +15,7 @@ from .resource_history_routes import resource_history_bp
from .job_query_routes import job_query_bp from .job_query_routes import job_query_bp
from .query_tool_routes import query_tool_bp from .query_tool_routes import query_tool_bp
from .tmtt_defect_routes import tmtt_defect_bp from .tmtt_defect_routes import tmtt_defect_bp
from .qc_gate_routes import qc_gate_bp
def register_routes(app) -> None: def register_routes(app) -> None:
@@ -28,6 +29,7 @@ def register_routes(app) -> None:
app.register_blueprint(job_query_bp) app.register_blueprint(job_query_bp)
app.register_blueprint(query_tool_bp) app.register_blueprint(query_tool_bp)
app.register_blueprint(tmtt_defect_bp) app.register_blueprint(tmtt_defect_bp)
app.register_blueprint(qc_gate_bp)
__all__ = [ __all__ = [
'wip_bp', 'wip_bp',
@@ -41,5 +43,6 @@ __all__ = [
'job_query_bp', 'job_query_bp',
'query_tool_bp', 'query_tool_bp',
'tmtt_defect_bp', 'tmtt_defect_bp',
'qc_gate_bp',
'register_routes', 'register_routes',
] ]

View File

@@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
"""QC-GATE API routes."""
from __future__ import annotations
from flask import Blueprint, jsonify
from mes_dashboard.services.qc_gate_service import get_qc_gate_summary
qc_gate_bp = Blueprint('qc_gate', __name__, url_prefix='/api/qc-gate')
@qc_gate_bp.route('/summary')
def api_qc_gate_summary():
"""Return per-station QC-GATE lot summary from cached WIP data."""
result = get_qc_gate_summary()
if result is not None:
return jsonify({'success': True, 'data': result})
return jsonify({'success': False, 'error': '查詢失敗'}), 500

View File

@@ -31,6 +31,7 @@ _CACHE = {
'workcenter_groups': None, # List of {name, sequence} 'workcenter_groups': None, # List of {name, sequence}
'workcenter_mapping': None, # Dict {workcentername: {group, sequence}} 'workcenter_mapping': None, # Dict {workcentername: {group, sequence}}
'workcenter_to_short': None, # Dict {workcentername: short_name} 'workcenter_to_short': None, # Dict {workcentername: short_name}
'spec_order_mapping': None, # Dict {spec_name_upper: spec_order}
'last_refresh': None, 'last_refresh': None,
'is_loading': False, 'is_loading': False,
} }
@@ -148,6 +149,19 @@ def get_workcenters_by_group(group_name: str) -> List[str]:
] ]
def get_spec_order_mapping(force_refresh: bool = False) -> Dict[str, int]:
"""Get SPEC -> SPEC_ORDER mapping from SPEC_WORKCENTER_V cache.
Returns:
Dict mapping normalized SPEC name (uppercase) to integer SPEC_ORDER.
"""
_ensure_cache_loaded(force_refresh)
mapping = _CACHE.get('spec_order_mapping')
if isinstance(mapping, dict):
return mapping
return {}
# ============================================================ # ============================================================
# Cache Management # Cache Management
# ============================================================ # ============================================================
@@ -166,6 +180,7 @@ def get_cache_status() -> Dict[str, Any]:
'is_loading': _CACHE.get('is_loading', False), 'is_loading': _CACHE.get('is_loading', False),
'workcenter_groups_count': len(_CACHE.get('workcenter_groups') or []), 'workcenter_groups_count': len(_CACHE.get('workcenter_groups') or []),
'workcenter_mapping_count': len(_CACHE.get('workcenter_mapping') or {}), 'workcenter_mapping_count': len(_CACHE.get('workcenter_mapping') or {}),
'spec_order_mapping_count': len(_CACHE.get('spec_order_mapping') or {}),
} }
@@ -215,17 +230,20 @@ def _load_cache() -> bool:
try: try:
# Load workcenter groups - prioritize SPEC_WORKCENTER_V # Load workcenter groups - prioritize SPEC_WORKCENTER_V
wc_groups, wc_mapping, wc_short = _load_workcenter_data() wc_groups, wc_mapping, wc_short = _load_workcenter_data()
spec_order_mapping = _load_spec_order_mapping_from_spec()
with _CACHE_LOCK: with _CACHE_LOCK:
_CACHE['workcenter_groups'] = wc_groups _CACHE['workcenter_groups'] = wc_groups
_CACHE['workcenter_mapping'] = wc_mapping _CACHE['workcenter_mapping'] = wc_mapping
_CACHE['workcenter_to_short'] = wc_short _CACHE['workcenter_to_short'] = wc_short
_CACHE['spec_order_mapping'] = spec_order_mapping
_CACHE['last_refresh'] = datetime.now() _CACHE['last_refresh'] = datetime.now()
_CACHE['is_loading'] = False _CACHE['is_loading'] = False
logger.info( logger.info(
f"Filter cache refreshed: {len(wc_groups or [])} groups, " f"Filter cache refreshed: {len(wc_groups or [])} groups, "
f"{len(wc_mapping or {})} workcenters" f"{len(wc_mapping or {})} workcenters, "
f"{len(spec_order_mapping or {})} specs"
) )
return True return True
@@ -346,6 +364,60 @@ def _load_workcenter_mapping_from_spec():
return [], {}, {} return [], {}, {}
def _safe_sort_value(value: Any, default: int = 999999) -> int:
"""Parse sequence/order values into stable integers."""
if value is None:
return default
try:
return int(value)
except (TypeError, ValueError):
text = str(value).strip()
if not text:
return default
digits = ''.join(ch for ch in text if ch.isdigit())
if not digits:
return default
try:
return int(digits)
except (TypeError, ValueError):
return default
def _normalize_spec_name(spec_name: Any) -> str:
if spec_name is None:
return ''
return str(spec_name).strip().upper()
def _load_spec_order_mapping_from_spec() -> Dict[str, int]:
"""Load SPEC -> SPEC_ORDER mapping from DW_MES_SPEC_WORKCENTER_V."""
try:
sql = f"""
SELECT DISTINCT
SPEC,
SPEC_ORDER
FROM {SPEC_WORKCENTER_VIEW}
WHERE SPEC IS NOT NULL
"""
df = read_sql_df(sql)
if df is None or df.empty:
return {}
mapping: Dict[str, int] = {}
for _, row in df.iterrows():
normalized_spec = _normalize_spec_name(row.get('SPEC'))
if not normalized_spec:
continue
sort_order = _safe_sort_value(row.get('SPEC_ORDER'))
previous = mapping.get(normalized_spec)
if previous is None or sort_order < previous:
mapping[normalized_spec] = sort_order
return mapping
except Exception as exc:
logger.error(f"Failed to load SPEC_ORDER mapping from SPEC_WORKCENTER_V: {exc}")
return {}
def _extract_workcenter_data_from_df(df): def _extract_workcenter_data_from_df(df):
"""Extract workcenter groups and mapping from DataFrame. """Extract workcenter groups and mapping from DataFrame.

View File

@@ -0,0 +1,247 @@
# -*- coding: utf-8 -*-
"""QC-GATE summary service built from cached WIP data."""
from __future__ import annotations
import logging
from typing import Any, Dict, Optional
import pandas as pd
from mes_dashboard.core.cache import (
get_cached_wip_data,
get_cached_sys_date,
get_cache_updated_at,
)
from mes_dashboard.services.filter_cache import get_spec_order_mapping
logger = logging.getLogger('mes_dashboard.qc_gate_service')
_DEFAULT_SPEC_ORDER = 999999
_BUCKET_TEMPLATE = {
'lt_6h': 0,
'6h_12h': 0,
'12h_24h': 0,
'gt_24h': 0,
}
def _safe_value(value: Any) -> Any:
"""Normalize pandas NaN/NaT values to None."""
if value is None:
return None
try:
if pd.isna(value):
return None
except Exception:
pass
if hasattr(value, 'item'):
try:
return value.item()
except Exception:
return value
return value
def _safe_int(value: Any, default: int = 0) -> int:
value = _safe_value(value)
if value is None:
return default
try:
return int(value)
except (TypeError, ValueError):
return default
def _safe_float(value: Any) -> Optional[float]:
value = _safe_value(value)
if value is None:
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def _normalize_text(value: Any) -> str:
value = _safe_value(value)
if value is None:
return ''
return str(value).strip()
def _normalize_spec(spec_name: Any) -> str:
return _normalize_text(spec_name).upper()
def _classify_wait_bucket(wait_hours: float) -> str:
if wait_hours < 6:
return 'lt_6h'
if wait_hours < 12:
return '6h_12h'
if wait_hours < 24:
return '12h_24h'
return 'gt_24h'
def _resolve_reference_time(cache_time: Optional[str], df: pd.DataFrame) -> Optional[pd.Timestamp]:
ts = pd.to_datetime(cache_time, errors='coerce')
if pd.notna(ts):
return ts
if 'SYS_DATE' in df.columns:
sys_dates = pd.to_datetime(df['SYS_DATE'], errors='coerce')
if not sys_dates.empty:
max_ts = sys_dates.max()
if pd.notna(max_ts):
return max_ts
return None
def _resolve_move_in_time(row: pd.Series) -> Optional[pd.Timestamp]:
for column in ('MOVEINTIMESTAMP', 'TRACKINTIMESTAMP', 'LOTTRACKINTIME', 'STARTDATE'):
if column not in row.index:
continue
ts = pd.to_datetime(row.get(column), errors='coerce')
if pd.notna(ts):
return ts
return None
def _resolve_wait_hours(row: pd.Series, reference_time: Optional[pd.Timestamp], move_in_time: Optional[pd.Timestamp]) -> float:
if reference_time is not None and move_in_time is not None:
delta = (reference_time - move_in_time).total_seconds() / 3600.0
if delta >= 0:
return float(delta)
age_days = _safe_float(row.get('AGEBYDAYS'))
if age_days is not None and age_days >= 0:
return float(age_days * 24)
return 0.0
def _derive_wip_status(row: pd.Series) -> str:
direct = _normalize_text(row.get('WIP_STATUS') or row.get('STATUS'))
if direct:
return direct.upper()
equipment_count = _safe_int(row.get('EQUIPMENTCOUNT'))
hold_count = _safe_int(row.get('CURRENTHOLDCOUNT'))
if equipment_count > 0:
return 'RUN'
if hold_count > 0:
return 'HOLD'
return 'QUEUE'
def _build_lot_payload(row: pd.Series, reference_time: Optional[pd.Timestamp]) -> Dict[str, Any]:
move_in_time = _resolve_move_in_time(row)
wait_hours = _resolve_wait_hours(row, reference_time, move_in_time)
bucket = _classify_wait_bucket(wait_hours)
move_in_display = None
if move_in_time is not None:
move_in_display = move_in_time.isoformat()
step = _normalize_text(row.get('SPECNAME'))
lot_id = _safe_value(row.get('LOTID') or row.get('CONTAINERNAME'))
container_id = _safe_value(row.get('CONTAINERID') or row.get('CONTAINERNAME') or lot_id)
product = (
_safe_value(row.get('PRODUCT'))
or _safe_value(row.get('PACKAGE_LEF'))
or _safe_value(row.get('PRODUCTLINENAME'))
)
return {
'lot_id': lot_id,
'container_id': container_id,
'product': product,
'qty': _safe_int(row.get('QTY')),
'step': step,
'workorder': _safe_value(row.get('WORKORDER')),
'move_in_time': move_in_display,
'wait_hours': round(wait_hours, 2),
'bucket': bucket,
'status': _derive_wip_status(row),
'equipment': _safe_value(row.get('EQUIPMENTS') or row.get('EQUIPMENTNAME')),
}
def get_qc_gate_summary() -> Optional[Dict[str, Any]]:
"""Get QC-GATE lot summary from Redis-cached WIP snapshot.
Returns:
Dict with cache_time and per-station lot summary, or None on failure.
"""
cache_time = get_cached_sys_date() or get_cache_updated_at()
try:
df = get_cached_wip_data()
if df is None or df.empty or 'SPECNAME' not in df.columns:
return {
'cache_time': cache_time,
'stations': [],
}
spec_series = df['SPECNAME'].fillna('').astype(str).str.upper()
qc_gate_mask = spec_series.str.contains('QC', na=False) & spec_series.str.contains('GATE', na=False)
qc_gate_df = df[qc_gate_mask].copy()
if qc_gate_df.empty:
return {
'cache_time': cache_time,
'stations': [],
}
reference_time = _resolve_reference_time(cache_time, qc_gate_df)
spec_order_mapping = get_spec_order_mapping() or {}
stations_by_spec: Dict[str, Dict[str, Any]] = {}
for _, row in qc_gate_df.iterrows():
spec_name = _normalize_text(row.get('SPECNAME'))
if not spec_name:
continue
normalized_spec = _normalize_spec(spec_name)
spec_order = int(spec_order_mapping.get(normalized_spec, _DEFAULT_SPEC_ORDER))
lot_payload = _build_lot_payload(row, reference_time)
station = stations_by_spec.get(spec_name)
if station is None:
station = {
'specname': spec_name,
'spec_order': spec_order,
'buckets': dict(_BUCKET_TEMPLATE),
'total': 0,
'lots': [],
}
stations_by_spec[spec_name] = station
station['buckets'][lot_payload['bucket']] += 1
station['total'] += 1
station['lots'].append(lot_payload)
stations = list(stations_by_spec.values())
for station in stations:
station['lots'].sort(
key=lambda lot: float(lot.get('wait_hours') or 0),
reverse=True,
)
stations.sort(
key=lambda station: (
int(station.get('spec_order', _DEFAULT_SPEC_ORDER)),
station.get('specname', ''),
)
)
return {
'cache_time': cache_time,
'stations': stations,
}
except Exception as exc:
logger.exception('Failed to build QC-GATE summary: %s', exc)
return None

View File

@@ -0,0 +1,71 @@
# -*- coding: utf-8 -*-
"""Tests for QC-GATE API and page routes."""
from __future__ import annotations
from unittest.mock import patch
import mes_dashboard.core.database as db
from flask import Response
from mes_dashboard.app import create_app
def _client():
db._ENGINE = None
app = create_app('testing')
app.config['TESTING'] = True
return app.test_client()
@patch('mes_dashboard.routes.qc_gate_routes.get_qc_gate_summary')
def test_qc_gate_summary_route_returns_success(mock_get_summary):
mock_get_summary.return_value = {
'cache_time': '2026-02-09T12:00:00',
'stations': [
{
'specname': 'QC-GATE-A',
'spec_order': 10,
'buckets': {
'lt_6h': 1,
'6h_12h': 0,
'12h_24h': 0,
'gt_24h': 0,
},
'total': 1,
'lots': [],
}
],
}
response = _client().get('/api/qc-gate/summary')
payload = response.get_json()
assert response.status_code == 200
assert payload['success'] is True
assert payload['data']['cache_time'] == '2026-02-09T12:00:00'
assert payload['data']['stations'][0]['specname'] == 'QC-GATE-A'
@patch('mes_dashboard.routes.qc_gate_routes.get_qc_gate_summary', return_value=None)
def test_qc_gate_summary_route_returns_500_on_failure(_mock_get_summary):
response = _client().get('/api/qc-gate/summary')
payload = response.get_json()
assert response.status_code == 500
assert payload['success'] is False
assert 'error' in payload
@patch('mes_dashboard.app.send_from_directory')
def test_qc_gate_page_served_from_static_dist(mock_send_from_directory):
mock_send_from_directory.return_value = Response('<html>ok</html>', mimetype='text/html')
response = _client().get('/qc-gate')
assert response.status_code == 200
assert 'text/html' in response.content_type
call_args = mock_send_from_directory.call_args[0]
assert call_args[0].endswith('/static/dist')
assert call_args[1] == 'qc-gate.html'

View File

@@ -0,0 +1,150 @@
# -*- coding: utf-8 -*-
"""Unit tests for QC-GATE summary service."""
from __future__ import annotations
from unittest.mock import patch
import pandas as pd
from mes_dashboard.services.qc_gate_service import get_qc_gate_summary
@patch('mes_dashboard.services.qc_gate_service.get_spec_order_mapping')
@patch('mes_dashboard.services.qc_gate_service.get_cache_updated_at')
@patch('mes_dashboard.services.qc_gate_service.get_cached_sys_date')
@patch('mes_dashboard.services.qc_gate_service.get_cached_wip_data')
def test_get_qc_gate_summary_filters_classifies_and_orders(
mock_get_wip,
mock_get_sys_date,
mock_get_cache_updated_at,
mock_get_spec_order_mapping,
):
mock_get_sys_date.return_value = '2026-02-09T12:00:00'
mock_get_cache_updated_at.return_value = '2026-02-09T12:00:00'
mock_get_spec_order_mapping.return_value = {
'QC-GATE-A': 10,
'QC-GATE-B': 20,
}
mock_get_wip.return_value = pd.DataFrame(
[
{
'LOTID': 'L-001',
'CONTAINERID': 'C-001',
'SPECNAME': 'QC-GATE-B',
'MOVEINTIMESTAMP': '2026-02-09T09:00:00',
'QTY': 100,
'WORKORDER': 'WO-1',
'STATUS': 'QUEUE',
'EQUIPMENTS': None,
},
{
'LOTID': 'L-002',
'CONTAINERID': 'C-002',
'SPECNAME': 'QC-GATE-A',
'MOVEINTIMESTAMP': '2026-02-09T11:00:00',
'QTY': 200,
'WORKORDER': 'WO-2',
'STATUS': 'QUEUE',
'EQUIPMENTS': None,
},
{
'LOTID': 'L-003',
'CONTAINERID': 'C-003',
'SPECNAME': 'QC-GATE-A',
'MOVEINTIMESTAMP': '2026-02-08T08:00:00',
'QTY': 50,
'WORKORDER': 'WO-3',
'STATUS': 'HOLD',
'EQUIPMENTS': None,
},
{
'LOTID': 'L-004',
'CONTAINERID': 'C-004',
'SPECNAME': 'ASSEMBLY-STEP',
'MOVEINTIMESTAMP': '2026-02-09T10:00:00',
'QTY': 25,
'WORKORDER': 'WO-4',
'STATUS': 'QUEUE',
'EQUIPMENTS': None,
},
{
'LOTID': 'L-005',
'CONTAINERID': 'C-005',
'SPECNAME': 'QC-LATE-GATE',
'MOVEINTIMESTAMP': '2026-02-07T06:00:00',
'QTY': 75,
'WORKORDER': 'WO-5',
'STATUS': 'QUEUE',
'EQUIPMENTS': None,
},
]
)
result = get_qc_gate_summary()
assert result is not None
assert result['cache_time'] == '2026-02-09T12:00:00'
stations = result['stations']
assert [station['specname'] for station in stations] == [
'QC-GATE-A',
'QC-GATE-B',
'QC-LATE-GATE',
]
station_a = stations[0]
assert station_a['buckets']['lt_6h'] == 1
assert station_a['buckets']['gt_24h'] == 1
assert station_a['total'] == 2
station_b = stations[1]
assert station_b['buckets']['lt_6h'] == 1
assert station_b['total'] == 1
unknown_station = stations[2]
assert unknown_station['spec_order'] == 999999
assert unknown_station['total'] == 1
@patch('mes_dashboard.services.qc_gate_service.get_spec_order_mapping', return_value={})
@patch('mes_dashboard.services.qc_gate_service.get_cache_updated_at', return_value='2026-02-09T12:00:00')
@patch('mes_dashboard.services.qc_gate_service.get_cached_sys_date', return_value=None)
@patch('mes_dashboard.services.qc_gate_service.get_cached_wip_data')
def test_get_qc_gate_summary_returns_empty_when_no_match(
mock_get_wip,
_mock_get_sys_date,
_mock_get_cache_updated_at,
_mock_get_spec_order_mapping,
):
mock_get_wip.return_value = pd.DataFrame(
[
{
'LOTID': 'L-100',
'SPECNAME': 'ASSEMBLY-STEP',
'MOVEINTIMESTAMP': '2026-02-09T10:00:00',
}
]
)
result = get_qc_gate_summary()
assert result is not None
assert result['cache_time'] == '2026-02-09T12:00:00'
assert result['stations'] == []
@patch('mes_dashboard.services.qc_gate_service.get_cache_updated_at', return_value='2026-02-09T12:00:00')
@patch('mes_dashboard.services.qc_gate_service.get_cached_sys_date', return_value=None)
@patch('mes_dashboard.services.qc_gate_service.get_cached_wip_data', return_value=None)
def test_get_qc_gate_summary_returns_empty_when_cache_missing(
_mock_get_wip,
_mock_get_sys_date,
_mock_get_cache_updated_at,
):
result = get_qc_gate_summary()
assert result is not None
assert result['cache_time'] == '2026-02-09T12:00:00'
assert result['stations'] == []