feat: 新增設備歷史績效頁面 Availability% 指標

在設備歷史績效頁面新增 Availability%(可用率)指標,與 OU% 互補以提供更完整的設備效能分析。

- 新增 _calc_availability_pct() 計算函數,公式: (PRD+SBY+EGT)/(PRD+SBY+EGT+SDT+UDT+NST)
- KPI 區新增 Availability% 卡片(綠色顯示)
- 趨勢圖改為雙線顯示 OU%(藍)與 Availability%(綠)
- API 回應新增 availability_pct 欄位(KPI、Trend、Detail)
- CSV 匯出新增 Availability% 欄位
- 新增單元測試與 E2E 測試驗證

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
beabigegg
2026-01-29 16:37:58 +08:00
parent 05e8f3f554
commit 1510646a36
10 changed files with 416 additions and 18 deletions

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-01-29

View File

@@ -0,0 +1,78 @@
## Context
設備歷史績效頁面已實作 OU%Overall Utilization指標計算公式為 `PRD / (PRD + SBY + UDT + SDT + EGT)`。現有架構包含:
- **後端**: `resource_history_service.py` 提供 `query_summary()` 函數,回傳 KPI、趨勢、熱力圖等資料
- **API**: `/api/resource/history/summary` 回傳 JSON 包含 `kpi.ou_pct``trend[].ou_pct`
- **前端**: `resource_history.html` 使用 Chart.js 繪製 OU% 趨勢圖
現有 SQL 查詢已包含 `PRD_HOURS`, `SBY_HOURS`, `UDT_HOURS`, `SDT_HOURS`, `EGT_HOURS`, `NST_HOURS` 六種狀態時數,無需修改查詢。
## Goals / Non-Goals
**Goals:**
- 在 KPI 區新增 Availability% 卡片
- 在趨勢圖區新增 Availability% 趨勢線
- 公式: `Availability% = (PRD + SBY + EGT) / (PRD + SBY + EGT + SDT + UDT + NST)`
- 維持 API 向下相容
**Non-Goals:**
- 不修改現有 SQL 查詢(資料已足夠)
- 不新增獨立 API 端點
- 不改變現有 OU% 計算邏輯
## Decisions
### 1. Availability% 計算位置
**選擇**: 在後端 Python 計算
**理由**:
- 與 OU% 計算位置一致(`_build_kpi_from_df`, `_build_trend_from_df`
- 避免前端重複計算
- 確保 API 回應可直接使用
**替代方案**: 前端計算
- 缺點: 增加前端複雜度,每次渲染都要計算
### 2. API 回應結構擴展
**選擇**: 新增 `availability_pct` 欄位至現有結構
```python
# KPI
{
'ou_pct': 85.5,
'availability_pct': 92.3, # 新增
'prd_hours': 1000,
...
}
# Trend
[
{
'date': '2026-01-01',
'ou_pct': 85.5,
'availability_pct': 92.3, # 新增
...
}
]
```
**理由**: 向下相容,現有前端不會因新欄位而出錯
### 3. 前端呈現方式
**選擇**: 在現有趨勢圖新增第二條線(雙 Y 軸或同一 Y 軸)
**理由**:
- 方便比較 OU% 與 Availability% 的變化趨勢
- 使用不同顏色區分OU%: 藍色, Availability%: 綠色)
## Risks / Trade-offs
| 風險 | 影響 | 緩解措施 |
|------|------|----------|
| 分母為零時除法錯誤 | 計算失敗 | 分母為零時回傳 0 或 None |
| 趨勢圖過於複雜 | 可讀性下降 | 提供切換選項,可單獨顯示 |
| NST 資料缺失 | 計算不準確 | 現有查詢已包含 NST無此風險 |

View File

