fix: 修正 DOWN 機台明細查詢與顯示邏輯
1. 修正 Oracle ORA-32034 錯誤 - 將巢狀 WITH 子句改為同層級 CTE 2. Last Update 改用資料庫最新 LASTSTATUSCHANGEDATE,非系統時間 3. Down Time 改為 MAX(LASTSTATUSCHANGEDATE) - 各機台時間差 4. 機台明細表預設只顯示 UDT/SDT (DOWN 狀態) 5. 標題更新為 "DOWN 機台明細" Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
103
apps/portal.py
103
apps/portal.py
@@ -1455,11 +1455,25 @@ def query_resource_detail_with_job(filters=None, limit=200, offset=0):
|
|||||||
- 工單 (PJ_LOTID): 來自 DW_MES_RESOURCE.PJ_LOTID
|
- 工單 (PJ_LOTID): 來自 DW_MES_RESOURCE.PJ_LOTID
|
||||||
- 症狀 (SYMPTOMCODENAME): 來自 DW_MES_JOB.SYMPTOMCODENAME (透過 JOBID 關聯)
|
- 症狀 (SYMPTOMCODENAME): 來自 DW_MES_JOB.SYMPTOMCODENAME (透過 JOBID 關聯)
|
||||||
- 原因碼 (CAUSECODENAME): 來自 DW_MES_JOB.CAUSECODENAME (透過 JOBID 關聯)
|
- 原因碼 (CAUSECODENAME): 來自 DW_MES_JOB.CAUSECODENAME (透過 JOBID 關聯)
|
||||||
- DownTime: 計算自 LASTSTATUSCHANGEDATE 到現在的時間差 (分鐘)
|
- DownTime: 計算自最新的 LASTSTATUSCHANGEDATE - 每台機台自己的 LASTSTATUSCHANGEDATE (分鐘)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- DataFrame with detail records
|
||||||
|
- Also includes MAX_STATUS_TIME for Last Update display
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
days_back = get_days_back(filters)
|
days_back = get_days_back(filters)
|
||||||
base_sql = get_resource_latest_status_subquery(days_back)
|
|
||||||
|
# 建立篩選條件
|
||||||
|
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}'))"
|
||||||
|
|
||||||
where_conditions = []
|
where_conditions = []
|
||||||
if filters:
|
if filters:
|
||||||
@@ -1499,14 +1513,70 @@ def query_resource_detail_with_job(filters=None, limit=200, offset=0):
|
|||||||
status_list = "', '".join(filters['assetsStatuses'])
|
status_list = "', '".join(filters['assetsStatuses'])
|
||||||
where_conditions.append(f"rs.PJ_ASSETSSTATUS IN ('{status_list}')")
|
where_conditions.append(f"rs.PJ_ASSETSSTATUS IN ('{status_list}')")
|
||||||
|
|
||||||
|
# 預設只顯示 DOWN 狀態 (UDT, SDT)
|
||||||
|
where_conditions.append("rs.NEWSTATUSNAME IN ('UDT', 'SDT')")
|
||||||
|
|
||||||
where_clause = " AND ".join(where_conditions) if where_conditions else "1=1"
|
where_clause = " AND ".join(where_conditions) if where_conditions else "1=1"
|
||||||
|
|
||||||
# Left join with JOB table for SDT/UDT details
|
# Left join with JOB table for SDT/UDT details
|
||||||
# PJ_LOTID 來自 RESOURCE 表
|
# PJ_LOTID 來自 RESOURCE 表
|
||||||
# SYMPTOMCODENAME, CAUSECODENAME 來自 JOB 表
|
# SYMPTOMCODENAME, CAUSECODENAME 來自 JOB 表
|
||||||
|
# DOWN_MINUTES: 使用全體最大 LASTSTATUSCHANGEDATE - 每台機台自己的時間
|
||||||
|
# 注意: 將所有 CTE 放在同一層級,避免巢狀 WITH 子句 (Oracle 不支援)
|
||||||
start_row = offset + 1
|
start_row = offset + 1
|
||||||
end_row = offset + limit
|
end_row = offset + limit
|
||||||
sql = f"""
|
sql = f"""
|
||||||
|
WITH latest_txn AS (
|
||||||
|
SELECT MAX(COALESCE(TXNDATE, LASTSTATUSCHANGEDATE)) AS MAX_TXNDATE
|
||||||
|
FROM DW_MES_RESOURCESTATUS
|
||||||
|
),
|
||||||
|
base_data AS (
|
||||||
|
SELECT *
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
r.RESOURCEID,
|
||||||
|
r.RESOURCENAME,
|
||||||
|
r.OBJECTCATEGORY,
|
||||||
|
r.OBJECTTYPE,
|
||||||
|
r.RESOURCEFAMILYNAME,
|
||||||
|
r.WORKCENTERNAME,
|
||||||
|
r.LOCATIONNAME,
|
||||||
|
r.VENDORNAME,
|
||||||
|
r.VENDORMODEL,
|
||||||
|
r.PJ_DEPARTMENT,
|
||||||
|
r.PJ_ASSETSSTATUS,
|
||||||
|
r.PJ_ISPRODUCTION,
|
||||||
|
r.PJ_ISKEY,
|
||||||
|
r.PJ_ISMONITOR,
|
||||||
|
r.PJ_LOTID,
|
||||||
|
r.DESCRIPTION,
|
||||||
|
s.NEWSTATUSNAME,
|
||||||
|
s.NEWREASONNAME,
|
||||||
|
s.LASTSTATUSCHANGEDATE,
|
||||||
|
s.OLDSTATUSNAME,
|
||||||
|
s.OLDREASONNAME,
|
||||||
|
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
|
||||||
|
),
|
||||||
|
max_time AS (
|
||||||
|
SELECT MAX(LASTSTATUSCHANGEDATE) AS MAX_STATUS_TIME FROM base_data
|
||||||
|
)
|
||||||
SELECT * FROM (
|
SELECT * FROM (
|
||||||
SELECT
|
SELECT
|
||||||
rs.RESOURCENAME,
|
rs.RESOURCENAME,
|
||||||
@@ -1530,7 +1600,8 @@ def query_resource_detail_with_job(filters=None, limit=200, offset=0):
|
|||||||
j.REPAIRCODENAME,
|
j.REPAIRCODENAME,
|
||||||
j.CREATEDATE as JOB_CREATEDATE,
|
j.CREATEDATE as JOB_CREATEDATE,
|
||||||
j.FIRSTCLOCKONDATE,
|
j.FIRSTCLOCKONDATE,
|
||||||
ROUND((SYSDATE - rs.LASTSTATUSCHANGEDATE) * 24 * 60, 0) as DOWN_MINUTES,
|
mt.MAX_STATUS_TIME,
|
||||||
|
ROUND((mt.MAX_STATUS_TIME - rs.LASTSTATUSCHANGEDATE) * 24 * 60, 0) as DOWN_MINUTES,
|
||||||
ROW_NUMBER() OVER (
|
ROW_NUMBER() OVER (
|
||||||
ORDER BY
|
ORDER BY
|
||||||
CASE rs.NEWSTATUSNAME
|
CASE rs.NEWSTATUSNAME
|
||||||
@@ -1540,25 +1611,33 @@ def query_resource_detail_with_job(filters=None, limit=200, offset=0):
|
|||||||
END,
|
END,
|
||||||
rs.LASTSTATUSCHANGEDATE DESC NULLS LAST
|
rs.LASTSTATUSCHANGEDATE DESC NULLS LAST
|
||||||
) AS rn
|
) AS rn
|
||||||
FROM ({base_sql}) rs
|
FROM base_data rs
|
||||||
|
CROSS JOIN max_time mt
|
||||||
LEFT JOIN DW_MES_JOB j ON rs.JOBID = j.JOBID
|
LEFT JOIN DW_MES_JOB j ON rs.JOBID = j.JOBID
|
||||||
WHERE {where_clause}
|
WHERE {where_clause}
|
||||||
) WHERE rn BETWEEN {start_row} AND {end_row}
|
) WHERE rn BETWEEN {start_row} AND {end_row}
|
||||||
"""
|
"""
|
||||||
df = read_sql_df(sql)
|
df = read_sql_df(sql)
|
||||||
|
|
||||||
|
# Get max_status_time for Last Update display
|
||||||
|
max_status_time = None
|
||||||
|
if 'MAX_STATUS_TIME' in df.columns and len(df) > 0:
|
||||||
|
max_status_time = df['MAX_STATUS_TIME'].iloc[0]
|
||||||
|
if pd.notna(max_status_time):
|
||||||
|
max_status_time = max_status_time.strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
|
||||||
# Convert datetime columns
|
# Convert datetime columns
|
||||||
datetime_cols = ['LASTSTATUSCHANGEDATE', 'JOB_CREATEDATE', 'FIRSTCLOCKONDATE']
|
datetime_cols = ['LASTSTATUSCHANGEDATE', 'JOB_CREATEDATE', 'FIRSTCLOCKONDATE', 'MAX_STATUS_TIME']
|
||||||
for col in datetime_cols:
|
for col in datetime_cols:
|
||||||
if col in df.columns:
|
if col in df.columns:
|
||||||
df[col] = df[col].apply(
|
df[col] = df[col].apply(
|
||||||
lambda x: x.strftime('%Y-%m-%d %H:%M:%S') if pd.notna(x) else None
|
lambda x: x.strftime('%Y-%m-%d %H:%M:%S') if pd.notna(x) else None
|
||||||
)
|
)
|
||||||
|
|
||||||
return df
|
return df, max_status_time
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print(f"明細查詢失敗: {exc}")
|
print(f"明細查詢失敗: {exc}")
|
||||||
return None
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/dashboard/kpi', methods=['POST'])
|
@app.route('/api/dashboard/kpi', methods=['POST'])
|
||||||
@@ -1605,10 +1684,16 @@ def api_dashboard_detail():
|
|||||||
limit = data.get('limit', 200)
|
limit = data.get('limit', 200)
|
||||||
offset = data.get('offset', 0)
|
offset = data.get('offset', 0)
|
||||||
|
|
||||||
df = query_resource_detail_with_job(filters, limit, offset)
|
df, max_status_time = query_resource_detail_with_job(filters, limit, offset)
|
||||||
if df is not None:
|
if df is not None:
|
||||||
records = df.to_dict(orient='records')
|
records = df.to_dict(orient='records')
|
||||||
return jsonify({'success': True, 'data': records, 'count': len(records), 'offset': offset})
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'data': records,
|
||||||
|
'count': len(records),
|
||||||
|
'offset': offset,
|
||||||
|
'max_status_time': max_status_time
|
||||||
|
})
|
||||||
return jsonify({'success': False, 'error': '查詢失敗'}), 500
|
return jsonify({'success': False, 'error': '查詢失敗'}), 500
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -489,7 +489,7 @@
|
|||||||
<!-- Detail Table -->
|
<!-- Detail Table -->
|
||||||
<div class="detail-section" style="position: relative;">
|
<div class="detail-section" style="position: relative;">
|
||||||
<div class="detail-header">
|
<div class="detail-header">
|
||||||
<div class="detail-title" id="detailTitle">機台明細</div>
|
<div class="detail-title" id="detailTitle">DOWN 機台明細 (UDT/SDT)</div>
|
||||||
<div class="detail-count" id="detailCount"></div>
|
<div class="detail-count" id="detailCount"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-container">
|
<div class="table-container">
|
||||||
@@ -845,9 +845,9 @@
|
|||||||
if (selectedOriginalWcs && selectedOriginalWcs.length > 0) {
|
if (selectedOriginalWcs && selectedOriginalWcs.length > 0) {
|
||||||
filters.original_wcs = selectedOriginalWcs;
|
filters.original_wcs = selectedOriginalWcs;
|
||||||
}
|
}
|
||||||
title.textContent = `機台明細 - ${selectedWorkcenter}`;
|
title.textContent = `DOWN 機台明細 - ${selectedWorkcenter}`;
|
||||||
} else {
|
} else {
|
||||||
title.textContent = '機台明細 (全部)';
|
title.textContent = 'DOWN 機台明細 (全部)';
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -861,6 +861,11 @@
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
count.textContent = `(${result.count} 筆)`;
|
count.textContent = `(${result.count} 筆)`;
|
||||||
|
|
||||||
|
// 更新 Last Update 使用 API 返回的最新狀態時間
|
||||||
|
if (result.max_status_time) {
|
||||||
|
document.getElementById('lastUpdate').textContent = `Last Update: ${result.max_status_time}`;
|
||||||
|
}
|
||||||
|
|
||||||
if (result.data.length === 0) {
|
if (result.data.length === 0) {
|
||||||
tbody.innerHTML = '<tr><td colspan="10" class="placeholder">查無資料</td></tr>';
|
tbody.innerHTML = '<tr><td colspan="10" class="placeholder">查無資料</td></tr>';
|
||||||
return;
|
return;
|
||||||
@@ -878,7 +883,7 @@
|
|||||||
|
|
||||||
// PJ_LOTID 來自 DW_MES_RESOURCE
|
// PJ_LOTID 來自 DW_MES_RESOURCE
|
||||||
// SYMPTOMCODENAME, CAUSECODENAME 來自 DW_MES_JOB (透過 JOBID 關聯)
|
// SYMPTOMCODENAME, CAUSECODENAME 來自 DW_MES_JOB (透過 JOBID 關聯)
|
||||||
// DOWN_MINUTES 計算自 LASTSTATUSCHANGEDATE 到現在
|
// DOWN_MINUTES 使用最新 LASTSTATUSCHANGEDATE - 每台機台自己的時間
|
||||||
html += `
|
html += `
|
||||||
<tr>
|
<tr>
|
||||||
<td><strong>${row.RESOURCENAME || '-'}</strong></td>
|
<td><strong>${row.RESOURCENAME || '-'}</strong></td>
|
||||||
@@ -911,7 +916,7 @@
|
|||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.innerHTML = '<span class="loading-spinner"></span>查詢中...';
|
btn.innerHTML = '<span class="loading-spinner"></span>查詢中...';
|
||||||
|
|
||||||
document.getElementById('lastUpdate').textContent = `Last Update: ${new Date().toLocaleTimeString('zh-TW')}`;
|
// Last Update 會由 loadDetail() 中的 API 回應更新
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
|
|||||||
Reference in New Issue
Block a user