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:
373
apps/portal.py
373
apps/portal.py
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
oracledb>=2.0.0
|
||||
flask>=3.0.0
|
||||
pandas>=2.0.0
|
||||
pandas>=2.0.0
|
||||
sqlalchemy>=2.0.0
|
||||
|
||||
Reference in New Issue
Block a user