@@ -0,0 +1,27 @@
## Why
設備歷史績效頁面目前只顯示 OU%Overall Utilization趨勢圖但使用者需要同時監控設備的 Availability%可用率來完整評估設備效能。Availability% 是衡量設備「可用時間」佔「總排程時間」的重要指標,與 OU% 互補使用可提供更全面的設備績效分析。
## What Changes
- 在設備歷史績效頁面的趨勢圖區塊新增 Availability% 趨勢圖
- 新增 Availability% 的計算邏輯:`(PRD + SBY + EGT) / (PRD + SBY + EGT + SDT + UDT + NST)`
- 與現有 OU% 趨勢圖並列顯示,使用相同的時間軸與篩選條件
- KPI 卡片區新增 Availability% 指標
## Capabilities
### New Capabilities
(無新增獨立模組,此功能擴展現有 resource-history 模組)
### Modified Capabilities
- `resource-cache`: 新增 Availability% 計算與趨勢資料輸出
## Impact
- **後端服務**: `resource_history_service.py` - 新增 Availability% 計算邏輯
- **API 回應**: `/api/resource/history/summary` - 回應結構新增 availability 欄位
- **前端頁面**: `resource_history.html` - 新增趨勢圖與 KPI 卡片
- **無破壞性變更**: 現有 API 回應結構維持向下相容

View File

@@ -0,0 +1,80 @@
## MODIFIED Requirements
### Requirement: Resource History KPI API - Availability%
系統 SHALL 在 KPI API 回應中新增 `availability_pct` 欄位。
#### Scenario: KPI includes availability percentage
- **WHEN** 呼叫 `GET /api/resource/history/summary`
- **THEN** 回應的 `kpi` 物件 SHALL 包含 `availability_pct` 欄位
- **AND** `availability_pct` 計算公式為 `(PRD + SBY + EGT) / (PRD + SBY + EGT + SDT + UDT + NST) * 100`
- **AND** 數值四捨五入至小數點後一位
#### Scenario: Availability percentage handles zero denominator
- **WHEN** 分母 `(PRD + SBY + EGT + SDT + UDT + NST)` 為零
- **THEN** `availability_pct` SHALL 回傳 `0`
---
### Requirement: Resource History Trend API - Availability%
系統 SHALL 在趨勢 API 回應的每個資料點中新增 `availability_pct` 欄位。
#### Scenario: Trend data includes availability percentage
- **WHEN** 呼叫 `GET /api/resource/history/summary`
- **THEN** 回應的 `trend` 陣列中每個物件 SHALL 包含 `availability_pct` 欄位
- **AND** 各資料點的 `availability_pct` 使用該時間區段的 E10 狀態時數計算
#### Scenario: Trend availability calculation formula
- **GIVEN** 單一時間區段的 E10 狀態時數
- **WHEN** 計算該區段的 `availability_pct`
- **THEN** 公式為 `(PRD_HOURS + SBY_HOURS + EGT_HOURS) / (PRD_HOURS + SBY_HOURS + EGT_HOURS + SDT_HOURS + UDT_HOURS + NST_HOURS) * 100`
---
### Requirement: Resource History Detail API - Availability%
系統 SHALL 在明細 API 回應的每筆資料中新增 `availability_pct` 欄位。
#### Scenario: Detail data includes availability percentage
- **WHEN** 呼叫 `GET /api/resource/history/detail`
- **THEN** 回應的 `data` 陣列中每個物件 SHALL 包含 `availability_pct` 欄位
---
### Requirement: CSV Export - Availability%
系統 SHALL 在 CSV 匯出中新增 Availability% 欄位。
#### Scenario: CSV includes availability column
- **WHEN** 匯出 CSV 檔案
- **THEN** CSV 標頭 SHALL 包含 `Availability%` 欄位(位於 `OU%` 之後)
- **AND** 各列的 `Availability%` 使用該機台的 E10 狀態時數計算
---
### Requirement: Frontend Trend Chart - Availability%
系統 SHALL 在趨勢圖中新增 Availability% 趨勢線。
#### Scenario: Chart displays availability trend line
- **WHEN** 顯示設備歷史績效頁面的趨勢圖
- **THEN** 圖表 SHALL 顯示 Availability% 趨勢線
- **AND** Availability% 使用綠色 (`#10B981`) 顯示
- **AND** OU% 保持原有藍色 (`#3B82F6`)
#### Scenario: Chart legend shows both metrics
- **WHEN** 顯示趨勢圖
- **THEN** 圖例 SHALL 包含 "OU%" 與 "Availability%" 兩項
---
### Requirement: Frontend KPI Card - Availability%
系統 SHALL 在 KPI 區新增 Availability% 卡片。
#### Scenario: KPI section displays availability card
- **WHEN** 顯示設備歷史績效頁面
- **THEN** KPI 區 SHALL 顯示 Availability% 卡片
- **AND** 卡片顯示格式為 `XX.X%`
- **AND** 卡片位置在 OU% 卡片之後

