diff --git a/README.mdj b/README.mdj deleted file mode 100644 index 0332781..0000000 --- a/README.mdj +++ /dev/null @@ -1,153 +0,0 @@ -# MES Dashboard(README.mdj) - -本文件為 `README.md` 的精簡技術同步版,聚焦目前可運行架構與運維契約。 - -## 1. 架構摘要(2026-02-08) - -- 後端:Flask + Gunicorn(單一 port) -- 前端:Vite build 輸出到 `src/mes_dashboard/static/dist` -- 快取:Redis + process-level cache + indexed selection telemetry -- 資料:Oracle(QueuePool) -- 運維: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-fast:conda/runtime path drift 時拒絕啟動並輸出可操作診斷。 -- worker restart policy:cooldown + 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 秒 memo(testing 模式自動關閉)。 -- 高成本 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= -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 hardening(LDAP URL 驗證、bounded LRU cache、circuit breaker 鎖外日誌、安全標頭、分頁邊界)。 -- 2026-02-08:完成 round-3 residual hardening(staged publish、health memo、API rate limit、DB redaction、filter view env 化)。 -- 2026-02-08:完成 round-4 residual consolidation(resource index 表示正規化、shared SQL fragments、型別與常數治理、atomic page status 寫入)。 diff --git a/data/page_status.json b/data/page_status.json index faaae02..b64227b 100644 --- a/data/page_status.json +++ b/data/page_status.json @@ -29,6 +29,13 @@ "drawer_id": "reports", "order": 3 }, + { + "route": "/qc-gate", + "name": "QC-GATE 狀態", + "status": "released", + "drawer_id": "reports", + "order": 4 + }, { "route": "/tables", "name": "表格總覽", @@ -113,4 +120,4 @@ "admin_only": true } ] -} \ No newline at end of file +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b2feced..e78bea0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -7,10 +7,62 @@ "": { "name": "mes-dashboard-frontend", "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": { "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": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -18,7 +70,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -35,7 +86,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -52,7 +102,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -69,7 +118,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -86,7 +134,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -103,7 +150,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -120,7 +166,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -137,7 +182,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -154,7 +198,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -171,7 +214,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -188,7 +230,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -205,7 +246,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -222,7 +262,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -239,7 +278,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -256,7 +294,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -273,7 +310,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -290,7 +326,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -307,7 +342,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -324,7 +358,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -341,7 +374,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -358,7 +390,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -375,7 +406,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -392,7 +422,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -409,7 +438,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -426,7 +454,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -443,7 +470,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -453,6 +479,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": { "version": "4.57.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", @@ -460,7 +498,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -474,7 +511,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -488,7 +524,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -502,7 +537,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -516,7 +550,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -530,7 +563,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -544,7 +576,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -558,7 +589,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -572,7 +602,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -586,7 +615,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -600,7 +628,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -614,7 +641,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -628,7 +654,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -642,7 +667,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -656,7 +680,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -670,7 +693,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -684,7 +706,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -698,7 +719,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -712,7 +732,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -726,7 +745,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -740,7 +758,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -754,7 +771,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -768,7 +784,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -782,7 +797,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -796,7 +810,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -807,14 +820,156 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, "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": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", - "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -852,11 +1007,16 @@ "@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": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -874,7 +1034,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -885,11 +1044,19 @@ "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": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, "funding": [ { "type": "github", @@ -908,16 +1075,13 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/picomatch": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -929,7 +1093,6 @@ "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "dev": true, "funding": [ { "type": "opencollective", @@ -958,7 +1121,6 @@ "version": "4.57.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", - "dev": true, "license": "MIT", "dependencies": { "@types/estree": "1.0.8" @@ -1003,7 +1165,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -1013,7 +1174,6 @@ "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -1026,11 +1186,16 @@ "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": { "version": "6.4.1", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", - "dev": true, "license": "MIT", "dependencies": { "esbuild": "^0.25.0", @@ -1100,6 +1265,46 @@ "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" + } } } } diff --git a/frontend/package.json b/frontend/package.json index c7a63a5..3ccf2fa 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,10 +5,16 @@ "type": "module", "scripts": { "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" }, "devDependencies": { "vite": "^6.3.0" + }, + "dependencies": { + "@vitejs/plugin-vue": "^6.0.4", + "echarts": "^6.0.0", + "vue": "^3.5.27", + "vue-echarts": "^8.0.1" } } diff --git a/frontend/src/qc-gate/App.vue b/frontend/src/qc-gate/App.vue new file mode 100644 index 0000000..f4dbe7d --- /dev/null +++ b/frontend/src/qc-gate/App.vue @@ -0,0 +1,170 @@ + + + diff --git a/frontend/src/qc-gate/components/LotTable.vue b/frontend/src/qc-gate/components/LotTable.vue new file mode 100644 index 0000000..343faa2 --- /dev/null +++ b/frontend/src/qc-gate/components/LotTable.vue @@ -0,0 +1,176 @@ + + + diff --git a/frontend/src/qc-gate/components/QcGateChart.vue b/frontend/src/qc-gate/components/QcGateChart.vue new file mode 100644 index 0000000..2cdd676 --- /dev/null +++ b/frontend/src/qc-gate/components/QcGateChart.vue @@ -0,0 +1,150 @@ + + + diff --git a/frontend/src/qc-gate/composables/useQcGateData.js b/frontend/src/qc-gate/composables/useQcGateData.js new file mode 100644 index 0000000..8a0c7a3 --- /dev/null +++ b/frontend/src/qc-gate/composables/useQcGateData.js @@ -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, + }; +} diff --git a/frontend/src/qc-gate/index.html b/frontend/src/qc-gate/index.html new file mode 100644 index 0000000..5ce4393 --- /dev/null +++ b/frontend/src/qc-gate/index.html @@ -0,0 +1,12 @@ + + + + + + QC-GATE 狀態 + + +
+ + + diff --git a/frontend/src/qc-gate/main.js b/frontend/src/qc-gate/main.js new file mode 100644 index 0000000..3cb2340 --- /dev/null +++ b/frontend/src/qc-gate/main.js @@ -0,0 +1,6 @@ +import { createApp } from 'vue'; + +import App from './App.vue'; +import './style.css'; + +createApp(App).mount('#app'); diff --git a/frontend/src/qc-gate/style.css b/frontend/src/qc-gate/style.css new file mode 100644 index 0000000..2ed5c6a --- /dev/null +++ b/frontend/src/qc-gate/style.css @@ -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; + } +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js index ca431a4..caceb33 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -1,7 +1,10 @@ import { defineConfig } from 'vite'; import { resolve } from 'node:path'; +import vue from '@vitejs/plugin-vue'; export default defineConfig(({ mode }) => ({ + base: '/static/dist/', + plugins: [vue()], publicDir: false, build: { outDir: '../src/mes_dashboard/static/dist', @@ -19,19 +22,31 @@ export default defineConfig(({ mode }) => ({ 'excel-query': resolve(__dirname, 'src/excel-query/main.js'), tables: resolve(__dirname, 'src/tables/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: { entryFileNames: '[name].js', chunkFileNames: 'chunks/[name]-[hash].js', assetFileNames: '[name][extname]', manualChunks(id) { - if (!id.includes('node_modules')) { + const normalizedId = id.replace(/\\/g, '/'); + if (!normalizedId.includes('node_modules')) { 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'; } + if ( + normalizedId.includes('/node_modules/vue/') || + normalizedId.includes('/node_modules/@vue/') + ) { + return 'vendor-vue'; + } return 'vendor'; } } diff --git a/frontend_design/Hold_detail.pen b/frontend_design/Hold_detail.pen deleted file mode 100644 index 541f2ff..0000000 --- a/frontend_design/Hold_detail.pen +++ /dev/null @@ -1,2182 +0,0 @@ -{ - "version": "2.6", - "children": [ - { - "type": "frame", - "id": "bi8Au", - "x": 0, - "y": 0, - "name": "Frame", - "clip": true, - "width": 800, - "height": 600, - "fill": "#FFFFFF", - "layout": "none" - }, - { - "type": "frame", - "id": "7V3YX", - "x": 0, - "y": 0, - "name": "Hold Detail Page", - "width": 1400, - "fill": "#F5F7FA", - "layout": "vertical", - "gap": 16, - "padding": 20, - "children": [ - { - "type": "frame", - "id": "I5lpc", - "name": "header", - "width": "fill_container", - "fill": { - "type": "gradient", - "gradientType": "linear", - "enabled": true, - "rotation": 135, - "size": { - "height": 1 - }, - "colors": [ - { - "color": "#667eea", - "position": 0 - }, - { - "color": "#764ba2", - "position": 1 - } - ] - }, - "cornerRadius": 10, - "padding": [ - 18, - 22 - ], - "justifyContent": "space_between", - "alignItems": "center", - "children": [ - { - "type": "frame", - "id": "EdWXi", - "name": "headerLeft", - "gap": 12, - "alignItems": "center", - "children": [ - { - "type": "frame", - "id": "MVVGK", - "name": "backBtn", - "width": 36, - "height": 36, - "fill": "rgba(255,255,255,0.2)", - "cornerRadius": 8, - "justifyContent": "center", - "alignItems": "center", - "children": [ - { - "type": "icon_font", - "id": "ZJEO4", - "name": "backIcon", - "width": 20, - "height": 20, - "iconFontName": "arrow-left", - "iconFontFamily": "lucide", - "fill": "#FFFFFF" - } - ] - }, - { - "type": "frame", - "id": "8YBhs", - "name": "titleGroup", - "layout": "vertical", - "gap": 4, - "children": [ - { - "type": "text", - "id": "W3jyy", - "name": "pageTitle", - "fill": "#FFFFFF", - "content": "Hold Detail: 缺陷", - "fontFamily": "Inter", - "fontSize": 22, - "fontWeight": "600" - }, - { - "type": "frame", - "id": "V3p7U", - "name": "holdBadge", - "fill": "#FEE2E2", - "cornerRadius": 4, - "gap": 6, - "padding": [ - 4, - 8 - ], - "alignItems": "center", - "children": [ - { - "type": "text", - "id": "6Mvg0", - "name": "badgeText", - "fill": "#991B1B", - "content": "品質異常 Hold", - "fontFamily": "Inter", - "fontSize": 12, - "fontWeight": "600" - } - ] - } - ] - } - ] - }, - { - "type": "frame", - "id": "JpXu7", - "name": "headerRight", - "gap": 12, - "alignItems": "center", - "children": [ - { - "type": "text", - "id": "Bg1BI", - "name": "lastUpdate", - "fill": "rgba(255,255,255,0.8)", - "content": "Last Update: 2026-01-28 10:30:00", - "fontFamily": "Inter", - "fontSize": 13, - "fontWeight": "normal" - } - ] - } - ] - }, - { - "type": "frame", - "id": "dH5jc", - "name": "summaryRow", - "width": "fill_container", - "gap": 16, - "children": [ - { - "type": "frame", - "id": "Jcuku", - "name": "card1", - "width": "fill_container", - "fill": "#FFFFFF", - "cornerRadius": 10, - "stroke": { - "align": "inside", - "thickness": 1, - "fill": "#E2E6EF" - }, - "layout": "vertical", - "gap": 8, - "padding": 20, - "children": [ - { - "type": "text", - "id": "NO3qg", - "name": "card1Label", - "fill": "#666666", - "content": "Total Lots", - "fontFamily": "Inter", - "fontSize": 13, - "fontWeight": "normal" - }, - { - "type": "text", - "id": "S6x9b", - "name": "card1Value", - "fill": "#222222", - "content": "127", - "fontFamily": "Inter", - "fontSize": 32, - "fontWeight": "700" - } - ] - }, - { - "type": "frame", - "id": "k5Fqe", - "name": "card2", - "width": "fill_container", - "fill": "#FFFFFF", - "cornerRadius": 10, - "stroke": { - "align": "inside", - "thickness": 1, - "fill": "#E2E6EF" - }, - "layout": "vertical", - "gap": 8, - "padding": 20, - "children": [ - { - "type": "text", - "id": "OzCf3", - "name": "card2Label", - "fill": "#666666", - "content": "Total QTY (pcs)", - "fontFamily": "Inter", - "fontSize": 13, - "fontWeight": "normal" - }, - { - "type": "text", - "id": "wrtwd", - "name": "card2Value", - "fill": "#222222", - "content": "458,920", - "fontFamily": "Inter", - "fontSize": 32, - "fontWeight": "700" - } - ] - }, - { - "type": "frame", - "id": "tGRHt", - "name": "card3", - "width": "fill_container", - "fill": "#FFFFFF", - "cornerRadius": 10, - "stroke": { - "align": "inside", - "thickness": 1, - "fill": "#E2E6EF" - }, - "layout": "vertical", - "gap": 8, - "padding": 20, - "children": [ - { - "type": "text", - "id": "WbSE9", - "name": "card3Label", - "fill": "#666666", - "content": "平均當站滯留", - "fontFamily": "Inter", - "fontSize": 13, - "fontWeight": "normal" - }, - { - "type": "text", - "id": "wUiN4", - "name": "card3Value", - "fill": "#F59E0B", - "content": "3.2 天", - "fontFamily": "Inter", - "fontSize": 32, - "fontWeight": "700" - } - ] - }, - { - "type": "frame", - "id": "BlhLH", - "name": "card4", - "width": "fill_container", - "fill": "#FFFFFF", - "cornerRadius": 10, - "stroke": { - "align": "inside", - "thickness": 1, - "fill": "#E2E6EF" - }, - "layout": "vertical", - "gap": 8, - "padding": 20, - "children": [ - { - "type": "text", - "id": "CLGML", - "name": "card4Label", - "fill": "#666666", - "content": "最久當站滯留", - "fontFamily": "Inter", - "fontSize": 13, - "fontWeight": "normal" - }, - { - "type": "text", - "id": "aGrnM", - "name": "card4Value", - "fill": "#EF4444", - "content": "15 天", - "fontFamily": "Inter", - "fontSize": 32, - "fontWeight": "700" - } - ] - }, - { - "type": "frame", - "id": "7uKhR", - "name": "card5", - "width": "fill_container", - "fill": "#FFFFFF", - "cornerRadius": 10, - "stroke": { - "align": "inside", - "thickness": 1, - "fill": "#E2E6EF" - }, - "layout": "vertical", - "gap": 8, - "padding": 20, - "children": [ - { - "type": "text", - "id": "P1gHF", - "name": "card5Label", - "fill": "#666666", - "content": "影響站群", - "fontFamily": "Inter", - "fontSize": 13, - "fontWeight": "normal" - }, - { - "type": "text", - "id": "EoKkT", - "name": "card5Value", - "fill": "#222222", - "content": "8", - "fontFamily": "Inter", - "fontSize": 32, - "fontWeight": "700" - } - ] - } - ] - }, - { - "type": "frame", - "id": "qYy5R", - "name": "contentGrid", - "width": "fill_container", - "gap": 16, - "children": [ - { - "type": "frame", - "id": "n6o9k", - "name": "leftCard", - "width": "fill_container", - "fill": "#FFFFFF", - "cornerRadius": 10, - "stroke": { - "align": "inside", - "thickness": 1, - "fill": "#E2E6EF" - }, - "layout": "vertical", - "children": [ - { - "type": "frame", - "id": "TQjuU", - "name": "leftHeader", - "width": "fill_container", - "stroke": { - "align": "inside", - "thickness": { - "bottom": 1 - }, - "fill": "#E2E6EF" - }, - "padding": [ - 16, - 20 - ], - "children": [ - { - "type": "text", - "id": "9ZCN9", - "name": "leftTitle", - "fill": "#222222", - "content": "依站群分佈 (By Workcenter) - 點擊可篩選", - "fontFamily": "Inter", - "fontSize": 16, - "fontWeight": "600" - }, - { - "type": "frame", - "id": "sRUro", - "name": "clickHint1", - "fill": "#667eea", - "cornerRadius": 4, - "padding": [ - 4, - 8 - ], - "children": [ - { - "type": "text", - "id": "Er9HP", - "name": "clickText1", - "fill": "#FFFFFF", - "content": "可點擊篩選", - "fontFamily": "Inter", - "fontSize": 10, - "fontWeight": "600" - } - ] - } - ] - }, - { - "type": "frame", - "id": "6dcCv", - "name": "leftBody", - "width": "fill_container", - "layout": "vertical", - "children": [ - { - "type": "frame", - "id": "KJxxR", - "name": "tableHeader", - "width": "fill_container", - "fill": "#F9FAFB", - "gap": 16, - "padding": [ - 12, - 20 - ], - "children": [ - { - "type": "text", - "id": "pl1PL", - "name": "th1", - "fill": "#666666", - "content": "Workcenter", - "fontFamily": "Inter", - "fontSize": 12, - "fontWeight": "600" - }, - { - "type": "text", - "id": "0Nler", - "name": "th2", - "fill": "#666666", - "content": "Lots", - "textAlign": "right", - "fontFamily": "Inter", - "fontSize": 12, - "fontWeight": "600" - }, - { - "type": "text", - "id": "v8SQy", - "name": "th3", - "fill": "#666666", - "content": "QTY", - "textAlign": "right", - "fontFamily": "Inter", - "fontSize": 12, - "fontWeight": "600" - }, - { - "type": "text", - "id": "hgtG9", - "name": "th4", - "fill": "#666666", - "content": "佔比", - "textAlign": "right", - "fontFamily": "Inter", - "fontSize": 12, - "fontWeight": "600" - } - ] - }, - { - "type": "frame", - "id": "FeNSs", - "name": "row1", - "width": "fill_container", - "fill": "#EEF2FF", - "stroke": { - "align": "inside", - "thickness": 2, - "fill": "#667eea" - }, - "gap": 16, - "padding": [ - 12, - 20 - ], - "alignItems": "center", - "children": [ - { - "type": "text", - "id": "pzbjE", - "name": "r1c1", - "fill": "#222222", - "content": "DIE_BOND", - "fontFamily": "Inter", - "fontSize": 14, - "fontWeight": "normal" - }, - { - "type": "text", - "id": "ufZSd", - "name": "r1c2", - "fill": "#222222", - "content": "45", - "textAlign": "right", - "fontFamily": "Inter", - "fontSize": 14, - "fontWeight": "normal" - }, - { - "type": "text", - "id": "sNXbx", - "name": "r1c3", - "fill": "#222222", - "content": "156,230", - "textAlign": "right", - "fontFamily": "Inter", - "fontSize": 14, - "fontWeight": "normal" - }, - { - "type": "text", - "id": "R0Zvo", - "name": "r1c4", - "fill": "#667eea", - "content": "34.1%", - "textAlign": "right", - "fontFamily": "Inter", - "fontSize": 14, - "fontWeight": "600" - } - ] - }, - { - "type": "frame", - "id": "QcUcl", - "name": "row2", - "width": "fill_container", - "stroke": { - "align": "inside", - "thickness": { - "bottom": 1 - }, - "fill": "#F0F0F0" - }, - "gap": 16, - "padding": [ - 12, - 20 - ], - "alignItems": "center", - "children": [ - { - "type": "text", - "id": "pnDQb", - "name": "r2c1", - "fill": "#222222", - "content": "WIRE_BOND", - "fontFamily": "Inter", - "fontSize": 14, - "fontWeight": "normal" - }, - { - "type": "text", - "id": "8BYJg", - "name": "r2c2", - "fill": "#222222", - "content": "38", - "textAlign": "right", - "fontFamily": "Inter", - "fontSize": 14, - "fontWeight": "normal" - }, - { - "type": "text", - "id": "0ItAD", - "name": "r2c3", - "fill": "#222222", - "content": "128,450", - "textAlign": "right", - "fontFamily": "Inter", - "fontSize": 14, - "fontWeight": "normal" - }, - { - "type": "text", - "id": "EjrRN", - "name": "r2c4", - "fill": "#667eea", - "content": "28.0%", - "textAlign": "right", - "fontFamily": "Inter", - "fontSize": 14, - "fontWeight": "600" - } - ] - }, - { - "type": "frame", - "id": "0E6IL", - "name": "row3", - "width": "fill_container", - "stroke": { - "align": "inside", - "thickness": { - "bottom": 1 - }, - "fill": "#F0F0F0" - }, - "gap": 16, - "padding": [ - 12, - 20 - ], - "alignItems": "center", - "children": [ - { - "type": "text", - "id": "BuPpm", - "name": "r3c1", - "fill": "#222222", - "content": "MOLDING", - "fontFamily": "Inter", - "fontSize": 14, - "fontWeight": "normal" - }, - { - "type": "text", - "id": "EePKK", - "name": "r3c2", - "fill": "#222222", - "content": "28", - "textAlign": "right", - "fontFamily": "Inter", - "fontSize": 14, - "fontWeight": "normal" - }, - { - "type": "text", - "id": "2F2i9", - "name": "r3c3", - "fill": "#222222", - "content": "98,120", - "textAlign": "right", - "fontFamily": "Inter", - "fontSize": 14, - "fontWeight": "normal" - }, - { - "type": "text", - "id": "mBXbQ", - "name": "r3c4", - "fill": "#667eea", - "content": "21.4%", - "textAlign": "right", - "fontFamily": "Inter", - "fontSize": 14, - "fontWeight": "600" - } - ] - }, - { - "type": "frame", - "id": "XoxQ0", - "name": "row4", - "width": "fill_container", - "gap": 16, - "padding": [ - 12, - 20 - ], - "alignItems": "center", - "children": [ - { - "type": "text", - "id": "jiGXt", - "name": "r4c1", - "fill": "#888888", - "content": "Others (5)", - "fontFamily": "Inter", - "fontSize": 14, - "fontWeight": "normal" - }, - { - "type": "text", - "id": "RJDFw", - "name": "r4c2", - "fill": "#888888", - "content": "16", - "textAlign": "right", - "fontFamily": "Inter", - "fontSize": 14, - "fontWeight": "normal" - }, - { - "type": "text", - "id": "TO1my", - "name": "r4c3", - "fill": "#888888", - "content": "76,120", - "textAlign": "right", - "fontFamily": "Inter", - "fontSize": 14, - "fontWeight": "normal" - }, - { - "type": "text", - "id": "vTEIy", - "name": "r4c4", - "fill": "#888888", - "content": "16.5%", - "textAlign": "right", - "fontFamily": "Inter", - "fontSize": 14, - "fontWeight": "normal" - } - ] - } - ] - } - ] - }, - { - "type": "frame", - "id": "jJRVr", - "name": "rightCard", - "width": "fill_container", - "fill": "#FFFFFF", - "cornerRadius": 10, - "stroke": { - "align": "inside", - "thickness": 1, - "fill": "#E2E6EF" - }, - "layout": "vertical", - "children": [ - { - "type": "frame", - "id": "D431a", - "name": "rightHeader", - "width": "fill_container", - "stroke": { - "align": "inside", - "thickness": { - "bottom": 1 - }, - "fill": "#E2E6EF" - }, - "padding": [ - 16, - 20 - ], - "children": [ - { - "type": "text", - "id": "wbKWf", - "name": "rightTitle", - "fill": "#222222", - "content": "依 Package 分佈 - 點擊可篩選", - "fontFamily": "Inter", - "fontSize": 16, - "fontWeight": "600" - }, - { - "type": "frame", - "id": "Pq3Qm", - "name": "clickHint2", - "fill": "#667eea", - "cornerRadius": 4, - "padding": [ - 4, - 8 - ], - "children": [ - { - "type": "text", - "id": "vMiTb", - "name": "clickText2", - "fill": "#FFFFFF", - "content": "可點擊篩選", - "fontFamily": "Inter", - "fontSize": 10, - "fontWeight": "600" - } - ] - } - ] - }, - { - "type": "frame", - "id": "crSW4", - "name": "rightBody", - "width": "fill_container", - "layout": "vertical", - "children": [ - { - "type": "frame", - "id": "CAnxl", - "name": "pTableHeader", - "width": "fill_container", - "fill": "#F9FAFB", - "gap": 16, - "padding": [ - 12, - 20 - ], - "children": [ - { - "type": "text", - "id": "hbJ0a", - "name": "pth1", - "fill": "#666666", - "content": "Package", - "fontFamily": "Inter", - "fontSize": 12, - "fontWeight": "600" - }, - { - "type": "text", - "id": "AjQJS", - "name": "pth2", - "fill": "#666666", - "content": "Lots", - "textAlign": "right", - "fontFamily": "Inter", - "fontSize": 12, - "fontWeight": "600" - }, - { - "type": "text", - "id": "JYohj", - "name": "pth3", - "fill": "#666666", - "content": "QTY", - "textAlign": "right", - "fontFamily": "Inter", - "fontSize": 12, - "fontWeight": "600" - }, - { - "type": "text", - "id": "MV6jF", - "name": "pth4", - "fill": "#666666", - "content": "佔比", - "textAlign": "right", - "fontFamily": "Inter", - "fontSize": 12, - "fontWeight": "600" - } - ] - }, - { - "type": "frame", - "id": "j9Ycq", - "name": "prow1", - "width": "fill_container", - "stroke": { - "align": "inside", - "thickness": { - "bottom": 1 - }, - "fill": "#F0F0F0" - }, - "gap": 16, - "padding": [ - 12, - 20 - ], - "alignItems": "center", - "children": [ - { - "type": "text", - "id": "5PO2Y", - "name": "pr1c1", - "fill": "#222222", - "content": "QFN", - "fontFamily": "Inter", - "fontSize": 14, - "fontWeight": "normal" - }, - { - "type": "text", - "id": "1CgoT", - "name": "pr1c2", - "fill": "#222222", - "content": "52", - "textAlign": "right", - "fontFamily": "Inter", - "fontSize": 14, - "fontWeight": "normal" - }, - { - "type": "text", - "id": "uQB2r", - "name": "pr1c3", - "fill": "#222222", - "content": "189,450", - "textAlign": "right", - "fontFamily": "Inter", - "fontSize": 14, - "fontWeight": "normal" - }, - { - "type": "text", - "id": "wY306", - "name": "pr1c4", - "fill": "#667eea", - "content": "41.3%", - "textAlign": "right", - "fontFamily": "Inter", - "fontSize": 14, - "fontWeight": "600" - } - ] - }, - { - "type": "frame", - "id": "qI6Ae", - "name": "prow2", - "width": "fill_container", - "stroke": { - "align": "inside", - "thickness": { - "bottom": 1 - }, - "fill": "#F0F0F0" - }, - "gap": 16, - "padding": [ - 12, - 20 - ], - "alignItems": "center", - "children": [ - { - "type": "text", - "id": "E02Zh", - "name": "pr2c1", - "fill": "#222222", - "content": "DFN", - "fontFamily": "Inter", - "fontSize": 14, - "fontWeight": "normal" - }, - { - "type": "text", - "id": "hFdSa", - "name": "pr2c2", - "fill": "#222222", - "content": "35", - "textAlign": "right", - "fontFamily": "Inter", - "fontSize": 14, - "fontWeight": "normal" - }, - { - "type": "text", - "id": "ZegQX", - "name": "pr2c3", - "fill": "#222222", - "content": "145,230", - "textAlign": "right", - "fontFamily": "Inter", - "fontSize": 14, - "fontWeight": "normal" - }, - { - "type": "text", - "id": "pQQf8", - "name": "pr2c4", - "fill": "#667eea", - "content": "31.6%", - "textAlign": "right", - "fontFamily": "Inter", - "fontSize": 14, - "fontWeight": "600" - } - ] - }, - { - "type": "frame", - "id": "3zbTw", - "name": "prow3", - "width": "fill_container", - "stroke": { - "align": "inside", - "thickness": { - "bottom": 1 - }, - "fill": "#F0F0F0" - }, - "gap": 16, - "padding": [ - 12, - 20 - ], - "alignItems": "center", - "children": [ - { - "type": "text", - "id": "eago9", - "name": "pr3c1", - "fill": "#222222", - "content": "SOT", - "fontFamily": "Inter", - "fontSize": 14, - "fontWeight": "normal" - }, - { - "type": "text", - "id": "h4G0R", - "name": "pr3c2", - "fill": "#222222", - "content": "22", - "textAlign": "right", - "fontFamily": "Inter", - "fontSize": 14, - "fontWeight": "normal" - }, - { - "type": "text", - "id": "mwqGr", - "name": "pr3c3", - "fill": "#222222", - "content": "78,120", - "textAlign": "right", - "fontFamily": "Inter", - "fontSize": 14, - "fontWeight": "normal" - }, - { - "type": "text", - "id": "3sOgp", - "name": "pr3c4", - "fill": "#667eea", - "content": "17.0%", - "textAlign": "right", - "fontFamily": "Inter", - "fontSize": 14, - "fontWeight": "600" - } - ] - }, - { - "type": "frame", - "id": "lDXi1", - "name": "prow4", - "width": "fill_container", - "gap": 16, - "padding": [ - 12, - 20 - ], - "alignItems": "center", - "children": [ - { - "type": "text", - "id": "m8vMX", - "name": "pr4c1", - "fill": "#888888", - "content": "Others (4)", - "fontFamily": "Inter", - "fontSize": 14, - "fontWeight": "normal" - }, - { - "type": "text", - "id": "HNQIl", - "name": "pr4c2", - "fill": "#888888", - "content": "18", - "textAlign": "right", - "fontFamily": "Inter", - "fontSize": 14, - "fontWeight": "normal" - }, - { - "type": "text", - "id": "uLIWM", - "name": "pr4c3", - "fill": "#888888", - "content": "46,120", - "textAlign": "right", - "fontFamily": "Inter", - "fontSize": 14, - "fontWeight": "normal" - }, - { - "type": "text", - "id": "R4W2c", - "name": "pr4c4", - "fill": "#888888", - "content": "10.1%", - "textAlign": "right", - "fontFamily": "Inter", - "fontSize": 14, - "fontWeight": "normal" - } - ] - } - ] - } - ] - } - ] - }, - { - "type": "frame", - "id": "IRtLq", - "name": "ageSection", - "width": "fill_container", - "fill": "#FFFFFF", - "cornerRadius": 10, - "stroke": { - "align": "inside", - "thickness": 1, - "fill": "#E2E6EF" - }, - "layout": "vertical", - "children": [ - { - "type": "frame", - "id": "QczQu", - "name": "ageHeader", - "width": "fill_container", - "stroke": { - "align": "inside", - "thickness": { - "bottom": 1 - }, - "fill": "#E2E6EF" - }, - "padding": [ - 16, - 20 - ], - "justifyContent": "space_between", - "alignItems": "center", - "children": [ - { - "type": "text", - "id": "rjWxw", - "name": "ageTitle", - "fill": "#222222", - "content": "當站滯留天數分佈 (Age at Current Station)", - "fontFamily": "Inter", - "fontSize": 16, - "fontWeight": "600" - }, - { - "type": "text", - "id": "o8zso", - "name": "ageNote", - "fill": "#888888", - "content": "依 MOVEIN 時間計算 | 點擊可篩選", - "fontFamily": "Inter", - "fontSize": 12, - "fontWeight": "normal" - } - ] - }, - { - "type": "frame", - "id": "1TyeC", - "name": "ageBody", - "width": "fill_container", - "gap": 16, - "padding": 20, - "justifyContent": "space_between", - "children": [ - { - "type": "frame", - "id": "llWVM", - "name": "bucket1", - "width": "fill_container", - "fill": "#F0FDF4", - "cornerRadius": 8, - "stroke": { - "align": "inside", - "thickness": 2, - "fill": "#22C55E" - }, - "layout": "vertical", - "gap": 8, - "padding": 16, - "alignItems": "center", - "children": [ - { - "type": "text", - "id": "NQoCS", - "name": "b1Label", - "fill": "#166534", - "content": "0-1 天", - "fontFamily": "Inter", - "fontSize": 13, - "fontWeight": "600" - }, - { - "type": "text", - "id": "ovS4b", - "name": "b1Value", - "fill": "#166534", - "content": "42 lots", - "fontFamily": "Inter", - "fontSize": 24, - "fontWeight": "700" - }, - { - "type": "text", - "id": "DWt6p", - "name": "b1Pct", - "fill": "#22C55E", - "content": "33.1%", - "fontFamily": "Inter", - "fontSize": 14, - "fontWeight": "normal" - }, - { - "type": "text", - "id": "vgoxY", - "name": "b1Qty", - "fill": "#166534", - "content": "152,300", - "fontFamily": "Inter", - "fontSize": 13, - "fontWeight": "normal" - } - ] - }, - { - "type": "frame", - "id": "ndAGZ", - "name": "bucket2", - "width": "fill_container", - "fill": "#FFFBEB", - "cornerRadius": 8, - "stroke": { - "align": "inside", - "thickness": 2, - "fill": "#F59E0B" - }, - "layout": "vertical", - "gap": 8, - "padding": 16, - "alignItems": "center", - "children": [ - { - "type": "text", - "id": "icdfd", - "name": "b2Label", - "fill": "#92400E", - "content": "1-3 天", - "fontFamily": "Inter", - "fontSize": 13, - "fontWeight": "600" - }, - { - "type": "text", - "id": "Rvns8", - "name": "b2Value", - "fill": "#92400E", - "content": "38 lots", - "fontFamily": "Inter", - "fontSize": 24, - "fontWeight": "700" - }, - { - "type": "text", - "id": "TuQKb", - "name": "b2Pct", - "fill": "#F59E0B", - "content": "29.9%", - "fontFamily": "Inter", - "fontSize": 14, - "fontWeight": "normal" - }, - { - "type": "text", - "id": "kEr4r", - "name": "b2Qty", - "fill": "#92400E", - "content": "138,450", - "fontFamily": "Inter", - "fontSize": 13, - "fontWeight": "normal" - } - ] - }, - { - "type": "frame", - "id": "gy2x0", - "name": "bucket3", - "width": "fill_container", - "fill": "#FFF7ED", - "cornerRadius": 8, - "stroke": { - "align": "inside", - "thickness": 2, - "fill": "#F97316" - }, - "layout": "vertical", - "gap": 8, - "padding": 16, - "alignItems": "center", - "children": [ - { - "type": "text", - "id": "B98JF", - "name": "b3Label", - "fill": "#9A3412", - "content": "3-7 天", - "fontFamily": "Inter", - "fontSize": 13, - "fontWeight": "600" - }, - { - "type": "text", - "id": "TdRbd", - "name": "b3Value", - "fill": "#9A3412", - "content": "28 lots", - "fontFamily": "Inter", - "fontSize": 24, - "fontWeight": "700" - }, - { - "type": "text", - "id": "KF1LK", - "name": "b3Pct", - "fill": "#F97316", - "content": "22.0%", - "fontFamily": "Inter", - "fontSize": 14, - "fontWeight": "normal" - }, - { - "type": "text", - "id": "IcmHX", - "name": "b3Qty", - "fill": "#9A3412", - "content": "98,120", - "fontFamily": "Inter", - "fontSize": 13, - "fontWeight": "normal" - } - ] - }, - { - "type": "frame", - "id": "iDYN9", - "name": "bucket4", - "width": "fill_container", - "fill": "#FEF2F2", - "cornerRadius": 8, - "stroke": { - "align": "inside", - "thickness": 2, - "fill": "#EF4444" - }, - "layout": "vertical", - "gap": 8, - "padding": 16, - "alignItems": "center", - "children": [ - { - "type": "text", - "id": "Peb5J", - "name": "b4Label", - "fill": "#991B1B", - "content": "7+ 天", - "fontFamily": "Inter", - "fontSize": 13, - "fontWeight": "600" - }, - { - "type": "text", - "id": "ydAd9", - "name": "b4Value", - "fill": "#991B1B", - "content": "19 lots", - "fontFamily": "Inter", - "fontSize": 24, - "fontWeight": "700" - }, - { - "type": "text", - "id": "DVUNT", - "name": "b4Pct", - "fill": "#EF4444", - "content": "15.0%", - "fontFamily": "Inter", - "fontSize": 14, - "fontWeight": "normal" - }, - { - "type": "text", - "id": "r1aMo", - "name": "b4Qty", - "fill": "#991B1B", - "content": "70,050", - "fontFamily": "Inter", - "fontSize": 13, - "fontWeight": "normal" - } - ] - } - ] - } - ] - }, - { - "type": "frame", - "id": "EkLV0", - "name": "lotSection", - "width": "fill_container", - "fill": "#FFFFFF", - "cornerRadius": 10, - "stroke": { - "align": "inside", - "thickness": 1, - "fill": "#E2E6EF" - }, - "layout": "vertical", - "children": [ - { - "type": "frame", - "id": "6Tg6Z", - "name": "lotHeader", - "width": "fill_container", - "stroke": { - "align": "inside", - "thickness": { - "bottom": 1 - }, - "fill": "#E2E6EF" - }, - "padding": [ - 16, - 20 - ], - "justifyContent": "space_between", - "alignItems": "center", - "children": [ - { - "type": "frame", - "id": "hlYwY", - "name": "lotTitleGroup", - "gap": 12, - "alignItems": "center", - "children": [ - { - "type": "text", - "id": "UvnOY", - "name": "lotTitle", - "fill": "#222222", - "content": "Lot Details", - "fontFamily": "Inter", - "fontSize": 16, - "fontWeight": "600" - }, - { - "type": "frame", - "id": "54DK5", - "name": "lotCount", - "fill": "#667eea", - "cornerRadius": 12, - "padding": [ - 4, - 10 - ], - "children": [ - { - "type": "text", - "id": "65lwE", - "name": "lotCountText", - "fill": "#FFFFFF", - "content": "127 lots", - "fontFamily": "Inter", - "fontSize": 12, - "fontWeight": "600" - } - ] - }, - { - "type": "frame", - "id": "OCvgO", - "name": "filterIndicator", - "fill": "#F0FDF4", - "cornerRadius": 12, - "stroke": { - "align": "inside", - "thickness": 1, - "fill": "#22C55E" - }, - "padding": [ - 4, - 10 - ], - "children": [ - { - "type": "text", - "id": "dYBGb", - "name": "filterText", - "fill": "#166534", - "content": "篩選: DIE_BOND", - "fontFamily": "Inter", - "fontSize": 11, - "fontWeight": "600" - } - ] - }, - { - "type": "frame", - "id": "hVBnP", - "name": "clearBtn", - "fill": "#FEF2F2", - "cornerRadius": 12, - "stroke": { - "align": "inside", - "thickness": 1, - "fill": "#EF4444" - }, - "padding": [ - 4, - 10 - ], - "children": [ - { - "type": "text", - "id": "Upvp6", - "name": "clearText", - "fill": "#991B1B", - "content": "✕ 清除篩選", - "fontFamily": "Inter", - "fontSize": 11, - "fontWeight": "600" - } - ] - } - ] - }, - { - "type": "text", - "id": "UAIbQ", - "name": "lotInfo", - "fill": "#888888", - "content": "依滯留天數排序 (最久優先)", - "fontFamily": "Inter", - "fontSize": 12, - "fontWeight": "normal" - } - ] - }, - { - "type": "frame", - "id": "k2PhE", - "name": "lotBody", - "width": "fill_container", - "layout": "vertical", - "children": [ - { - "type": "frame", - "id": "FYGFO", - "name": "lotTableHeader", - "width": "fill_container", - "fill": "#F9FAFB", - "gap": 8, - "padding": [ - 12, - 20 - ], - "children": [ - { - "type": "text", - "id": "LLmJp", - "name": "lth1", - "fill": "#666666", - "content": "LOTID", - "fontFamily": "Inter", - "fontSize": 12, - "fontWeight": "600" - }, - { - "type": "text", - "id": "at5RZ", - "name": "lth2", - "fill": "#666666", - "content": "WORKORDER", - "fontFamily": "Inter", - "fontSize": 12, - "fontWeight": "600" - }, - { - "type": "text", - "id": "ujPfX", - "name": "lth3", - "fill": "#666666", - "content": "QTY", - "fontFamily": "Inter", - "fontSize": 12, - "fontWeight": "600" - }, - { - "type": "text", - "id": "UoBHt", - "name": "lth4", - "fill": "#666666", - "content": "Package", - "fontFamily": "Inter", - "fontSize": 12, - "fontWeight": "600" - }, - { - "type": "text", - "id": "zV5Ma", - "name": "lth5", - "fill": "#666666", - "content": "Workcenter", - "fontFamily": "Inter", - "fontSize": 12, - "fontWeight": "600" - }, - { - "type": "text", - "id": "ArmE7", - "name": "lth6", - "fill": "#666666", - "content": "Spec", - "fontFamily": "Inter", - "fontSize": 12, - "fontWeight": "600" - }, - { - "type": "text", - "id": "jgkw4", - "name": "lth7", - "fill": "#666666", - "content": "Age", - "fontFamily": "Inter", - "fontSize": 12, - "fontWeight": "600" - }, - { - "type": "text", - "id": "IZD0x", - "name": "lth8", - "fill": "#666666", - "content": "Hold By", - "fontFamily": "Inter", - "fontSize": 12, - "fontWeight": "600" - }, - { - "type": "text", - "id": "wk2g6", - "name": "lth9", - "fill": "#666666", - "content": "Dept", - "fontFamily": "Inter", - "fontSize": 12, - "fontWeight": "600" - } - ] - }, - { - "type": "frame", - "id": "U7daK", - "name": "lotRow1", - "width": "fill_container", - "stroke": { - "align": "inside", - "thickness": { - "bottom": 1 - }, - "fill": "#F0F0F0" - }, - "gap": 8, - "padding": [ - 12, - 20 - ], - "alignItems": "center", - "children": [ - { - "type": "text", - "id": "t3ceS", - "name": "lr1c1", - "fill": "#222222", - "content": "LOT2401150001", - "fontFamily": "Inter", - "fontSize": 13, - "fontWeight": "normal" - }, - { - "type": "text", - "id": "LV0bf", - "name": "lr1c2", - "fill": "#222222", - "content": "WO20240115001", - "fontFamily": "Inter", - "fontSize": 13, - "fontWeight": "normal" - }, - { - "type": "text", - "id": "KPyX9", - "name": "lr1c3", - "fill": "#222222", - "content": "5,200", - "fontFamily": "Inter", - "fontSize": 13, - "fontWeight": "normal" - }, - { - "type": "text", - "id": "4IAJD", - "name": "lr1c4", - "fill": "#222222", - "content": "QFN", - "fontFamily": "Inter", - "fontSize": 13, - "fontWeight": "normal" - }, - { - "type": "text", - "id": "ejmJF", - "name": "lr1c5", - "fill": "#222222", - "content": "DIE_BOND", - "fontFamily": "Inter", - "fontSize": 13, - "fontWeight": "normal" - }, - { - "type": "text", - "id": "YpYn3", - "name": "lr1c6", - "fill": "#222222", - "content": "Die Attach", - "fontFamily": "Inter", - "fontSize": 13, - "fontWeight": "normal" - }, - { - "type": "frame", - "id": "0MDI8", - "name": "lr1c7", - "fill": "#FEE2E2", - "cornerRadius": 4, - "padding": [ - 2, - 8 - ], - "children": [ - { - "type": "text", - "id": "wGDd5", - "name": "lr1c7t", - "fill": "#991B1B", - "content": "15d", - "fontFamily": "Inter", - "fontSize": 12, - "fontWeight": "600" - } - ] - }, - { - "type": "text", - "id": "bddVn", - "name": "lr1c8", - "fill": "#222222", - "content": "王小明", - "fontFamily": "Inter", - "fontSize": 13, - "fontWeight": "normal" - }, - { - "type": "text", - "id": "DrnAd", - "name": "lr1c9", - "fill": "#222222", - "content": "QC", - "fontFamily": "Inter", - "fontSize": 13, - "fontWeight": "normal" - } - ] - }, - { - "type": "frame", - "id": "XKBSi", - "name": "lotRow2", - "width": "fill_container", - "stroke": { - "align": "inside", - "thickness": { - "bottom": 1 - }, - "fill": "#F0F0F0" - }, - "gap": 8, - "padding": [ - 12, - 20 - ], - "alignItems": "center", - "children": [ - { - "type": "text", - "id": "lHSqf", - "name": "lr2c1", - "fill": "#222222", - "content": "LOT2401180023", - "fontFamily": "Inter", - "fontSize": 13, - "fontWeight": "normal" - }, - { - "type": "text", - "id": "xXL5X", - "name": "lr2c2", - "fill": "#222222", - "content": "WO20240118005", - "fontFamily": "Inter", - "fontSize": 13, - "fontWeight": "normal" - }, - { - "type": "text", - "id": "PCg5f", - "name": "lr2c3", - "fill": "#222222", - "content": "3,800", - "fontFamily": "Inter", - "fontSize": 13, - "fontWeight": "normal" - }, - { - "type": "text", - "id": "UsM4m", - "name": "lr2c4", - "fill": "#222222", - "content": "DFN", - "fontFamily": "Inter", - "fontSize": 13, - "fontWeight": "normal" - }, - { - "type": "text", - "id": "soi3b", - "name": "lr2c5", - "fill": "#222222", - "content": "WIRE_BOND", - "fontFamily": "Inter", - "fontSize": 13, - "fontWeight": "normal" - }, - { - "type": "text", - "id": "rHrFq", - "name": "lr2c6", - "fill": "#222222", - "content": "Wire Bond", - "fontFamily": "Inter", - "fontSize": 13, - "fontWeight": "normal" - }, - { - "type": "frame", - "id": "kKLNP", - "name": "lr2c7", - "fill": "#FFF7ED", - "cornerRadius": 4, - "padding": [ - 2, - 8 - ], - "children": [ - { - "type": "text", - "id": "jVduw", - "name": "lr2c7t", - "fill": "#9A3412", - "content": "8d", - "fontFamily": "Inter", - "fontSize": 12, - "fontWeight": "600" - } - ] - }, - { - "type": "text", - "id": "Nsja6", - "name": "lr2c8", - "fill": "#222222", - "content": "李大華", - "fontFamily": "Inter", - "fontSize": 13, - "fontWeight": "normal" - }, - { - "type": "text", - "id": "0VS56", - "name": "lr2c9", - "fill": "#222222", - "content": "PE", - "fontFamily": "Inter", - "fontSize": 13, - "fontWeight": "normal" - } - ] - }, - { - "type": "frame", - "id": "mUQgn", - "name": "lotRow3", - "width": "fill_container", - "gap": 8, - "padding": [ - 12, - 20 - ], - "alignItems": "center", - "children": [ - { - "type": "text", - "id": "yxhdB", - "name": "lr3c1", - "fill": "#222222", - "content": "LOT2401200045", - "fontFamily": "Inter", - "fontSize": 13, - "fontWeight": "normal" - }, - { - "type": "text", - "id": "zB9xG", - "name": "lr3c2", - "fill": "#222222", - "content": "WO20240120008", - "fontFamily": "Inter", - "fontSize": 13, - "fontWeight": "normal" - }, - { - "type": "text", - "id": "0GVPK", - "name": "lr3c3", - "fill": "#222222", - "content": "4,500", - "fontFamily": "Inter", - "fontSize": 13, - "fontWeight": "normal" - }, - { - "type": "text", - "id": "y5oaF", - "name": "lr3c4", - "fill": "#222222", - "content": "SOT", - "fontFamily": "Inter", - "fontSize": 13, - "fontWeight": "normal" - }, - { - "type": "text", - "id": "lkcvd", - "name": "lr3c5", - "fill": "#222222", - "content": "MOLDING", - "fontFamily": "Inter", - "fontSize": 13, - "fontWeight": "normal" - }, - { - "type": "text", - "id": "RqRPi", - "name": "lr3c6", - "fill": "#222222", - "content": "Molding", - "fontFamily": "Inter", - "fontSize": 13, - "fontWeight": "normal" - }, - { - "type": "frame", - "id": "kMWnK", - "name": "lr3c7", - "fill": "#FFFBEB", - "cornerRadius": 4, - "padding": [ - 2, - 8 - ], - "children": [ - { - "type": "text", - "id": "S80Dg", - "name": "lr3c7t", - "fill": "#92400E", - "content": "2d", - "fontFamily": "Inter", - "fontSize": 12, - "fontWeight": "600" - } - ] - }, - { - "type": "text", - "id": "vC6Ph", - "name": "lr3c8", - "fill": "#222222", - "content": "張三豐", - "fontFamily": "Inter", - "fontSize": 13, - "fontWeight": "normal" - }, - { - "type": "text", - "id": "xKBf2", - "name": "lr3c9", - "fill": "#222222", - "content": "QC", - "fontFamily": "Inter", - "fontSize": 13, - "fontWeight": "normal" - } - ] - } - ] - }, - { - "type": "frame", - "id": "fgdNX", - "name": "pagination", - "width": "fill_container", - "stroke": { - "align": "inside", - "thickness": { - "top": 1 - }, - "fill": "#E2E6EF" - }, - "padding": [ - 12, - 20 - ], - "justifyContent": "space_between", - "alignItems": "center", - "children": [ - { - "type": "text", - "id": "UkFtQ", - "name": "pageInfo", - "fill": "#888888", - "content": "Showing 1-50 of 127 lots", - "fontFamily": "Inter", - "fontSize": 13, - "fontWeight": "normal" - }, - { - "type": "frame", - "id": "b2LAR", - "name": "pageButtons", - "gap": 8, - "children": [ - { - "type": "frame", - "id": "QERaB", - "name": "prevBtn", - "cornerRadius": 6, - "stroke": { - "align": "inside", - "thickness": 1, - "fill": "#E2E6EF" - }, - "padding": [ - 8, - 16 - ], - "justifyContent": "center", - "alignItems": "center", - "children": [ - { - "type": "text", - "id": "Msjmx", - "name": "prevText", - "fill": "#666666", - "content": "← Previous", - "fontFamily": "Inter", - "fontSize": 13, - "fontWeight": "normal" - } - ] - }, - { - "type": "frame", - "id": "PFfh6", - "name": "pageNum", - "fill": "#667eea", - "cornerRadius": 6, - "padding": [ - 8, - 16 - ], - "justifyContent": "center", - "alignItems": "center", - "children": [ - { - "type": "text", - "id": "glqu5", - "name": "pageNumText", - "fill": "#FFFFFF", - "content": "1", - "fontFamily": "Inter", - "fontSize": 13, - "fontWeight": "600" - } - ] - }, - { - "type": "frame", - "id": "aWWrR", - "name": "nextBtn", - "cornerRadius": 6, - "stroke": { - "align": "inside", - "thickness": 1, - "fill": "#E2E6EF" - }, - "padding": [ - 8, - 16 - ], - "justifyContent": "center", - "alignItems": "center", - "children": [ - { - "type": "text", - "id": "4pKqZ", - "name": "nextText", - "fill": "#666666", - "content": "Next →", - "fontFamily": "Inter", - "fontSize": 13, - "fontWeight": "normal" - } - ] - } - ] - } - ] - } - ] - } - ] - } - ] -} \ No newline at end of file diff --git a/frontend_design/WIP_main.pen b/frontend_design/WIP_main.pen deleted file mode 100644 index d00b336..0000000 --- a/frontend_design/WIP_main.pen +++ /dev/null @@ -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" - } - ] - } - ] - } - ] - } - ] - } - ] -} \ No newline at end of file diff --git a/openspec/changes/archive/2026-02-09-qc-gate-report/.openspec.yaml b/openspec/changes/archive/2026-02-09-qc-gate-report/.openspec.yaml new file mode 100644 index 0000000..9bc4ae2 --- /dev/null +++ b/openspec/changes/archive/2026-02-09-qc-gate-report/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-02-09 diff --git a/openspec/changes/archive/2026-02-09-qc-gate-report/design.md b/openspec/changes/archive/2026-02-09-qc-gate-report/design.md new file mode 100644 index 0000000..bb073c9 --- /dev/null +++ b/openspec/changes/archive/2026-02-09-qc-gate-report/design.md @@ -0,0 +1,180 @@ +## Context + +目前系統有 11 個頁面,全部使用 Jinja2 shell + Vite JS 混合模式。前端無 UI 框架(純 vanilla JS),ECharts 以靜態 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 外層處理。 diff --git a/openspec/changes/archive/2026-02-09-qc-gate-report/proposal.md b/openspec/changes/archive/2026-02-09-qc-gate-report/proposal.md new file mode 100644 index 0000000..ffb2629 --- /dev/null +++ b/openspec/changes/archive/2026-02-09-qc-gate-report/proposal.md @@ -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 diff --git a/openspec/changes/archive/2026-02-09-qc-gate-report/specs/qc-gate-status-report/spec.md b/openspec/changes/archive/2026-02-09-qc-gate-report/specs/qc-gate-status-report/spec.md new file mode 100644 index 0000000..605e994 --- /dev/null +++ b/openspec/changes/archive/2026-02-09-qc-gate-report/specs/qc-gate-status-report/spec.md @@ -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 diff --git a/openspec/changes/archive/2026-02-09-qc-gate-report/specs/vue-vite-page-architecture/spec.md b/openspec/changes/archive/2026-02-09-qc-gate-report/specs/vue-vite-page-architecture/spec.md new file mode 100644 index 0000000..91a7d12 --- /dev/null +++ b/openspec/changes/archive/2026-02-09-qc-gate-report/specs/vue-vite-page-architecture/spec.md @@ -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 `.html`, `.js`, and `.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 diff --git a/openspec/changes/archive/2026-02-09-qc-gate-report/tasks.md b/openspec/changes/archive/2026-02-09-qc-gate-report/tasks.md new file mode 100644 index 0000000..2320699 --- /dev/null +++ b/openspec/changes/archive/2026-02-09-qc-gate-report/tasks.md @@ -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) diff --git a/openspec/specs/qc-gate-status-report/spec.md b/openspec/specs/qc-gate-status-report/spec.md new file mode 100644 index 0000000..58c89eb --- /dev/null +++ b/openspec/specs/qc-gate-status-report/spec.md @@ -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 diff --git a/openspec/specs/vue-vite-page-architecture/spec.md b/openspec/specs/vue-vite-page-architecture/spec.md new file mode 100644 index 0000000..b830689 --- /dev/null +++ b/openspec/specs/vue-vite-page-architecture/spec.md @@ -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 `.html`, `.js`, and `.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 diff --git a/src/mes_dashboard/app.py b/src/mes_dashboard/app.py index e1a38e8..e1c6295 100644 --- a/src/mes_dashboard/app.py +++ b/src/mes_dashboard/app.py @@ -9,7 +9,7 @@ import os import sys 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.settings import get_config @@ -413,6 +413,12 @@ def create_app(config_name: str | None = None) -> Flask: """TMTT printing & lead form defect analysis page.""" 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) # ======================================================== diff --git a/src/mes_dashboard/routes/__init__.py b/src/mes_dashboard/routes/__init__.py index 53c2083..0ce5a98 100644 --- a/src/mes_dashboard/routes/__init__.py +++ b/src/mes_dashboard/routes/__init__.py @@ -15,6 +15,7 @@ from .resource_history_routes import resource_history_bp from .job_query_routes import job_query_bp from .query_tool_routes import query_tool_bp from .tmtt_defect_routes import tmtt_defect_bp +from .qc_gate_routes import qc_gate_bp def register_routes(app) -> None: @@ -28,6 +29,7 @@ def register_routes(app) -> None: app.register_blueprint(job_query_bp) app.register_blueprint(query_tool_bp) app.register_blueprint(tmtt_defect_bp) + app.register_blueprint(qc_gate_bp) __all__ = [ 'wip_bp', @@ -41,5 +43,6 @@ __all__ = [ 'job_query_bp', 'query_tool_bp', 'tmtt_defect_bp', + 'qc_gate_bp', 'register_routes', ] diff --git a/src/mes_dashboard/routes/qc_gate_routes.py b/src/mes_dashboard/routes/qc_gate_routes.py new file mode 100644 index 0000000..9852214 --- /dev/null +++ b/src/mes_dashboard/routes/qc_gate_routes.py @@ -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 diff --git a/src/mes_dashboard/services/filter_cache.py b/src/mes_dashboard/services/filter_cache.py index baff4fb..4e9458e 100644 --- a/src/mes_dashboard/services/filter_cache.py +++ b/src/mes_dashboard/services/filter_cache.py @@ -31,6 +31,7 @@ _CACHE = { 'workcenter_groups': None, # List of {name, sequence} 'workcenter_mapping': None, # Dict {workcentername: {group, sequence}} 'workcenter_to_short': None, # Dict {workcentername: short_name} + 'spec_order_mapping': None, # Dict {spec_name_upper: spec_order} 'last_refresh': None, '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 # ============================================================ @@ -166,6 +180,7 @@ def get_cache_status() -> Dict[str, Any]: 'is_loading': _CACHE.get('is_loading', False), 'workcenter_groups_count': len(_CACHE.get('workcenter_groups') 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: # Load workcenter groups - prioritize SPEC_WORKCENTER_V wc_groups, wc_mapping, wc_short = _load_workcenter_data() + spec_order_mapping = _load_spec_order_mapping_from_spec() with _CACHE_LOCK: _CACHE['workcenter_groups'] = wc_groups _CACHE['workcenter_mapping'] = wc_mapping _CACHE['workcenter_to_short'] = wc_short + _CACHE['spec_order_mapping'] = spec_order_mapping _CACHE['last_refresh'] = datetime.now() _CACHE['is_loading'] = False logger.info( 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 @@ -346,6 +364,60 @@ def _load_workcenter_mapping_from_spec(): 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): """Extract workcenter groups and mapping from DataFrame. diff --git a/src/mes_dashboard/services/qc_gate_service.py b/src/mes_dashboard/services/qc_gate_service.py new file mode 100644 index 0000000..0248d13 --- /dev/null +++ b/src/mes_dashboard/services/qc_gate_service.py @@ -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 diff --git a/tests/test_qc_gate_routes.py b/tests/test_qc_gate_routes.py new file mode 100644 index 0000000..375f047 --- /dev/null +++ b/tests/test_qc_gate_routes.py @@ -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('ok', 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' diff --git a/tests/test_qc_gate_service.py b/tests/test_qc_gate_service.py new file mode 100644 index 0000000..b55aa43 --- /dev/null +++ b/tests/test_qc_gate_service.py @@ -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'] == []