feat: SQLAlchemy連接池、快取機制、UI淺色主題

後端改進:
- 新增 SQLAlchemy engine 連接池 (pool_size=5, max_overflow=10)
- 實現記憶體快取機制 (60秒 TTL)
- 新增排除清單過濾: EXCLUDED_LOCATIONS, EXCLUDED_ASSET_STATUSES
- SQL 查詢使用 CTE 優化、支援分頁 offset
- 預設查詢天數改為 365 天

前端改進:
- UI 主題從深色改為淺色 (CSS 變數化)
- 移除廠區/資產狀態多選下拉選單 (改由後端排除清單控制)
- 明細表格新增「最後狀態時間」欄位
- 簡化篩選邏輯

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
ymirliu
2026-01-16 09:49:02 +08:00
parent 313e41390b
commit 1165d7b84e
3 changed files with 367 additions and 400 deletions

View File

@@ -3,10 +3,13 @@ Unified MES portal with tabs for WIP report and table viewer.
"""
from datetime import datetime
import json
import time
import oracledb
import pandas as pd
from flask import Flask, jsonify, render_template, request
from sqlalchemy import create_engine, text
# Database connection config
DB_CONFIG = {
@@ -138,6 +141,27 @@ TABLES_CONFIG = {
}
app = Flask(__name__, template_folder="templates")
ENGINE = create_engine(
"oracle+oracledb://MBU1_R:Pj2481mbu1@10.1.1.58:1521/?service_name=DWDB",
pool_size=5,
max_overflow=10,
pool_pre_ping=True,
)
CACHE_TTL_SECONDS = 60
CACHE = {}
EXCLUDED_LOCATIONS = [
'ATEC',
'F區',
'F區焊接站',
'報廢',
'實驗室',
'山東',
'成型站_F區',
'焊接F區',
'無錫',
'熒茂',
]
EXCLUDED_ASSET_STATUSES = ['Disapproved']
def get_db_connection():
@@ -149,6 +173,40 @@ def get_db_connection():
return None
def read_sql_df(sql, params=None):
"""Run SQL with SQLAlchemy engine to avoid pandas DBAPI warnings."""
with ENGINE.connect() as conn:
df = pd.read_sql(text(sql), conn, params=params)
df.columns = [str(c).upper() for c in df.columns]
return df
def cache_get(key):
entry = CACHE.get(key)
if not entry:
return None
expires_at, value = entry
if time.time() > expires_at:
CACHE.pop(key, None)
return None
return value
def cache_set(key, value, ttl=CACHE_TTL_SECONDS):
CACHE[key] = (time.time() + ttl, value)
def make_cache_key(prefix, days_back=None, filters=None):
filters_key = json.dumps(filters, sort_keys=True, ensure_ascii=False) if filters else ""
return f"{prefix}:{days_back}:{filters_key}"
def get_days_back(filters=None, default=365):
if filters:
return int(filters.get('days_back', default))
return default
def get_table_columns(table_name):
"""Get column names for a table."""
connection = get_db_connection()
@@ -298,7 +356,7 @@ def query_wip_by_spec_workcenter():
GROUP BY SPECNAME, WORKCENTERNAME
ORDER BY TOTAL_QTY DESC
"""
df = pd.read_sql(sql, connection)
df = read_sql_df(sql)
connection.close()
return df
except Exception as exc:
@@ -327,7 +385,7 @@ def query_wip_by_product_line():
GROUP BY PRODUCTLINENAME_LEF, SPECNAME, WORKCENTERNAME
ORDER BY TOTAL_QTY DESC
"""
df = pd.read_sql(sql, connection)
df = read_sql_df(sql)
connection.close()
return df
except Exception as exc:
@@ -489,7 +547,7 @@ def query_wip_by_status():
GROUP BY STATUS
ORDER BY LOT_COUNT DESC
"""
df = pd.read_sql(sql, connection)
df = read_sql_df(sql)
connection.close()
return df
except Exception as exc:
@@ -518,7 +576,7 @@ def query_wip_by_mfgorder(limit=100):
ORDER BY TOTAL_QTY DESC
) WHERE ROWNUM <= :limit
"""
df = pd.read_sql(sql, connection, params={'limit': limit})
df = read_sql_df(sql, params={'limit': limit})
connection.close()
return df
except Exception as exc:
@@ -564,10 +622,24 @@ def get_resource_latest_status_subquery(days_back=30):
Includes JOBID for SDT/UDT drill-down.
Includes PJ_LOTID from RESOURCE table.
"""
location_filter = ""
if EXCLUDED_LOCATIONS:
excluded_locations = "', '".join(EXCLUDED_LOCATIONS)
location_filter = f"AND (r.LOCATIONNAME IS NULL OR r.LOCATIONNAME NOT IN ('{excluded_locations}'))"
asset_status_filter = ""
if EXCLUDED_ASSET_STATUSES:
excluded_assets = "', '".join(EXCLUDED_ASSET_STATUSES)
asset_status_filter = f"AND (r.PJ_ASSETSSTATUS IS NULL OR r.PJ_ASSETSSTATUS NOT IN ('{excluded_assets}'))"
return f"""
SELECT *
FROM (
SELECT
WITH latest_txn AS (
SELECT MAX(COALESCE(TXNDATE, LASTSTATUSCHANGEDATE)) AS MAX_TXNDATE
FROM DW_MES_RESOURCESTATUS
)
SELECT *
FROM (
SELECT
r.RESOURCEID,
r.RESOURCENAME,
r.OBJECTCATEGORY,
@@ -584,28 +656,33 @@ def get_resource_latest_status_subquery(days_back=30):
r.PJ_ISMONITOR,
r.PJ_LOTID,
r.DESCRIPTION,
s.NEWSTATUSNAME,
s.NEWREASONNAME,
s.LASTSTATUSCHANGEDATE,
s.OLDSTATUSNAME,
s.NEWSTATUSNAME,
s.NEWREASONNAME,
s.LASTSTATUSCHANGEDATE,
s.OLDSTATUSNAME,
s.OLDREASONNAME,
s.AVAILABILITY,
s.JOBID,
ROW_NUMBER() OVER (
PARTITION BY r.RESOURCEID
ORDER BY s.LASTSTATUSCHANGEDATE DESC NULLS LAST, s.TXNDATE DESC
) AS rn
FROM DW_MES_RESOURCE r
JOIN DW_MES_RESOURCESTATUS s ON r.RESOURCEID = s.HISTORYID
WHERE ((r.OBJECTCATEGORY = 'ASSEMBLY' AND r.OBJECTTYPE = 'ASSEMBLY')
OR (r.OBJECTCATEGORY = 'WAFERSORT' AND r.OBJECTTYPE = 'WAFERSORT'))
AND s.LASTSTATUSCHANGEDATE >= SYSDATE - {days_back}
)
WHERE rn = 1
"""
s.AVAILABILITY,
s.JOBID,
s.TXNDATE,
ROW_NUMBER() OVER (
PARTITION BY r.RESOURCEID
ORDER BY s.LASTSTATUSCHANGEDATE DESC NULLS LAST,
COALESCE(s.TXNDATE, s.LASTSTATUSCHANGEDATE) DESC
) AS rn
FROM DW_MES_RESOURCE r
JOIN DW_MES_RESOURCESTATUS s ON r.RESOURCEID = s.HISTORYID
CROSS JOIN latest_txn lt
WHERE ((r.OBJECTCATEGORY = 'ASSEMBLY' AND r.OBJECTTYPE = 'ASSEMBLY')
OR (r.OBJECTCATEGORY = 'WAFERSORT' AND r.OBJECTTYPE = 'WAFERSORT'))
AND COALESCE(s.TXNDATE, s.LASTSTATUSCHANGEDATE) >= lt.MAX_TXNDATE - {days_back}
{location_filter}
{asset_status_filter}
)
WHERE rn = 1
"""
def query_resource_status_summary():
def query_resource_status_summary(days_back=30):
"""Query resource status summary."""
connection = get_db_connection()
if not connection:
@@ -618,7 +695,7 @@ def query_resource_status_summary():
COUNT(DISTINCT WORKCENTERNAME) as WORKCENTER_COUNT,
COUNT(DISTINCT RESOURCEFAMILYNAME) as FAMILY_COUNT,
COUNT(DISTINCT PJ_DEPARTMENT) as DEPT_COUNT
FROM ({get_resource_latest_status_subquery()}) rs
FROM ({get_resource_latest_status_subquery(days_back)}) rs
"""
cursor = connection.cursor()
cursor.execute(sql)
@@ -641,67 +718,49 @@ def query_resource_status_summary():
return None
def query_resource_by_status():
def query_resource_by_status(days_back=30):
"""Query resource count by status."""
connection = get_db_connection()
if not connection:
return None
try:
sql = f"""
SELECT
NEWSTATUSNAME,
COUNT(*) as COUNT
FROM ({get_resource_latest_status_subquery()}) rs
FROM ({get_resource_latest_status_subquery(days_back)}) rs
WHERE NEWSTATUSNAME IS NOT NULL
GROUP BY NEWSTATUSNAME
ORDER BY COUNT DESC
"""
df = pd.read_sql(sql, connection)
connection.close()
df = read_sql_df(sql)
return df
except Exception as exc:
if connection:
connection.close()
print(f"查詢失敗: {exc}")
return None
def query_resource_by_workcenter():
def query_resource_by_workcenter(days_back=30):
"""Query resource count by workcenter and status."""
connection = get_db_connection()
if not connection:
return None
try:
sql = f"""
SELECT
WORKCENTERNAME,
NEWSTATUSNAME,
COUNT(*) as COUNT
FROM ({get_resource_latest_status_subquery()}) rs
FROM ({get_resource_latest_status_subquery(days_back)}) rs
WHERE WORKCENTERNAME IS NOT NULL
GROUP BY WORKCENTERNAME, NEWSTATUSNAME
ORDER BY WORKCENTERNAME, COUNT DESC
"""
df = pd.read_sql(sql, connection)
connection.close()
df = read_sql_df(sql)
return df
except Exception as exc:
if connection:
connection.close()
print(f"查詢失敗: {exc}")
return None
def query_resource_detail(filters=None, limit=500):
def query_resource_detail(filters=None, limit=500, offset=0, days_back=30):
"""Query resource detail with optional filters."""
connection = get_db_connection()
if not connection:
return None
try:
base_sql = get_resource_latest_status_subquery()
base_sql = get_resource_latest_status_subquery(days_back)
where_conditions = []
if filters:
@@ -740,6 +799,8 @@ def query_resource_detail(filters=None, limit=500):
else:
where_clause = ""
start_row = offset + 1
end_row = offset + limit
sql = f"""
SELECT * FROM (
SELECT
@@ -756,14 +817,15 @@ def query_resource_detail(filters=None, limit=500):
AVAILABILITY,
PJ_ISPRODUCTION,
PJ_ISKEY,
PJ_ISMONITOR
PJ_ISMONITOR,
ROW_NUMBER() OVER (
ORDER BY LASTSTATUSCHANGEDATE DESC NULLS LAST
) AS rn
FROM ({base_sql}) rs
WHERE 1=1 {where_clause}
ORDER BY LASTSTATUSCHANGEDATE DESC NULLS LAST
) WHERE ROWNUM <= {limit}
) WHERE rn BETWEEN {start_row} AND {end_row}
"""
df = pd.read_sql(sql, connection)
connection.close()
df = read_sql_df(sql)
# Convert datetime to string
if 'LASTSTATUSCHANGEDATE' in df.columns:
@@ -773,13 +835,11 @@ def query_resource_detail(filters=None, limit=500):
return df
except Exception as exc:
if connection:
connection.close()
print(f"查詢失敗: {exc}")
return None
def query_resource_workcenter_status_matrix():
def query_resource_workcenter_status_matrix(days_back=30):
"""Query resource count matrix by workcenter and status category.
Actual status values in database (verified):
@@ -791,10 +851,6 @@ def query_resource_workcenter_status_matrix():
- NST: (待確認,暫歸類為 OTHER)
- SCRAP: 報廢
"""
connection = get_db_connection()
if not connection:
return None
try:
# Use exact status values based on database verification
sql = f"""
@@ -812,7 +868,7 @@ def query_resource_workcenter_status_matrix():
END as STATUS_CATEGORY,
NEWSTATUSNAME,
COUNT(*) as COUNT
FROM ({get_resource_latest_status_subquery()}) rs
FROM ({get_resource_latest_status_subquery(days_back)}) rs
WHERE WORKCENTERNAME IS NOT NULL
GROUP BY WORKCENTERNAME,
CASE NEWSTATUSNAME
@@ -828,49 +884,46 @@ def query_resource_workcenter_status_matrix():
NEWSTATUSNAME
ORDER BY WORKCENTERNAME, STATUS_CATEGORY
"""
df = pd.read_sql(sql, connection)
connection.close()
df = read_sql_df(sql)
return df
except Exception as exc:
if connection:
connection.close()
print(f"查詢失敗: {exc}")
return None
def query_resource_filter_options():
def query_resource_filter_options(days_back=30):
"""Get available filter options.
優化:合併成一個查詢,只掃描一次子查詢,大幅提升效能。
"""
connection = get_db_connection()
if not connection:
return None
try:
# 一次查詢取得所有需要的 distinct 值
sql = f"""
sql_latest = f"""
SELECT
WORKCENTERNAME,
NEWSTATUSNAME,
RESOURCEFAMILYNAME,
PJ_DEPARTMENT,
PJ_DEPARTMENT
FROM ({get_resource_latest_status_subquery(days_back)}) rs
"""
latest_df = read_sql_df(sql_latest)
sql_resource = """
SELECT
LOCATIONNAME,
PJ_ASSETSSTATUS
FROM ({get_resource_latest_status_subquery()}) rs
FROM DW_MES_RESOURCE r
WHERE ((r.OBJECTCATEGORY = 'ASSEMBLY' AND r.OBJECTTYPE = 'ASSEMBLY')
OR (r.OBJECTCATEGORY = 'WAFERSORT' AND r.OBJECTTYPE = 'WAFERSORT'))
"""
print("開始執行 filter_options 查詢...")
df = pd.read_sql(sql, connection)
print(f"查詢完成,共 {len(df)} 筆資料")
connection.close()
resource_df = read_sql_df(sql_resource)
# 從結果中提取各欄位的不重複值
workcenters = sorted(df['WORKCENTERNAME'].dropna().unique().tolist())
statuses = sorted(df['NEWSTATUSNAME'].dropna().unique().tolist())
families = sorted(df['RESOURCEFAMILYNAME'].dropna().unique().tolist())
departments = sorted(df['PJ_DEPARTMENT'].dropna().unique().tolist())
locations = sorted(df['LOCATIONNAME'].dropna().unique().tolist())
assets_statuses = sorted(df['PJ_ASSETSSTATUS'].dropna().unique().tolist())
workcenters = sorted(latest_df['WORKCENTERNAME'].dropna().unique().tolist())
statuses = sorted(latest_df['NEWSTATUSNAME'].dropna().unique().tolist())
families = sorted(latest_df['RESOURCEFAMILYNAME'].dropna().unique().tolist())
departments = sorted(latest_df['PJ_DEPARTMENT'].dropna().unique().tolist())
locations = sorted(resource_df['LOCATIONNAME'].dropna().unique().tolist())
assets_statuses = sorted(resource_df['PJ_ASSETSSTATUS'].dropna().unique().tolist())
print(f"篩選選項: locations={len(locations)}, assets_statuses={len(assets_statuses)}")
@@ -883,8 +936,6 @@ def query_resource_filter_options():
'assets_statuses': assets_statuses
}
except Exception as exc:
if connection:
connection.close()
print(f"查詢失敗: {exc}")
import traceback
traceback.print_exc()
@@ -900,7 +951,13 @@ def resource_page():
@app.route('/api/resource/summary')
def api_resource_summary():
"""API: Resource status summary."""
summary = query_resource_status_summary()
days_back = request.args.get('days_back', 30, type=int)
cache_key = make_cache_key("resource_summary", days_back)
summary = cache_get(cache_key)
if summary is None:
summary = query_resource_status_summary(days_back)
if summary:
cache_set(cache_key, summary)
if summary:
return jsonify({'success': True, 'data': summary})
return jsonify({'success': False, 'error': '查詢失敗'}), 500
@@ -909,9 +966,17 @@ def api_resource_summary():
@app.route('/api/resource/by_status')
def api_resource_by_status():
"""API: Resource count by status."""
df = query_resource_by_status()
if df is not None:
data = df.to_dict(orient='records')
days_back = request.args.get('days_back', 30, type=int)
cache_key = make_cache_key("resource_by_status", days_back)
data = cache_get(cache_key)
if data is None:
df = query_resource_by_status(days_back)
if df is not None:
data = df.to_dict(orient='records')
cache_set(cache_key, data)
else:
data = None
if data is not None:
return jsonify({'success': True, 'data': data})
return jsonify({'success': False, 'error': '查詢失敗'}), 500
@@ -919,9 +984,17 @@ def api_resource_by_status():
@app.route('/api/resource/by_workcenter')
def api_resource_by_workcenter():
"""API: Resource count by workcenter."""
df = query_resource_by_workcenter()
if df is not None:
data = df.to_dict(orient='records')
days_back = request.args.get('days_back', 30, type=int)
cache_key = make_cache_key("resource_by_workcenter", days_back)
data = cache_get(cache_key)
if data is None:
df = query_resource_by_workcenter(days_back)
if df is not None:
data = df.to_dict(orient='records')
cache_set(cache_key, data)
else:
data = None
if data is not None:
return jsonify({'success': True, 'data': data})
return jsonify({'success': False, 'error': '查詢失敗'}), 500
@@ -929,9 +1002,17 @@ def api_resource_by_workcenter():
@app.route('/api/resource/workcenter_status_matrix')
def api_resource_workcenter_status_matrix():
"""API: Resource count matrix by workcenter and status category."""
df = query_resource_workcenter_status_matrix()
if df is not None:
data = df.to_dict(orient='records')
days_back = request.args.get('days_back', 30, type=int)
cache_key = make_cache_key("resource_workcenter_matrix", days_back)
data = cache_get(cache_key)
if data is None:
df = query_resource_workcenter_status_matrix(days_back)
if df is not None:
data = df.to_dict(orient='records')
cache_set(cache_key, data)
else:
data = None
if data is not None:
return jsonify({'success': True, 'data': data})
return jsonify({'success': False, 'error': '查詢失敗'}), 500
@@ -942,24 +1023,28 @@ def api_resource_detail():
data = request.get_json() or {}
filters = data.get('filters')
limit = data.get('limit', 500)
offset = data.get('offset', 0)
days_back = get_days_back(filters)
df = query_resource_detail(filters, limit)
df = query_resource_detail(filters, limit, offset, days_back)
if df is not None:
records = df.to_dict(orient='records')
return jsonify({'success': True, 'data': records, 'count': len(records)})
return jsonify({'success': True, 'data': records, 'count': len(records), 'offset': offset})
return jsonify({'success': False, 'error': '查詢失敗'}), 500
@app.route('/api/resource/filter_options')
def api_resource_filter_options():
"""API: Get filter options."""
print("=== /api/resource/filter_options 被呼叫 ===")
options = query_resource_filter_options()
days_back = request.args.get('days_back', 30, type=int)
cache_key = make_cache_key("resource_filter_options", days_back)
options = cache_get(cache_key)
if options is None:
options = query_resource_filter_options(days_back)
if options:
cache_set(cache_key, options)
if options:
print(f"locations 數量: {len(options.get('locations', []))}")
print(f"assets_statuses 數量: {len(options.get('assets_statuses', []))}")
return jsonify({'success': True, 'data': options})
print("查詢失敗options 為 None")
return jsonify({'success': False, 'error': '查詢失敗'}), 500
@@ -1013,7 +1098,8 @@ def query_dashboard_kpi(filters=None):
return None
try:
base_sql = get_resource_latest_status_subquery()
days_back = get_days_back(filters)
base_sql = get_resource_latest_status_subquery(days_back)
# Build filter conditions
where_conditions = []
@@ -1203,12 +1289,9 @@ def query_workcenter_cards(filters=None):
10: 元件切割 (PKG_SAE)
11: 測試 (TMTT)
"""
connection = get_db_connection()
if not connection:
return None
try:
base_sql = get_resource_latest_status_subquery()
days_back = get_days_back(filters)
base_sql = get_resource_latest_status_subquery(days_back)
# Build filter conditions
where_conditions = []
@@ -1244,8 +1327,7 @@ def query_workcenter_cards(filters=None):
WHERE WORKCENTERNAME IS NOT NULL AND {where_clause}
GROUP BY WORKCENTERNAME
"""
df = pd.read_sql(sql, connection)
connection.close()
df = read_sql_df(sql)
# Group workcenters
grouped_data = {}
@@ -1362,13 +1444,11 @@ def query_workcenter_cards(filters=None):
return result
except Exception as exc:
if connection:
connection.close()
print(f"工站卡片查詢失敗: {exc}")
return None
def query_resource_detail_with_job(filters=None, limit=200):
def query_resource_detail_with_job(filters=None, limit=200, offset=0):
"""Query resource detail with JOB info for SDT/UDT drill-down.
欄位來源說明:
@@ -1377,12 +1457,9 @@ def query_resource_detail_with_job(filters=None, limit=200):
- 原因碼 (CAUSECODENAME): 來自 DW_MES_JOB.CAUSECODENAME (透過 JOBID 關聯)
- DownTime: 計算自 LASTSTATUSCHANGEDATE 到現在的時間差 (分鐘)
"""
connection = get_db_connection()
if not connection:
return None
try:
base_sql = get_resource_latest_status_subquery()
days_back = get_days_back(filters)
base_sql = get_resource_latest_status_subquery(days_back)
where_conditions = []
if filters:
@@ -1427,6 +1504,8 @@ def query_resource_detail_with_job(filters=None, limit=200):
# Left join with JOB table for SDT/UDT details
# PJ_LOTID 來自 RESOURCE 表
# SYMPTOMCODENAME, CAUSECODENAME 來自 JOB 表
start_row = offset + 1
end_row = offset + limit
sql = f"""
SELECT * FROM (
SELECT
@@ -1451,21 +1530,22 @@ def query_resource_detail_with_job(filters=None, limit=200):
j.REPAIRCODENAME,
j.CREATEDATE as JOB_CREATEDATE,
j.FIRSTCLOCKONDATE,
ROUND((SYSDATE - rs.LASTSTATUSCHANGEDATE) * 24 * 60, 0) as DOWN_MINUTES
ROUND((SYSDATE - rs.LASTSTATUSCHANGEDATE) * 24 * 60, 0) as DOWN_MINUTES,
ROW_NUMBER() OVER (
ORDER BY
CASE rs.NEWSTATUSNAME
WHEN 'UDT' THEN 1
WHEN 'SDT' THEN 2
ELSE 3
END,
rs.LASTSTATUSCHANGEDATE DESC NULLS LAST
) AS rn
FROM ({base_sql}) rs
LEFT JOIN DW_MES_JOB j ON rs.JOBID = j.JOBID
WHERE {where_clause}
ORDER BY
CASE rs.NEWSTATUSNAME
WHEN 'UDT' THEN 1
WHEN 'SDT' THEN 2
ELSE 3
END,
rs.LASTSTATUSCHANGEDATE DESC NULLS LAST
) WHERE ROWNUM <= {limit}
) WHERE rn BETWEEN {start_row} AND {end_row}
"""
df = pd.read_sql(sql, connection)
connection.close()
df = read_sql_df(sql)
# Convert datetime columns
datetime_cols = ['LASTSTATUSCHANGEDATE', 'JOB_CREATEDATE', 'FIRSTCLOCKONDATE']
@@ -1477,8 +1557,6 @@ def query_resource_detail_with_job(filters=None, limit=200):
return df
except Exception as exc:
if connection:
connection.close()
print(f"明細查詢失敗: {exc}")
return None
@@ -1489,7 +1567,13 @@ def api_dashboard_kpi():
data = request.get_json() or {}
filters = data.get('filters')
kpi = query_dashboard_kpi(filters)
days_back = get_days_back(filters)
cache_key = make_cache_key("dashboard_kpi", days_back, filters)
kpi = cache_get(cache_key)
if kpi is None:
kpi = query_dashboard_kpi(filters)
if kpi:
cache_set(cache_key, kpi)
if kpi:
return jsonify({'success': True, 'data': kpi})
return jsonify({'success': False, 'error': '查詢失敗'}), 500
@@ -1501,7 +1585,13 @@ def api_dashboard_workcenter_cards():
data = request.get_json() or {}
filters = data.get('filters')
cards = query_workcenter_cards(filters)
days_back = get_days_back(filters)
cache_key = make_cache_key("dashboard_workcenter_cards", days_back, filters)
cards = cache_get(cache_key)
if cards is None:
cards = query_workcenter_cards(filters)
if cards is not None:
cache_set(cache_key, cards)
if cards is not None:
return jsonify({'success': True, 'data': cards})
return jsonify({'success': False, 'error': '查詢失敗'}), 500
@@ -1513,11 +1603,12 @@ def api_dashboard_detail():
data = request.get_json() or {}
filters = data.get('filters')
limit = data.get('limit', 200)
offset = data.get('offset', 0)
df = query_resource_detail_with_job(filters, limit)
df = query_resource_detail_with_job(filters, limit, offset)
if df is not None:
records = df.to_dict(orient='records')
return jsonify({'success': True, 'data': records, 'count': len(records)})
return jsonify({'success': True, 'data': records, 'count': len(records), 'offset': offset})
return jsonify({'success': False, 'error': '查詢失敗'}), 500

View File

@@ -5,7 +5,25 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>全廠機況 Dashboard</title>
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
<style>
:root {
--bg: #f5f7fa;
--card-bg: #ffffff;
--text: #222;
--muted: #666;
--border: #e2e6ef;
--primary: #667eea;
--primary-dark: #5568d3;
--shadow: 0 2px 10px rgba(0,0,0,0.08);
--shadow-strong: 0 4px 15px rgba(102, 126, 234, 0.2);
--success: #22c55e;
--danger: #ef4444;
--warning: #f59e0b;
--info: #3b82f6;
--neutral: #64748b;
}
* {
margin: 0;
padding: 0;
@@ -14,15 +32,15 @@
body {
font-family: 'Microsoft JhengHei', Arial, sans-serif;
background: #1a1a2e;
color: #eee;
background: var(--bg);
color: var(--text);
min-height: 100vh;
}
.dashboard {
max-width: 1800px;
margin: 0 auto;
padding: 15px;
padding: 20px;
}
/* Header */
@@ -30,182 +48,137 @@
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 20px;
background: linear-gradient(135deg, #16213e 0%, #0f3460 100%);
flex-wrap: wrap;
gap: 12px;
padding: 18px 22px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 10px;
margin-bottom: 15px;
margin-bottom: 16px;
box-shadow: var(--shadow-strong);
}
.header h1 {
font-size: 24px;
color: #00d4ff;
color: #fff;
}
.header-right {
display: flex;
align-items: center;
gap: 20px;
gap: 16px;
flex-wrap: wrap;
}
.last-update {
color: #888;
color: rgba(255, 255, 255, 0.8);
font-size: 13px;
}
/* Filters */
.filters {
display: flex;
gap: 15px;
gap: 12px;
align-items: center;
flex-wrap: wrap;
background: rgba(255, 255, 255, 0.9);
padding: 10px 14px;
border-radius: 10px;
box-shadow: var(--shadow);
}
.filter-group {
display: flex;
align-items: center;
gap: 8px;
background: rgba(255,255,255,0.1);
padding: 8px 15px;
border-radius: 20px;
gap: 10px;
}
.filter-group label {
display: flex;
align-items: center;
gap: 5px;
gap: 6px;
cursor: pointer;
font-size: 13px;
color: var(--text);
}
.filter-group input[type="checkbox"] {
width: 16px;
height: 16px;
cursor: pointer;
}
.filter-group select {
background: #1a1a2e;
color: #eee;
border: 1px solid #444;
border-radius: 5px;
padding: 5px 10px;
font-size: 12px;
cursor: pointer;
min-width: 120px;
}
.filter-group select:focus {
border-color: #00d4ff;
outline: none;
}
.filter-group select[multiple] {
min-width: 150px;
height: 80px;
padding: 5px;
}
.filter-group select[multiple] option {
padding: 3px 8px;
margin: 1px 0;
border-radius: 3px;
}
.filter-group select[multiple] option:checked {
background: linear-gradient(0deg, #00d4ff 0%, #00d4ff 100%);
color: #000;
}
.filter-label {
font-size: 12px;
color: #888;
margin-right: 5px;
}
.filter-hint {
font-size: 10px;
color: #666;
display: block;
margin-top: 2px;
accent-color: var(--primary);
}
.btn-query {
background: #00d4ff;
color: #1a1a2e;
background: var(--primary);
color: white;
border: none;
padding: 10px 25px;
border-radius: 20px;
font-size: 14px;
font-weight: bold;
padding: 9px 20px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
transition: background 0.2s ease;
}
.btn-query:hover {
background: #00a8cc;
transform: scale(1.05);
background: var(--primary-dark);
}
.btn-query:disabled {
background: #555;
background: #a6b0f5;
cursor: not-allowed;
transform: none;
}
/* KPI Cards */
.kpi-row {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 15px;
margin-bottom: 15px;
gap: 14px;
margin-bottom: 16px;
}
.kpi-card {
background: linear-gradient(135deg, #16213e 0%, #1a1a2e 100%);
background: var(--card-bg);
border-radius: 10px;
padding: 20px;
padding: 18px;
text-align: center;
border: 1px solid #333;
transition: transform 0.3s;
}
.kpi-card:hover {
transform: translateY(-3px);
border: 1px solid var(--border);
box-shadow: var(--shadow);
}
.kpi-label {
font-size: 13px;
color: #888;
margin-bottom: 8px;
color: var(--muted);
margin-bottom: 6px;
}
.kpi-value {
font-size: 36px;
font-size: 30px;
font-weight: bold;
}
.kpi-value.green { color: #00ff88; }
.kpi-value.blue { color: #00d4ff; }
.kpi-value.red { color: #ff4757; }
.kpi-value.yellow { color: #ffc107; }
.kpi-value.green { color: var(--success); }
.kpi-value.blue { color: var(--primary); }
.kpi-value.red { color: var(--danger); }
.kpi-value.yellow { color: var(--warning); }
.kpi-sub {
font-size: 12px;
color: #666;
margin-top: 5px;
color: var(--muted);
margin-top: 4px;
}
/* Workcenter Cards Grid */
.workcenter-section {
margin-bottom: 15px;
margin-bottom: 16px;
}
.section-title {
font-size: 16px;
color: #00d4ff;
color: var(--primary);
margin-bottom: 10px;
padding-left: 10px;
border-left: 3px solid #00d4ff;
border-left: 3px solid var(--primary);
}
.workcenter-grid {
@@ -215,33 +188,34 @@
}
.wc-card {
background: #16213e;
background: var(--card-bg);
border-radius: 8px;
padding: 15px;
padding: 14px;
cursor: pointer;
transition: all 0.3s;
border: 2px solid transparent;
transition: all 0.2s;
border: 1px solid var(--border);
box-shadow: var(--shadow);
}
.wc-card:hover {
border-color: #00d4ff;
border-color: var(--primary);
transform: translateY(-2px);
}
.wc-card.selected {
border-color: #00ff88;
background: #1a3a5c;
border-color: var(--success);
box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.25);
}
.wc-card.has-issue {
border-left: 4px solid #ff4757;
border-left: 4px solid var(--danger);
}
.wc-name {
font-size: 14px;
font-weight: bold;
color: #fff;
margin-bottom: 10px;
font-weight: 600;
color: var(--text);
margin-bottom: 8px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -254,51 +228,53 @@
}
.wc-ou {
font-size: 24px;
font-size: 22px;
font-weight: bold;
}
.wc-ou.high { color: #00ff88; }
.wc-ou.medium { color: #ffc107; }
.wc-ou.low { color: #ff4757; }
.wc-ou.high { color: var(--success); }
.wc-ou.medium { color: var(--warning); }
.wc-ou.low { color: var(--danger); }
.wc-counts {
text-align: right;
font-size: 11px;
color: #888;
color: var(--muted);
}
.wc-counts .down {
color: #ff4757;
color: var(--danger);
font-weight: bold;
}
.wc-mini-chart {
height: 30px;
height: 34px;
margin-top: 8px;
}
/* Detail Table */
.detail-section {
background: #16213e;
background: var(--card-bg);
border-radius: 10px;
padding: 15px;
padding: 16px;
border: 1px solid var(--border);
box-shadow: var(--shadow);
}
.detail-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
margin-bottom: 12px;
}
.detail-title {
font-size: 16px;
color: #00d4ff;
color: var(--primary);
}
.detail-count {
color: #888;
color: var(--muted);
font-size: 13px;
}
@@ -317,25 +293,27 @@
thead {
position: sticky;
top: 0;
background: #0f3460;
background: #eef2ff;
z-index: 1;
}
th {
padding: 10px 8px;
text-align: left;
color: #00d4ff;
color: #3f4aa7;
font-weight: 600;
white-space: nowrap;
border-bottom: 1px solid var(--border);
}
td {
padding: 8px;
border-bottom: 1px solid #2a2a4a;
border-bottom: 1px solid var(--border);
color: var(--text);
}
tbody tr:hover {
background: rgba(0, 212, 255, 0.1);
background: #f1f5ff;
}
.status-badge {
@@ -343,16 +321,16 @@
padding: 3px 10px;
border-radius: 12px;
font-size: 11px;
font-weight: bold;
font-weight: 700;
}
.status-prd { background: #00ff88; color: #000; }
.status-sby { background: #17a2b8; color: #fff; }
.status-udt { background: #ff4757; color: #fff; }
.status-sdt { background: #ffc107; color: #000; }
.status-egt { background: #6c757d; color: #fff; }
.status-nst { background: #9b59b6; color: #fff; }
.status-other { background: #444; color: #fff; }
.status-prd { background: #bbf7d0; color: #166534; }
.status-sby { background: #bfdbfe; color: #1e40af; }
.status-udt { background: #fecaca; color: #991b1b; }
.status-sdt { background: #fef3c7; color: #92400e; }
.status-egt { background: #e2e8f0; color: #475569; }
.status-nst { background: #e9d5ff; color: #6b21a8; }
.status-other { background: #e5e7eb; color: #374151; }
.flag-badge {
display: inline-block;
@@ -362,18 +340,18 @@
margin-right: 3px;
}
.flag-prod { background: #28a745; color: white; }
.flag-key { background: #dc3545; color: white; }
.flag-monitor { background: #17a2b8; color: white; }
.flag-prod { background: #dcfce7; color: #166534; }
.flag-key { background: #fee2e2; color: #991b1b; }
.flag-monitor { background: #dbeafe; color: #1e40af; }
.down-time {
color: #ff4757;
color: var(--danger);
font-weight: bold;
}
.job-info {
font-size: 11px;
color: #888;
color: var(--muted);
}
/* Loading */
@@ -381,8 +359,8 @@
display: inline-block;
width: 18px;
height: 18px;
border: 2px solid #333;
border-top-color: #00d4ff;
border: 2px solid var(--border);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-right: 8px;
@@ -399,7 +377,7 @@
left: 0;
right: 0;
bottom: 0;
background: rgba(26, 26, 46, 0.9);
background: rgba(255, 255, 255, 0.85);
display: flex;
align-items: center;
justify-content: center;
@@ -408,14 +386,14 @@
}
.loading-text {
color: #00d4ff;
color: var(--primary);
font-size: 14px;
}
.placeholder {
text-align: center;
padding: 40px;
color: #666;
color: var(--muted);
}
/* Responsive */
@@ -444,8 +422,12 @@
.workcenter-grid {
grid-template-columns: repeat(2, 1fr);
}
.header {
align-items: flex-start;
}
}
</style>
</head>
<body>
<div class="dashboard">
@@ -459,16 +441,8 @@
<label><input type="checkbox" id="filterKey"> 關鍵設備</label>
<label><input type="checkbox" id="filterMonitor"> 監控設備</label>
</div>
<div class="filter-group" style="flex-direction: column; align-items: flex-start;">
<span class="filter-label">廠區: <span class="filter-hint">(Ctrl+點選多選)</span></span>
<select id="filterLocation" multiple>
</select>
</div>
<div class="filter-group" style="flex-direction: column; align-items: flex-start;">
<span class="filter-label">資產狀態: <span class="filter-hint">(Ctrl+點選多選)</span></span>
<select id="filterAssetsStatus" multiple>
</select>
</div>
<button id="btnQuery" class="btn-query" onclick="loadDashboard()">查詢</button>
</div>
<span id="lastUpdate" class="last-update"></span>
@@ -526,6 +500,7 @@
<th>工作中心</th>
<th>狀態</th>
<th>原因</th>
<th>最後狀態時間</th>
<th>Down Time</th>
<th>批號 (PJ_LOTID)</th>
<th>症狀 (JOB)</th>
@@ -534,7 +509,7 @@
</tr>
</thead>
<tbody id="detailTableBody">
<tr><td colspan="9" class="placeholder">請點擊「查詢」載入資料</td></tr>
<tr><td colspan="10" class="placeholder">請點擊「查詢」載入資料</td></tr>
</tbody>
</table>
</div>
@@ -563,90 +538,12 @@
if (document.getElementById('filterProduction').checked) filters.isProduction = true;
if (document.getElementById('filterKey').checked) filters.isKey = true;
if (document.getElementById('filterMonitor').checked) filters.isMonitor = true;
filters.days_back = 365;
// 多選廠區
const locationSelect = document.getElementById('filterLocation');
const selectedLocations = Array.from(locationSelect.selectedOptions).map(opt => opt.value);
if (selectedLocations.length > 0) {
filters.locations = selectedLocations;
}
// 多選資產狀態
const assetsStatusSelect = document.getElementById('filterAssetsStatus');
const selectedStatuses = Array.from(assetsStatusSelect.selectedOptions).map(opt => opt.value);
if (selectedStatuses.length > 0) {
filters.assetsStatuses = selectedStatuses;
}
return Object.keys(filters).length > 0 ? filters : null;
}
async function loadFilterOptions() {
console.log('loadFilterOptions() 開始執行');
console.log('當前 URL:', window.location.href);
try {
const apiUrl = '/api/resource/filter_options';
console.log('準備呼叫:', apiUrl);
const response = await fetch(apiUrl);
console.log('API 回應狀態:', response.status, response.statusText);
if (!response.ok) {
console.error('API 回應錯誤:', response.status, response.statusText);
return;
}
const result = await response.json();
console.log('Filter options API response:', result);
if (result.success) {
const data = result.data;
console.log('locations:', data.locations);
console.log('assets_statuses:', data.assets_statuses);
// 載入 LOCATIONNAME 選項 (多選)
const locationSelect = document.getElementById('filterLocation');
locationSelect.innerHTML = ''; // 清空
if (data.locations && data.locations.length > 0) {
data.locations.forEach(loc => {
const option = document.createElement('option');
option.value = loc;
option.textContent = loc;
locationSelect.appendChild(option);
});
} else {
const option = document.createElement('option');
option.value = '';
option.textContent = '(無資料)';
option.disabled = true;
locationSelect.appendChild(option);
}
// 載入 PJ_ASSETSSTATUS 選項 (多選)
const assetsStatusSelect = document.getElementById('filterAssetsStatus');
assetsStatusSelect.innerHTML = ''; // 清空
if (data.assets_statuses && data.assets_statuses.length > 0) {
data.assets_statuses.forEach(status => {
const option = document.createElement('option');
option.value = status;
option.textContent = status;
assetsStatusSelect.appendChild(option);
});
} else {
const option = document.createElement('option');
option.value = '';
option.textContent = '(無資料)';
option.disabled = true;
assetsStatusSelect.appendChild(option);
}
} else {
console.error('API 回傳失敗:', result.error);
}
} catch (error) {
console.error('篩選選項載入失敗:', error);
console.error('錯誤詳情:', error.message, error.stack);
}
}
function formatNumber(num) {
if (num === null || num === undefined) return '-';
return num.toLocaleString('zh-TW');
@@ -939,7 +836,7 @@
const title = document.getElementById('detailTitle');
const count = document.getElementById('detailCount');
tbody.innerHTML = '<tr><td colspan="9" class="placeholder"><span class="loading-spinner"></span>載入中...</td></tr>';
tbody.innerHTML = '<tr><td colspan="10" class="placeholder"><span class="loading-spinner"></span>載入中...</td></tr>';
const filters = getFilters() || {};
if (selectedWorkcenter) {
@@ -957,7 +854,7 @@
const response = await fetch('/api/dashboard/detail', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filters: Object.keys(filters).length > 0 ? filters : null, limit: 200 })
body: JSON.stringify({ filters: Object.keys(filters).length > 0 ? filters : null, limit: 200, offset: 0 })
});
const result = await response.json();
@@ -965,7 +862,7 @@
count.textContent = `(${result.count} 筆)`;
if (result.data.length === 0) {
tbody.innerHTML = '<tr><td colspan="9" class="placeholder">查無資料</td></tr>';
tbody.innerHTML = '<tr><td colspan="10" class="placeholder">查無資料</td></tr>';
return;
}
@@ -988,6 +885,7 @@
<td>${row.WORKCENTERNAME || '-'}</td>
<td><span class="status-badge ${statusClass}">${row.NEWSTATUSNAME || '-'}</span></td>
<td>${row.NEWREASONNAME || '-'}</td>
<td>${row.LASTSTATUSCHANGEDATE || '-'}</td>
<td class="${row.DOWN_MINUTES > 0 ? 'down-time' : ''}">${downTime}</td>
<td class="job-info">${row.PJ_LOTID || '-'}</td>
<td class="job-info">${row.SYMPTOMCODENAME || '-'}</td>
@@ -998,10 +896,10 @@
});
tbody.innerHTML = html;
} else {
tbody.innerHTML = `<tr><td colspan="9" class="placeholder">${result.error}</td></tr>`;
tbody.innerHTML = `<tr><td colspan="10" class="placeholder">${result.error}</td></tr>`;
}
} catch (error) {
tbody.innerHTML = `<tr><td colspan="9" class="placeholder">載入失敗: ${error.message}</td></tr>`;
tbody.innerHTML = `<tr><td colspan="10" class="placeholder">載入失敗: ${error.message}</td></tr>`;
}
}
@@ -1029,29 +927,6 @@
}
// Page init - 使用標記確保只執行一次
let filterOptionsLoaded = false;
function initFilterOptions() {
if (filterOptionsLoaded) {
console.log('篩選選項已經載入過,跳過');
return;
}
filterOptionsLoaded = true;
console.log('initFilterOptions() - 開始載入篩選選項');
loadFilterOptions();
}
// 使用 DOMContentLoaded
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function() {
console.log('DOMContentLoaded 事件觸發');
initFilterOptions();
});
} else {
// DOM 已經準備好,直接執行
console.log('DOM 已準備好,直接初始化');
initFilterOptions();
}
</script>
</script>
</body>
</html>

View File

@@ -1,3 +1,4 @@
oracledb>=2.0.0
flask>=3.0.0
pandas>=2.0.0
pandas>=2.0.0
sqlalchemy>=2.0.0