View File

@@ -0,0 +1,35 @@
# Tasks: add-availability-trend
## Backend
- [x]`resource_history_service.py` 新增 `_calc_availability_pct()` 函數
- 公式: `(prd + sby + egt) / (prd + sby + egt + sdt + udt + nst) * 100`
- 分母為零時回傳 0
- [x] 修改 `_build_kpi_from_df()` 新增 `availability_pct` 欄位
- [x] 修改 `_build_trend_from_df()` 在每個資料點新增 `availability_pct`
- [x] 修改 `_build_detail_from_df()` 新增 `availability_pct` 欄位
- [x] 修改 `export_csv()` 新增 Availability% 欄位
- 標頭新增 `Availability%`(位於 `OU%` 之後)
- 各列計算並輸出 Availability%
## Frontend
- [x] 修改 `resource_history.html` 趨勢圖新增 Availability% 趨勢線
- 使用綠色 (`#10B981`)
- 更新圖例顯示兩項指標
- [x] 修改 `resource_history.html` KPI 區新增 Availability% 卡片
- 顯示格式: `XX.X%`
- 位置: OU% 卡片之後
## Testing
- [x] 新增單元測試 `test_calc_availability_pct`
- 測試正常計算
- 測試分母為零情況
- [x] 新增 API 整合測試驗證 `availability_pct` 欄位存在

View File

@@ -178,3 +178,84 @@
#### Scenario: Resource cache not loaded warning
- **WHEN** 呼叫 `GET /health` 且 resource 快取啟用但未載入
- **THEN** 回應 body 的 `warnings` SHALL 包含 "Resource cache not loaded"
---
### Requirement: Resource History KPI API - Availability%
系統 SHALL 在 KPI API 回應中新增 `availability_pct` 欄位。
#### Scenario: KPI includes availability percentage
- **WHEN** 呼叫 `GET /api/resource/history/summary`
- **THEN** 回應的 `kpi` 物件 SHALL 包含 `availability_pct` 欄位
- **AND** `availability_pct` 計算公式為 `(PRD + SBY + EGT) / (PRD + SBY + EGT + SDT + UDT + NST) * 100`
- **AND** 數值四捨五入至小數點後一位
#### Scenario: Availability percentage handles zero denominator
- **WHEN** 分母 `(PRD + SBY + EGT + SDT + UDT + NST)` 為零
- **THEN** `availability_pct` SHALL 回傳 `0`
---
### Requirement: Resource History Trend API - Availability%
系統 SHALL 在趨勢 API 回應的每個資料點中新增 `availability_pct` 欄位。
#### Scenario: Trend data includes availability percentage
- **WHEN** 呼叫 `GET /api/resource/history/summary`
- **THEN** 回應的 `trend` 陣列中每個物件 SHALL 包含 `availability_pct` 欄位
- **AND** 各資料點的 `availability_pct` 使用該時間區段的 E10 狀態時數計算
#### Scenario: Trend availability calculation formula
- **GIVEN** 單一時間區段的 E10 狀態時數
- **WHEN** 計算該區段的 `availability_pct`
- **THEN** 公式為 `(PRD_HOURS + SBY_HOURS + EGT_HOURS) / (PRD_HOURS + SBY_HOURS + EGT_HOURS + SDT_HOURS + UDT_HOURS + NST_HOURS) * 100`
---
### Requirement: Resource History Detail API - Availability%
系統 SHALL 在明細 API 回應的每筆資料中新增 `availability_pct` 欄位。
#### Scenario: Detail data includes availability percentage
- **WHEN** 呼叫 `GET /api/resource/history/detail`
- **THEN** 回應的 `data` 陣列中每個物件 SHALL 包含 `availability_pct` 欄位
---
### Requirement: CSV Export - Availability%
系統 SHALL 在 CSV 匯出中新增 Availability% 欄位。
#### Scenario: CSV includes availability column
- **WHEN** 匯出 CSV 檔案
- **THEN** CSV 標頭 SHALL 包含 `Availability%` 欄位(位於 `OU%` 之後)
- **AND** 各列的 `Availability%` 使用該機台的 E10 狀態時數計算
---
### Requirement: Frontend Trend Chart - Availability%
系統 SHALL 在趨勢圖中新增 Availability% 趨勢線。
#### Scenario: Chart displays availability trend line
- **WHEN** 顯示設備歷史績效頁面的趨勢圖
- **THEN** 圖表 SHALL 顯示 Availability% 趨勢線
- **AND** Availability% 使用綠色 (`#10B981`) 顯示
- **AND** OU% 保持原有藍色 (`#3B82F6`)
#### Scenario: Chart legend shows both metrics
- **WHEN** 顯示趨勢圖
- **THEN** 圖例 SHALL 包含 "OU%" 與 "Availability%" 兩項
---
### Requirement: Frontend KPI Card - Availability%
系統 SHALL 在 KPI 區新增 Availability% 卡片。
#### Scenario: KPI section displays availability card
- **WHEN** 顯示設備歷史績效頁面
- **THEN** KPI 區 SHALL 顯示 Availability% 卡片
- **AND** 卡片顯示格式為 `XX.X%`
- **AND** 卡片位置在 OU% 卡片之後

