feat: 新增 OU% 趨勢圖與工站熱力圖,修正 OU 計算邏輯

- 新增 query_ou_trend() 使用 SHIFT 表計算基於時間的 OU%
- 新增 query_utilization_heatmap() 提供工站利用率熱力圖資料
- OU% 計算公式: PRD / (PRD + SBY + EGT + SDT + UDT) × 100
  - 使用 TXNDATE 和 OLDSTATUSNAME 欄位
  - NST 不納入分母計算
  - 精度調整為小數點後 2 位
- 前端新增趨勢圖與熱力圖視覺化元件
- 熱力圖 Y 軸依工站群組排序 (切割→測試)
- 修正 RESOURCESTATUS_SHIFT 時間欄位為 DATADATE

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
ymirliu
2026-01-19 08:08:15 +08:00
parent cac2017e9f
commit 0c2c253911
3 changed files with 556 additions and 4 deletions

View File

@@ -62,7 +62,7 @@ TABLES_CONFIG = {
'name': 'DW_MES_RESOURCESTATUS_SHIFT',
'display_name': 'RESOURCESTATUS_SHIFT (資源班次狀態)',
'row_count': 74155046,
'time_field': 'SHIFTDATE',
'time_field': 'DATADATE',
'description': '設備狀態班次彙總表 - 班次級狀態/工時'
},
{
@@ -1699,6 +1699,223 @@ def api_dashboard_detail():
return jsonify({'success': False, 'error': '查詢失敗'}), 500
def query_ou_trend(days=7, filters=None):
"""Query OU% trend by date using RESOURCESTATUS_SHIFT table.
Uses HOURS field to calculate actual time-based OU%.
OU% = PRD_HOURS / (PRD + SBY + EGT + SDT + UDT) * 100
Args:
days: Number of days to query (default 7)
filters: Optional filters (isProduction, isKey, isMonitor)
Returns:
List of {date, ou_pct, prd_hours, total_hours} records
"""
try:
# Build location and asset status filters
location_filter = ""
if EXCLUDED_LOCATIONS:
excluded_locations = "', '".join(EXCLUDED_LOCATIONS)
location_filter = f"AND (ss.LOCATIONNAME IS NULL OR ss.LOCATIONNAME NOT IN ('{excluded_locations}'))"
asset_status_filter = ""
if EXCLUDED_ASSET_STATUSES:
excluded_assets = "', '".join(EXCLUDED_ASSET_STATUSES)
asset_status_filter = f"AND (ss.PJ_ASSETSSTATUS IS NULL OR ss.PJ_ASSETSSTATUS NOT IN ('{excluded_assets}'))"
# Build filter conditions for equipment flags
flag_conditions = []
if filters:
if filters.get('isProduction'):
flag_conditions.append("r.PJ_ISPRODUCTION = 1")
if filters.get('isKey'):
flag_conditions.append("r.PJ_ISKEY = 1")
if filters.get('isMonitor'):
flag_conditions.append("r.PJ_ISMONITOR = 1")
flag_filter = ""
if flag_conditions:
flag_filter = "AND " + " AND ".join(flag_conditions)
sql = f"""
SELECT
TRUNC(ss.TXNDATE) as DATA_DATE,
SUM(CASE WHEN ss.OLDSTATUSNAME = 'PRD' THEN ss.HOURS ELSE 0 END) as PRD_HOURS,
SUM(CASE WHEN ss.OLDSTATUSNAME = 'SBY' THEN ss.HOURS ELSE 0 END) as SBY_HOURS,
SUM(CASE WHEN ss.OLDSTATUSNAME = 'UDT' THEN ss.HOURS ELSE 0 END) as UDT_HOURS,
SUM(CASE WHEN ss.OLDSTATUSNAME = 'SDT' THEN ss.HOURS ELSE 0 END) as SDT_HOURS,
SUM(CASE WHEN ss.OLDSTATUSNAME = 'EGT' THEN ss.HOURS ELSE 0 END) as EGT_HOURS,
SUM(ss.HOURS) as TOTAL_HOURS
FROM DW_MES_RESOURCESTATUS_SHIFT ss
JOIN DW_MES_RESOURCE r ON ss.HISTORYID = r.RESOURCEID
WHERE ss.TXNDATE >= TRUNC(SYSDATE) - {days}
AND ss.TXNDATE < TRUNC(SYSDATE)
AND ((r.OBJECTCATEGORY = 'ASSEMBLY' AND r.OBJECTTYPE = 'ASSEMBLY')
OR (r.OBJECTCATEGORY = 'WAFERSORT' AND r.OBJECTTYPE = 'WAFERSORT'))
{location_filter}
{asset_status_filter}
{flag_filter}
GROUP BY TRUNC(ss.TXNDATE)
ORDER BY DATA_DATE
"""
df = read_sql_df(sql)
result = []
for _, row in df.iterrows():
prd = float(row['PRD_HOURS'] or 0)
sby = float(row['SBY_HOURS'] or 0)
udt = float(row['UDT_HOURS'] or 0)
sdt = float(row['SDT_HOURS'] or 0)
egt = float(row['EGT_HOURS'] or 0)
# OU% denominator: PRD + SBY + EGT + SDT + UDT (excludes NST)
denominator = prd + sby + egt + sdt + udt
ou_pct = round((prd / denominator * 100), 2) if denominator > 0 else 0
result.append({
'date': row['DATA_DATE'].strftime('%Y-%m-%d') if pd.notna(row['DATA_DATE']) else None,
'ou_pct': ou_pct,
'prd_hours': round(prd, 1),
'sby_hours': round(sby, 1),
'udt_hours': round(udt, 1),
'sdt_hours': round(sdt, 1),
'egt_hours': round(egt, 1),
'total_hours': round(float(row['TOTAL_HOURS'] or 0), 1)
})
return result
except Exception as exc:
print(f"OU趨勢查詢失敗: {exc}")
import traceback
traceback.print_exc()
return None
def query_utilization_heatmap(days=7, filters=None):
"""Query equipment utilization heatmap data by workcenter and date.
Uses HOURS field to calculate PRD% per workcenter per day.
Args:
days: Number of days to query (default 7)
filters: Optional filters (isProduction, isKey, isMonitor)
Returns:
List of {workcenter, date, prd_pct, prd_hours, total_hours} records
"""
try:
# Build location and asset status filters
location_filter = ""
if EXCLUDED_LOCATIONS:
excluded_locations = "', '".join(EXCLUDED_LOCATIONS)
location_filter = f"AND (ss.LOCATIONNAME IS NULL OR ss.LOCATIONNAME NOT IN ('{excluded_locations}'))"
asset_status_filter = ""
if EXCLUDED_ASSET_STATUSES:
excluded_assets = "', '".join(EXCLUDED_ASSET_STATUSES)
asset_status_filter = f"AND (ss.PJ_ASSETSSTATUS IS NULL OR ss.PJ_ASSETSSTATUS NOT IN ('{excluded_assets}'))"
# Build filter conditions for equipment flags
flag_conditions = []
if filters:
if filters.get('isProduction'):
flag_conditions.append("r.PJ_ISPRODUCTION = 1")
if filters.get('isKey'):
flag_conditions.append("r.PJ_ISKEY = 1")
if filters.get('isMonitor'):
flag_conditions.append("r.PJ_ISMONITOR = 1")
flag_filter = ""
if flag_conditions:
flag_filter = "AND " + " AND ".join(flag_conditions)
sql = f"""
SELECT
ss.WORKCENTERNAME,
TRUNC(ss.TXNDATE) as DATA_DATE,
SUM(CASE WHEN ss.OLDSTATUSNAME = 'PRD' THEN ss.HOURS ELSE 0 END) as PRD_HOURS,
SUM(CASE WHEN ss.OLDSTATUSNAME IN ('PRD', 'SBY', 'UDT', 'SDT', 'EGT') THEN ss.HOURS ELSE 0 END) as AVAIL_HOURS
FROM DW_MES_RESOURCESTATUS_SHIFT ss
JOIN DW_MES_RESOURCE r ON ss.HISTORYID = r.RESOURCEID
WHERE ss.TXNDATE >= TRUNC(SYSDATE) - {days}
AND ss.TXNDATE < TRUNC(SYSDATE)
AND ss.WORKCENTERNAME IS NOT NULL
AND ((r.OBJECTCATEGORY = 'ASSEMBLY' AND r.OBJECTTYPE = 'ASSEMBLY')
OR (r.OBJECTCATEGORY = 'WAFERSORT' AND r.OBJECTTYPE = 'WAFERSORT'))
{location_filter}
{asset_status_filter}
{flag_filter}
GROUP BY ss.WORKCENTERNAME, TRUNC(ss.TXNDATE)
ORDER BY ss.WORKCENTERNAME, DATA_DATE
"""
df = read_sql_df(sql)
# Group by workcenter for heatmap format
result = []
for _, row in df.iterrows():
prd = float(row['PRD_HOURS'] or 0)
avail = float(row['AVAIL_HOURS'] or 0)
prd_pct = round((prd / avail * 100), 2) if avail > 0 else 0
wc_name = row['WORKCENTERNAME']
# Apply workcenter grouping
group_name, _ = get_workcenter_group(wc_name)
result.append({
'workcenter': wc_name,
'group': group_name,
'date': row['DATA_DATE'].strftime('%Y-%m-%d') if pd.notna(row['DATA_DATE']) else None,
'prd_pct': prd_pct,
'prd_hours': round(prd, 1),
'avail_hours': round(avail, 1)
})
return result
except Exception as exc:
print(f"利用率熱力圖查詢失敗: {exc}")
import traceback
traceback.print_exc()
return None
@app.route('/api/dashboard/ou_trend', methods=['POST'])
def api_dashboard_ou_trend():
"""API: OU% trend data for line chart."""
data = request.get_json() or {}
filters = data.get('filters')
days = data.get('days', 7)
days_back = get_days_back(filters)
cache_key = make_cache_key("dashboard_ou_trend", days, filters)
trend = cache_get(cache_key)
if trend is None:
trend = query_ou_trend(days, filters)
if trend is not None:
cache_set(cache_key, trend, ttl=300) # 5 min cache
if trend is not None:
return jsonify({'success': True, 'data': trend})
return jsonify({'success': False, 'error': '查詢失敗'}), 500
@app.route('/api/dashboard/utilization_heatmap', methods=['POST'])
def api_dashboard_utilization_heatmap():
"""API: Utilization heatmap data."""
data = request.get_json() or {}
filters = data.get('filters')
days = data.get('days', 7)
cache_key = make_cache_key("dashboard_heatmap", days, filters)
heatmap = cache_get(cache_key)
if heatmap is None:
heatmap = query_utilization_heatmap(days, filters)
if heatmap is not None:
cache_set(cache_key, heatmap, ttl=300) # 5 min cache
if heatmap is not None:
return jsonify({'success': True, 'data': heatmap})
return jsonify({'success': False, 'error': '查詢失敗'}), 500
if __name__ == '__main__':
print("正在測試數據庫連接...")
conn = get_db_connection()

View File

@@ -60,7 +60,7 @@ TABLES_CONFIG = {
'name': 'DW_MES_RESOURCESTATUS_SHIFT',
'display_name': 'RESOURCESTATUS_SHIFT (資源班次狀態)',
'row_count': 74155046,
'time_field': 'SHIFTDATE',
'time_field': 'DATADATE',
'description': '設備狀態班次彙總表 - 班次級狀態/工時'
},
{

View File

@@ -252,6 +252,51 @@
margin-top: 8px;
}
/* Charts Section */
.charts-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 16px;
}
.chart-card {
background: var(--card-bg);
border-radius: 10px;
padding: 16px;
border: 1px solid var(--border);
box-shadow: var(--shadow);
}
.chart-title {
font-size: 14px;
font-weight: 600;
color: var(--primary);
margin-bottom: 12px;
display: flex;
align-items: center;
justify-content: space-between;
}
.chart-title select {
font-size: 12px;
padding: 4px 8px;
border: 1px solid var(--border);
border-radius: 4px;
background: white;
cursor: pointer;
}
.chart-container {
height: 280px;
}
@media (max-width: 1200px) {
.charts-row {
grid-template-columns: 1fr;
}
}
/* Loading */
.loading-spinner {
display: inline-block;
@@ -376,9 +421,35 @@
</div>
</div>
<!-- Charts Row: Trend + Heatmap -->
<div class="charts-row">
<div class="chart-card">
<div class="chart-title">
<span>稼動率趨勢 (OU%)</span>
<select id="trendDays" onchange="loadOuTrend()">
<option value="7" selected>過去 7 天</option>
<option value="14">過去 14 天</option>
<option value="30">過去 30 天</option>
</select>
</div>
<div class="chart-container" id="ouTrendChart"></div>
</div>
<div class="chart-card">
<div class="chart-title">
<span>工站利用率熱力圖</span>
<select id="heatmapDays" onchange="loadHeatmap()">
<option value="7" selected>過去 7 天</option>
<option value="14">過去 14 天</option>
<option value="30">過去 30 天</option>
</select>
</div>
<div class="chart-container" id="heatmapChart"></div>
</div>
</div>
<!-- Workcenter Cards -->
<div class="workcenter-section">
<div class="section-title">各工站狀態 (點選卡片查看明細)</div>
<div class="section-title">各工站即時狀態</div>
<div class="workcenter-grid" id="workcenterGrid">
<div class="placeholder">請點擊「查詢」載入資料</div>
</div>
@@ -392,6 +463,8 @@
let workcenterData = {}; // 快取工站資料
let isLoading = false;
let miniCharts = {}; // 快取 ECharts 實例
let ouTrendChart = null; // OU趨勢圖實例
let heatmapChart = null; // 熱力圖實例
// 狀態顏色定義 (統一)
const STATUS_COLORS = {
@@ -680,6 +753,257 @@
}
// ========== OU% 趨勢圖 ==========
async function loadOuTrend() {
const days = parseInt(document.getElementById('trendDays').value);
const chartDom = document.getElementById('ouTrendChart');
try {
const response = await fetch('/api/dashboard/ou_trend', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filters: getFilters(), days: days })
});
const result = await response.json();
if (result.success && result.data.length > 0) {
renderOuTrendChart(result.data);
} else {
chartDom.innerHTML = '<div class="placeholder">查無資料</div>';
}
} catch (error) {
console.error('OU趨勢載入失敗:', error);
chartDom.innerHTML = '<div class="placeholder">載入失敗</div>';
}
}
function renderOuTrendChart(data) {
const chartDom = document.getElementById('ouTrendChart');
if (ouTrendChart) {
ouTrendChart.dispose();
}
ouTrendChart = echarts.init(chartDom);
const dates = data.map(d => d.date.substring(5)); // MM-DD
const ouValues = data.map(d => d.ou_pct);
const option = {
tooltip: {
trigger: 'axis',
formatter: function(params) {
const idx = params[0].dataIndex;
const d = data[idx];
return `<b>${d.date}</b><br/>
OU%: <b>${d.ou_pct}%</b><br/>
PRD: ${d.prd_hours}h<br/>
SBY: ${d.sby_hours}h<br/>
UDT: ${d.udt_hours}h<br/>
SDT: ${d.sdt_hours}h<br/>
EGT: ${d.egt_hours}h`;
}
},
grid: {
left: 50,
right: 20,
top: 30,
bottom: 30
},
xAxis: {
type: 'category',
data: dates,
axisLabel: { fontSize: 11, color: '#666' }
},
yAxis: {
type: 'value',
min: 0,
max: 100,
axisLabel: { formatter: '{value}%', fontSize: 11, color: '#666' }
},
series: [{
name: 'OU%',
type: 'line',
data: ouValues,
smooth: true,
symbol: 'circle',
symbolSize: 8,
lineStyle: { width: 3, color: '#667eea' },
itemStyle: { color: '#667eea' },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(102, 126, 234, 0.3)' },
{ offset: 1, color: 'rgba(102, 126, 234, 0.05)' }
])
},
markLine: {
silent: true,
data: [
{ yAxis: 80, lineStyle: { color: '#22c55e', type: 'dashed' }, label: { formatter: '80%', position: 'end' } },
{ yAxis: 50, lineStyle: { color: '#f59e0b', type: 'dashed' }, label: { formatter: '50%', position: 'end' } }
]
}
}]
};
ouTrendChart.setOption(option);
}
// ========== 工站利用率熱力圖 ==========
async function loadHeatmap() {
const days = parseInt(document.getElementById('heatmapDays').value);
const chartDom = document.getElementById('heatmapChart');
try {
const response = await fetch('/api/dashboard/utilization_heatmap', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filters: getFilters(), days: days })
});
const result = await response.json();
if (result.success && result.data.length > 0) {
renderHeatmapChart(result.data, days);
} else {
chartDom.innerHTML = '<div class="placeholder">查無資料</div>';
}
} catch (error) {
console.error('熱力圖載入失敗:', error);
chartDom.innerHTML = '<div class="placeholder">載入失敗</div>';
}
}
function renderHeatmapChart(data, days) {
const chartDom = document.getElementById('heatmapChart');
if (heatmapChart) {
heatmapChart.dispose();
}
heatmapChart = echarts.init(chartDom);
// 工站排序順序定義 (與後端 WORKCENTER_GROUPS 對應)
const GROUP_ORDER = {
'切割': 0,
'焊接_DB': 1,
'焊接_WB': 2,
'焊接_DW': 3,
'成型': 4,
'去膠': 5,
'水吹砂': 6,
'電鍍': 7,
'移印': 8,
'切彎腳': 9,
'元件切割': 10,
'測試': 11
};
// 取得工站排序值
function getGroupOrder(groupName) {
if (GROUP_ORDER.hasOwnProperty(groupName)) {
return GROUP_ORDER[groupName];
}
return 999; // 未定義的放最後
}
// 整理資料:按群組聚合
const groupedData = {};
const allDates = new Set();
data.forEach(d => {
const group = d.group || d.workcenter;
if (!groupedData[group]) {
groupedData[group] = {};
}
if (!groupedData[group][d.date]) {
groupedData[group][d.date] = { prd: 0, avail: 0 };
}
groupedData[group][d.date].prd += d.prd_hours;
groupedData[group][d.date].avail += d.avail_hours;
allDates.add(d.date);
});
// 排序:日期升序,工站按指定順序 (order 小的在上方ECharts Y軸需反轉)
const dates = Array.from(allDates).sort();
const groups = Object.keys(groupedData).sort((a, b) => {
const orderA = getGroupOrder(a);
const orderB = getGroupOrder(b);
if (orderA !== orderB) return orderB - orderA; // 反轉:讓 order 小的在上方
return a.localeCompare(b); // 同 order 按字母排序
});
// 轉換為熱力圖資料格式 [x, y, value]
const heatmapData = [];
groups.forEach((group, yIdx) => {
dates.forEach((date, xIdx) => {
const cell = groupedData[group][date];
if (cell && cell.avail > 0) {
const pct = Math.round(cell.prd / cell.avail * 10000) / 100;
heatmapData.push([xIdx, yIdx, pct]);
} else {
heatmapData.push([xIdx, yIdx, '-']);
}
});
});
const option = {
tooltip: {
position: 'top',
formatter: function(params) {
const xIdx = params.data[0];
const yIdx = params.data[1];
const val = params.data[2];
const date = dates[xIdx];
const group = groups[yIdx];
const cell = groupedData[group][date];
if (val === '-') return `${group}<br/>${date}<br/>無資料`;
return `<b>${group}</b><br/>${date}<br/>OU%: <b>${val}%</b><br/>PRD: ${cell?.prd?.toFixed(1) || 0}h`;
}
},
grid: {
left: 100,
right: 40,
top: 10,
bottom: 30
},
xAxis: {
type: 'category',
data: dates.map(d => d.substring(5)),
splitArea: { show: true },
axisLabel: { fontSize: 10, color: '#666' }
},
yAxis: {
type: 'category',
data: groups,
splitArea: { show: true },
axisLabel: { fontSize: 10, color: '#666', width: 80, overflow: 'truncate' }
},
visualMap: {
min: 0,
max: 100,
calculable: true,
orient: 'vertical',
right: 0,
top: 'center',
itemHeight: 120,
inRange: {
color: ['#fee2e2', '#fef3c7', '#bbf7d0', '#22c55e']
},
formatter: '{value}%'
},
series: [{
type: 'heatmap',
data: heatmapData,
label: {
show: days <= 7,
fontSize: 9,
formatter: function(params) {
return params.data[2] === '-' ? '' : params.data[2];
}
},
emphasis: {
itemStyle: { shadowBlur: 10, shadowColor: 'rgba(0, 0, 0, 0.5)' }
}
}]
};
heatmapChart.setOption(option);
}
async function loadDashboard() {
if (isLoading) return;
isLoading = true;
@@ -691,7 +1015,9 @@
try {
await Promise.all([
loadKPI(),
loadWorkcenterCards()
loadWorkcenterCards(),
loadOuTrend(),
loadHeatmap()
]);
// 更新 Last Update
document.getElementById('lastUpdate').textContent = `Last Update: ${new Date().toLocaleString('zh-TW')}`;
@@ -702,6 +1028,15 @@
}
}
// 視窗調整時重繪圖表
window.addEventListener('resize', () => {
if (ouTrendChart && !ouTrendChart.isDisposed()) ouTrendChart.resize();
if (heatmapChart && !heatmapChart.isDisposed()) heatmapChart.resize();
Object.values(miniCharts).forEach(chart => {
if (chart && !chart.isDisposed()) chart.resize();
});
});
// Page init - 使用標記確保只執行一次
</script>
</body>