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:
219
apps/portal.py
219
apps/portal.py
@@ -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()
|
||||
|
||||
@@ -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': '設備狀態班次彙總表 - 班次級狀態/工時'
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user