View File

@@ -444,7 +444,7 @@ def export_csv(
output = io.StringIO()
writer = csv.writer(output)
headers = [
'站點', '型號', '機台', 'OU%',
'站點', '型號', '機台', 'OU%', 'Availability%',
'PRD(h)', 'PRD(%)', 'SBY(h)', 'SBY(%)',
'UDT(h)', 'UDT(%)', 'SDT(h)', 'SDT(%)',
'EGT(h)', 'EGT(%)', 'NST(h)', 'NST(%)'
@@ -471,6 +471,7 @@ def export_csv(
# Calculate percentages
ou_pct = _calc_ou_pct(prd, sby, udt, sdt, egt)
availability_pct = _calc_availability_pct(prd, sby, udt, sdt, egt, nst)
prd_pct = round(prd / total * 100, 1) if total > 0 else 0
sby_pct = round(sby / total * 100, 1) if total > 0 else 0
udt_pct = round(udt / total * 100, 1) if total > 0 else 0
@@ -483,6 +484,7 @@ def export_csv(
row['RESOURCEFAMILYNAME'],
row['RESOURCENAME'],
f"{ou_pct}%",
f"{availability_pct}%",
round(prd, 1), f"{prd_pct}%",
round(sby, 1), f"{sby_pct}%",
round(udt, 1), f"{udt_pct}%",
@@ -623,11 +625,19 @@ def _calc_ou_pct(prd: float, sby: float, udt: float, sdt: float, egt: float) ->
return round(prd / denominator * 100, 1) if denominator > 0 else 0
def _calc_availability_pct(prd: float, sby: float, udt: float, sdt: float, egt: float, nst: float) -> float:
"""Calculate Availability% = (PRD + SBY + EGT) / (PRD + SBY + EGT + SDT + UDT + NST) * 100."""
numerator = prd + sby + egt
denominator = prd + sby + egt + sdt + udt + nst
return round(numerator / denominator * 100, 1) if denominator > 0 else 0
def _build_kpi_from_df(df: pd.DataFrame) -> Dict[str, Any]:
"""Build KPI dict from query result DataFrame."""
if df is None or len(df) == 0:
return {
'ou_pct': 0,
'availability_pct': 0,
'prd_hours': 0,
'sby_hours': 0,
'udt_hours': 0,
@@ -648,6 +658,7 @@ def _build_kpi_from_df(df: pd.DataFrame) -> Dict[str, Any]:
return {
'ou_pct': _calc_ou_pct(prd, sby, udt, sdt, egt),
'availability_pct': _calc_availability_pct(prd, sby, udt, sdt, egt, nst),
'prd_hours': round(prd, 1),
'sby_hours': round(sby, 1),
'udt_hours': round(udt, 1),
@@ -690,6 +701,7 @@ def _build_trend_from_df(df: pd.DataFrame, granularity: str) -> List[Dict]:
result.append({
'date': _format_date(row['DATA_DATE'], granularity),
'ou_pct': _calc_ou_pct(prd, sby, udt, sdt, egt),
'availability_pct': _calc_availability_pct(prd, sby, udt, sdt, egt, nst),
'prd_hours': round(prd, 1),
'sby_hours': round(sby, 1),
'udt_hours': round(udt, 1),
@@ -824,6 +836,7 @@ def _build_detail_from_df(df: pd.DataFrame) -> List[Dict]:
'family': family if not pd.isna(family) else '',
'resource': resource if not pd.isna(resource) else '',
'ou_pct': _calc_ou_pct(prd, sby, udt, sdt, egt),
'availability_pct': _calc_availability_pct(prd, sby, udt, sdt, egt, nst),
'prd_hours': round(prd, 1),
'prd_pct': round(prd / total * 100, 1) if total > 0 else 0,
'sby_hours': round(sby, 1),

View File

@@ -290,7 +290,7 @@
/* KPI Cards */
.kpi-row {
display: grid;
grid-template-columns: repeat(6, 1fr);
grid-template-columns: repeat(7, 1fr);
gap: 14px;
margin-bottom: 16px;
}
@@ -504,6 +504,12 @@
}
/* Responsive */
@media (max-width: 1400px) {
.kpi-row {
grid-template-columns: repeat(4, 1fr);
}
}
@media (max-width: 1200px) {
.kpi-row {
grid-template-columns: repeat(3, 1fr);
@@ -599,12 +605,17 @@
<div class="kpi-row" id="kpiRow">
<div class="kpi-card">
<div class="kpi-label">OU%</div>
<div class="kpi-value green" id="kpiOuPct">--</div>
<div class="kpi-value blue" id="kpiOuPct">--</div>
<div class="kpi-sub">稼動率</div>
</div>
<div class="kpi-card">
<div class="kpi-label">Availability%</div>
<div class="kpi-value green" id="kpiAvailabilityPct">--</div>
<div class="kpi-sub">可用率</div>
</div>
<div class="kpi-card">
<div class="kpi-label">PRD 時數</div>
<div class="kpi-value blue" id="kpiPrdHours">--</div>
<div class="kpi-value" style="color: #3B82F6;" id="kpiPrdHours">--</div>
<div class="kpi-sub">生產時間</div>
</div>
<div class="kpi-card">
@@ -632,7 +643,7 @@
<!-- Charts Row 1 -->
<div class="charts-row">
<div class="chart-card">
<div class="chart-title">OU% 趨勢</div>
<div class="chart-title">OU% / Availability% 趨勢</div>
<div class="chart-container" id="trendChart"></div>
</div>
<div class="chart-card">
@@ -990,6 +1001,7 @@
// ============================================================
function updateKpiCards(kpi) {
document.getElementById('kpiOuPct').textContent = kpi.ou_pct + '%';
document.getElementById('kpiAvailabilityPct').textContent = kpi.availability_pct + '%';
document.getElementById('kpiPrdHours').textContent = formatHours(kpi.prd_hours);
document.getElementById('kpiUdtHours').textContent = formatHours(kpi.udt_hours);
document.getElementById('kpiSdtHours').textContent = formatHours(kpi.sdt_hours);
@@ -1010,6 +1022,7 @@
function updateTrendChart(trend) {
const dates = trend.map(t => t.date);
const ouPcts = trend.map(t => t.ou_pct);
const availabilityPcts = trend.map(t => t.availability_pct);
charts.trend.setOption({
tooltip: {
@@ -1017,12 +1030,18 @@
formatter: function(params) {
const d = trend[params[0].dataIndex];
return `${d.date}<br/>
OU%: <b>${d.ou_pct}%</b><br/>
<span style="color:#3B82F6">●</span> OU%: <b>${d.ou_pct}%</b><br/>
<span style="color:#10B981">●</span> Availability%: <b>${d.availability_pct}%</b><br/>
PRD: ${d.prd_hours}h<br/>
SBY: ${d.sby_hours}h<br/>
UDT: ${d.udt_hours}h`;
}
},
legend: {
data: ['OU%', 'Availability%'],
bottom: 0,
textStyle: { fontSize: 11 }
},
xAxis: {
type: 'category',
data: dates,
@@ -1030,19 +1049,31 @@
},
yAxis: {
type: 'value',
name: 'OU%',
name: '%',
max: 100,
axisLabel: { formatter: '{value}%' }
},
series: [{
data: ouPcts,
type: 'line',
smooth: true,
areaStyle: { opacity: 0.3 },
itemStyle: { color: '#667eea' },
lineStyle: { width: 2 }
}],
grid: { left: 50, right: 20, top: 30, bottom: 30 }
series: [
{
name: 'OU%',
data: ouPcts,
type: 'line',
smooth: true,
areaStyle: { opacity: 0.2 },
itemStyle: { color: '#3B82F6' },
lineStyle: { width: 2 }
},
{
name: 'Availability%',
data: availabilityPcts,
type: 'line',
smooth: true,
areaStyle: { opacity: 0.2 },
itemStyle: { color: '#10B981' },
lineStyle: { width: 2 }
}
],
grid: { left: 50, right: 20, top: 30, bottom: 50 }
});
}

View File

@@ -66,6 +66,7 @@ class TestResourceHistoryPageAccess:
content = response.data.decode('utf-8')
assert 'kpiOuPct' in content
assert 'kpiAvailabilityPct' in content
assert 'kpiPrdHours' in content
assert 'kpiUdtHours' in content
assert 'kpiSdtHours' in content
@@ -173,10 +174,14 @@ class TestResourceHistoryAPIWorkflow:
# Verify KPI
assert data['data']['kpi']['ou_pct'] == 80.0
# Availability% = (8000+1000+200) / (8000+1000+200+300+500+1000) * 100 = 9200/11000 = 83.6%
assert data['data']['kpi']['availability_pct'] == 83.6
assert data['data']['kpi']['machine_count'] == 100
# Verify trend
assert len(data['data']['trend']) == 2
# Trend should also have availability_pct
assert 'availability_pct' in data['data']['trend'][0]
# Verify heatmap
assert len(data['data']['heatmap']) == 2
@@ -217,6 +222,7 @@ class TestResourceHistoryAPIWorkflow:
assert 'family' in first_row
assert 'resource' in first_row
assert 'ou_pct' in first_row
assert 'availability_pct' in first_row
assert 'prd_hours' in first_row
assert 'prd_pct' in first_row
@@ -248,6 +254,7 @@ class TestResourceHistoryAPIWorkflow:
header = lines[0]
assert '站點' in header
assert 'OU%' in header
assert 'Availability%' in header
class TestResourceHistoryValidation:

View File

@@ -22,6 +22,7 @@ from mes_dashboard.services.resource_history_service import (
_validate_date_range,
_get_date_trunc,
_calc_ou_pct,
_calc_availability_pct,
_build_kpi_from_df,
_build_detail_from_df,
MAX_QUERY_DAYS,
@@ -112,6 +113,45 @@ class TestCalcOuPct(unittest.TestCase):
self.assertEqual(result, 0)
class TestCalcAvailabilityPct(unittest.TestCase):
"""Test Availability% calculation."""
def test_normal_calculation(self):
"""Availability% should be calculated correctly."""
# PRD=800, SBY=100, UDT=50, SDT=30, EGT=20, NST=100
# Availability% = (800+100+20) / (800+100+20+30+50+100) * 100 = 920 / 1100 * 100 = 83.6%
result = _calc_availability_pct(800, 100, 50, 30, 20, 100)
self.assertEqual(result, 83.6)
def test_zero_denominator(self):
"""Zero denominator should return 0, not error."""
result = _calc_availability_pct(0, 0, 0, 0, 0, 0)
self.assertEqual(result, 0)
def test_all_available(self):
"""100% available (no SDT, UDT, NST) should result in 100%."""
# PRD=100, SBY=50, EGT=50, no SDT/UDT/NST
# Availability% = (100+50+50) / (100+50+50+0+0+0) * 100 = 100%
result = _calc_availability_pct(100, 50, 0, 0, 50, 0)
self.assertEqual(result, 100.0)
def test_no_available_time(self):
"""No available time (all SDT/UDT/NST) should result in 0%."""
# PRD=0, SBY=0, EGT=0, SDT=50, UDT=30, NST=20
# Availability% = 0 / (0+0+0+50+30+20) * 100 = 0%
result = _calc_availability_pct(0, 0, 50, 30, 0, 20)
self.assertEqual(result, 0)
def test_mixed_scenario(self):
"""Mixed scenario with partial availability."""
# PRD=500, SBY=200, UDT=100, SDT=100, EGT=50, NST=50
# Numerator = PRD + SBY + EGT = 500 + 200 + 50 = 750
# Denominator = 500 + 200 + 50 + 100 + 100 + 50 = 1000
# Availability% = 750 / 1000 * 100 = 75%
result = _calc_availability_pct(500, 200, 100, 100, 50, 50)
self.assertEqual(result, 75.0)
class TestBuildKpiFromDf(unittest.TestCase):
"""Test KPI building from DataFrame."""
@@ -121,6 +161,7 @@ class TestBuildKpiFromDf(unittest.TestCase):
result = _build_kpi_from_df(df)
self.assertEqual(result['ou_pct'], 0)
self.assertEqual(result['availability_pct'], 0)
self.assertEqual(result['prd_hours'], 0)
self.assertEqual(result['machine_count'], 0)
@@ -138,6 +179,8 @@ class TestBuildKpiFromDf(unittest.TestCase):
result = _build_kpi_from_df(df)
self.assertEqual(result['ou_pct'], 80.0)
# Availability% = (800+100+20) / (800+100+20+30+50+100) * 100 = 920/1100 = 83.6%
self.assertEqual(result['availability_pct'], 83.6)
self.assertEqual(result['prd_hours'], 800)
self.assertEqual(result['machine_count'], 10)
@@ -155,6 +198,7 @@ class TestBuildKpiFromDf(unittest.TestCase):
result = _build_kpi_from_df(df)
self.assertEqual(result['ou_pct'], 0)
self.assertEqual(result['availability_pct'], 0)
self.assertEqual(result['prd_hours'], 0)
self.assertEqual(result['machine_count'], 0)
@@ -197,7 +241,7 @@ class TestGetFilterOptions(unittest.TestCase):
"""Test filter options retrieval."""
@patch('mes_dashboard.services.filter_cache.get_workcenter_groups')
@patch('mes_dashboard.services.filter_cache.get_resource_families')
@patch('mes_dashboard.services.resource_cache.get_resource_families')
def test_cache_failure(self, mock_families, mock_groups):
"""Cache failure should return None."""
mock_groups.return_value = None
@@ -206,7 +250,7 @@ class TestGetFilterOptions(unittest.TestCase):
self.assertIsNone(result)
@patch('mes_dashboard.services.filter_cache.get_workcenter_groups')
@patch('mes_dashboard.services.filter_cache.get_resource_families')
@patch('mes_dashboard.services.resource_cache.get_resource_families')
def test_successful_query(self, mock_families, mock_groups):
"""Successful query should return workcenter groups and families."""
mock_groups.return_value = [