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:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-01-29
|
||||
@@ -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,無此風險 |
|
||||
@@ -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 回應結構維持向下相容
|
||||
@@ -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% 卡片之後
|
||||
@@ -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` 欄位存在
|
||||
@@ -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% 卡片之後
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 }
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
Reference in New Issue
Block a user