feat: WIP IT 標準對齊 - RUN/QUEUE/HOLD 分類與 API 格式優化

- 後端:使用 IT 標準 WIP Status 計算邏輯(EQUIPMENTCOUNT/CURRENTHOLDCOUNT)
- API:回傳格式改為 camelCase,新增 byWipStatus 分組統計
- Overview:新增 RUN/QUEUE/HOLD 狀態卡片,Matrix 表格固定欄位樣式
- Detail:KPI 卡片改為狀態分類,表格新增 WIP Status 欄位
- 修復:URL 雙重編碼、race condition、API 逾時增加至 60 秒

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
beabigegg
2026-01-27 15:43:43 +08:00
parent 5b11d0567f
commit 2e67846d4f
11 changed files with 933 additions and 178 deletions

View File

@@ -0,0 +1,271 @@
## Technical Decisions
### Decision: WIP Status 計算位置
**選擇**: 在 SQL 查詢中計算 WIP Status而非在 Python 程式碼中計算
**原因**:
- 與 IT Power BI 的實作方式一致
- 減少資料傳輸量(資料庫端聚合)
- 效能更佳,可直接利用資料庫的 CASE WHEN 語法
**SQL 實作**:
```sql
CASE WHEN EQUIPMENTCOUNT > 0 THEN 'RUN'
WHEN CURRENTHOLDCOUNT > 0 THEN 'HOLD'
ELSE 'QUEUE' END AS WIP_STATUS
```
### Decision: API 回傳格式
**選擇**: 使用 camelCase 作為 API 欄位名稱
**原因**:
- 符合 JavaScript/前端慣例
- 便於前端直接使用
**欄位對應表(部分)**:
| View 欄位 | API 欄位 |
|-----------|----------|
| LOTID | lotId |
| WORKORDER | workOrderId |
| QTY | qtyPcs |
| EQUIPMENTCOUNT | equipmentCount |
| CURRENTHOLDCOUNT | holdCount |
| (計算) | wipStatus |
### Decision: Summary API 結構
**選擇**: 在現有 Summary API 新增 `byWipStatus` 欄位,而非建立新的 API
**原因**:
- 減少前端 API 呼叫次數
- 保持向後相容(原有欄位保留)
- 資料來自同一次查詢,效能較佳
**結構**:
```python
{
"totalLots": int,
"totalQtyPcs": int,
"byWipStatus": {
"run": {"lots": int, "qtyPcs": int},
"queue": {"lots": int, "qtyPcs": int},
"hold": {"lots": int, "qtyPcs": int}
},
"dataUpdateDate": str
}
```
### Decision: 移除 Hold KPI 卡片
**選擇**: 移除獨立的 Hold Lots / Hold QTY 卡片
**原因**:
- 避免資訊重複HOLD 資訊已在 WIP Status 卡片顯示)
- 簡化 UI 層級
- 符合新的三態分類設計
---
## Implementation Approach
### 1. 後端修改wip_service.py
#### 1.1 修改 `get_wip_summary()` 函數
新增 WIP Status 分組統計 SQL
```python
def get_wip_summary(...) -> Optional[Dict[str, Any]]:
sql = f"""
SELECT
COUNT(*) as TOTAL_LOTS,
SUM(QTY) as TOTAL_QTY_PCS,
-- RUN: EQUIPMENTCOUNT > 0
SUM(CASE WHEN EQUIPMENTCOUNT > 0 THEN 1 ELSE 0 END) as RUN_LOTS,
SUM(CASE WHEN EQUIPMENTCOUNT > 0 THEN QTY ELSE 0 END) as RUN_QTY_PCS,
-- HOLD: EQUIPMENTCOUNT = 0 AND CURRENTHOLDCOUNT > 0
SUM(CASE WHEN EQUIPMENTCOUNT = 0 AND CURRENTHOLDCOUNT > 0 THEN 1 ELSE 0 END) as HOLD_LOTS,
SUM(CASE WHEN EQUIPMENTCOUNT = 0 AND CURRENTHOLDCOUNT > 0 THEN QTY ELSE 0 END) as HOLD_QTY_PCS,
-- QUEUE: EQUIPMENTCOUNT = 0 AND CURRENTHOLDCOUNT = 0
SUM(CASE WHEN EQUIPMENTCOUNT = 0 AND CURRENTHOLDCOUNT = 0 THEN 1 ELSE 0 END) as QUEUE_LOTS,
SUM(CASE WHEN EQUIPMENTCOUNT = 0 AND CURRENTHOLDCOUNT = 0 THEN QTY ELSE 0 END) as QUEUE_QTY_PCS,
MAX(SYS_DATE) as DATA_UPDATE_DATE
FROM {WIP_VIEW}
{where_clause}
"""
```
#### 1.2 回傳格式調整
```python
return {
'totalLots': int(row['TOTAL_LOTS'] or 0),
'totalQtyPcs': int(row['TOTAL_QTY_PCS'] or 0),
'byWipStatus': {
'run': {
'lots': int(row['RUN_LOTS'] or 0),
'qtyPcs': int(row['RUN_QTY_PCS'] or 0)
},
'queue': {
'lots': int(row['QUEUE_LOTS'] or 0),
'qtyPcs': int(row['QUEUE_QTY_PCS'] or 0)
},
'hold': {
'lots': int(row['HOLD_LOTS'] or 0),
'qtyPcs': int(row['HOLD_QTY_PCS'] or 0)
}
},
'dataUpdateDate': str(row['DATA_UPDATE_DATE']) if row['DATA_UPDATE_DATE'] else None
}
```
### 2. 前端修改wip_overview.html
#### 2.1 HTML 結構修改
```html
<!-- Summary Cards (修改為 2 個) -->
<div class="summary-row">
<div class="summary-card">
<div class="summary-label">Total Lots</div>
<div class="summary-value" id="totalLots">-</div>
</div>
<div class="summary-card">
<div class="summary-label">Total QTY</div>
<div class="summary-value" id="totalQty">-</div>
</div>
</div>
<!-- WIP Status Cards (新增) -->
<div class="wip-status-row">
<div class="wip-status-card run">
<div class="status-header"><span class="dot"></span>RUN</div>
<div class="status-values">
<span class="lots" id="runLots">-</span>
<span class="qty" id="runQty">-</span>
</div>
</div>
<!-- QUEUE, HOLD 卡片類似 -->
</div>
```
#### 2.2 CSS 樣式新增
```css
.wip-status-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 14px;
margin-bottom: 16px;
}
.wip-status-card {
border-radius: 10px;
padding: 12px 20px;
border: 2px solid;
}
.wip-status-card.run {
background: #F0FDF4;
border-color: #22C55E;
}
.wip-status-card.queue {
background: #FFFBEB;
border-color: #F59E0B;
}
.wip-status-card.hold {
background: #FEF2F2;
border-color: #EF4444;
}
.status-values span {
font-size: 24px;
font-weight: 700;
}
```
#### 2.3 JavaScript 渲染函數修改
```javascript
function renderSummary(data) {
updateElementWithTransition('totalLots', data.totalLots);
updateElementWithTransition('totalQty', data.totalQtyPcs);
// WIP Status
const ws = data.byWipStatus;
updateElementWithTransition('runLots', ws.run.lots + ' lots');
updateElementWithTransition('runQty', formatNumber(ws.run.qtyPcs) + ' pcs');
updateElementWithTransition('queueLots', ws.queue.lots + ' lots');
updateElementWithTransition('queueQty', formatNumber(ws.queue.qtyPcs) + ' pcs');
updateElementWithTransition('holdLots', ws.hold.lots + ' lots');
updateElementWithTransition('holdQty', formatNumber(ws.hold.qtyPcs) + ' pcs');
// Update time
if (data.dataUpdateDate) {
document.getElementById('lastUpdate').textContent =
`Last Update: ${data.dataUpdateDate}`;
}
}
```
---
## Data Flow
```
┌──────────────────────────────────────────────────────────────────────────┐
│ 資料流程圖 │
├──────────────────────────────────────────────────────────────────────────┤
│ │
│ DWH.DW_PJ_LOT_V │
│ │ │
│ │ SQL Query (含 WIP Status CASE WHEN) │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ wip_service.py::get_wip_summary() │ │
│ │ - 查詢 TOTAL_LOTS, TOTAL_QTY_PCS │ │
│ │ - 查詢 RUN/QUEUE/HOLD_LOTS 和 _QTY_PCS │ │
│ │ - 組裝 byWipStatus 結構 │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │ │
│ │ JSON Response │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ wip_routes.py::api_overview_summary() │ │
│ │ - 回傳 {success: true, data: {...}} │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │ │
│ │ HTTP GET /api/wip/overview/summary │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ wip_overview.html::renderSummary() │ │
│ │ - 更新 Total Lots / Total QTY 卡片 │ │
│ │ - 更新 RUN / QUEUE / HOLD 狀態卡片 │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────────────┘
```
---
## Testing Strategy
### 後端測試
1. **單元測試**: 驗證 `get_wip_summary()` 回傳正確的 `byWipStatus` 結構
2. **SQL 測試**: 驗證 WIP Status CASE WHEN 邏輯正確
3. **API 測試**: 驗證 `/api/wip/overview/summary` 回傳格式
### 前端測試
1. **視覺測試**: 確認 WIP Status 卡片顯示正確顏色
2. **數據測試**: 確認數字與 API 回傳一致
3. **響應式測試**: 確認不同螢幕寬度下的卡片排列

View File

@@ -0,0 +1,48 @@
## Why
目前 WIP Dashboard 的資料欄位與查詢邏輯與 IT 部門提供的 Power BI 標準定義不一致。IT 提供了 `DW_PJ_LOT_V` 的標準 SQL 欄位定義與 WIP Status 判斷邏輯,需要對齊以確保:
1. 資料定義一致性 - 與其他報表系統使用相同的欄位名稱與計算邏輯
2. WIP 狀態分類標準化 - 使用 IT 定義的 RUN/HOLD/QUEUE 三態分類
3. 提供主管更清楚的 WIP 狀態分布視覺化
## What Changes
- 採用 IT Power BI 的 WIP Status 三態判斷邏輯:
- `RUN`: EquipmentCount > 0在機台上運行中
- `HOLD`: EquipmentCount = 0 AND CurrentHoldCount > 0暫停中
- `QUEUE`: EquipmentCount = 0 AND CurrentHoldCount = 0等待中
- 新增 IT SQL 中定義的欄位到 API 回傳(使用 camelCase
- 前端顯示使用 IT 定義的正確名稱(如 "Run Card Lot ID"
- Summary API 新增 `byWipStatus` 分組統計(每個狀態的 lots 數與 qty
- 前端 WIP Overview 新增 RUN/QUEUE/HOLD 狀態卡片lots 與 qty 數字同大顯示
- 保留現有的 `LOTID NOT LIKE '%DUMMY%'` 全域過濾條件
## Capabilities
### New Capabilities
- `wip-status-calculation`: WIP Status 三態計算邏輯RUN/HOLD/QUEUE基於 EquipmentCount 與 CurrentHoldCount 欄位判斷
### Modified Capabilities
- `wip-data-service`: 修改 WIP 查詢服務,新增 IT 定義的欄位、WIP Status 計算、Summary 分組統計
- `wip-overview`: 修改 WIP Overview 前端頁面,新增 WIP Status 狀態卡片顯示
## Impact
- **後端服務**: `wip_service.py`
- 新增 WIP Status 計算邏輯到查詢
- Summary API 新增 byWipStatus 分組回傳
- Detail API 新增 IT 定義的欄位
- **後端路由**: `wip_routes.py`
- 調整 API 回傳格式camelCase 欄位名)
- **前端頁面**: `wip_overview.html`
- 移除 Hold Lots / Hold QTY 兩個 KPI 卡片
- 新增 RUN / QUEUE / HOLD 三個狀態卡片
- 狀態卡片顯示 lots 數與 qty 數(同樣字體大小)
- **資料庫**: 無結構變更
- 使用現有的 `DWH.DW_PJ_LOT_V` view
- 新增查詢欄位EQUIPMENTCOUNT, CURRENTHOLDCOUNT, QTY2 等

View File

@@ -0,0 +1,69 @@
## CHANGED Requirements
### Requirement: KPI 摘要卡片(修改)
原有 4 個 KPI 卡片(總 Lots、總 QTY、Hold Lots、Hold QTY修改為 2 個 KPI 卡片。
#### Scenario: 顯示 KPI 摘要
- **WHEN** Overview 頁面載入完成
- **THEN** 系統顯示 2 個 KPI 卡片:
- 總 Lots 數量Total Lots
- 總 QTY 數量Total QTY使用千分位格式
- **AND** 移除獨立的 Hold Lots、Hold QTY 卡片(已整合至 WIP Status 卡片)
## ADDED Requirements
### Requirement: WIP Status 狀態卡片
系統 SHALL 在 KPI 摘要卡片下方顯示 3 個 WIP Status 狀態卡片RUN、QUEUE、HOLD
#### Scenario: 顯示 WIP Status 卡片
- **WHEN** Overview 頁面載入完成
- **THEN** 系統顯示 3 個狀態卡片,依序為:
1. **RUN 卡片**(綠色邊框,淺綠背景)
- 顯示 RUN 狀態的 Lots 數量
- 顯示 RUN 狀態的 QTY 數量pcs
2. **QUEUE 卡片**(黃色邊框,淺黃背景)
- 顯示 QUEUE 狀態的 Lots 數量
- 顯示 QUEUE 狀態的 QTY 數量pcs
3. **HOLD 卡片**(紅色邊框,淺紅背景)
- 顯示 HOLD 狀態的 Lots 數量
- 顯示 HOLD 狀態的 QTY 數量pcs
#### Scenario: WIP Status 卡片數字呈現
- **GIVEN** WIP Status 卡片
- **THEN** Lots 數量與 QTY 數量使用相同字體大小顯示
- **AND** 兩個數字並排呈現,讓主管可同時清楚看到
### Requirement: WIP Status 卡片顏色定義
系統 SHALL 使用以下顏色定義 WIP Status 卡片:
| 狀態 | 邊框顏色 | 背景顏色 | 標籤文字顏色 |
|------|----------|----------|--------------|
| RUN | #22C55E | #F0FDF4 | #166534 |
| QUEUE | #F59E0B | #FFFBEB | #92400E |
| HOLD | #EF4444 | #FEF2F2 | #991B1B |
### Requirement: Summary API 回應格式
系統 SHALL 修改 Summary API 回傳格式以支援 WIP Status 顯示。
#### Scenario: API 回傳 WIP Status 統計
- **WHEN** 前端呼叫 `/api/wip/overview/summary`
- **THEN** API 回傳包含:
```json
{
"success": true,
"data": {
"totalLots": 1234,
"totalQtyPcs": 56789,
"byWipStatus": {
"run": { "lots": 500, "qtyPcs": 30000 },
"queue": { "lots": 634, "qtyPcs": 21789 },
"hold": { "lots": 100, "qtyPcs": 5000 }
},
"dataUpdateDate": "2026-01-27 14:30:00"
}
}
```

View File

@@ -0,0 +1,46 @@
## ADDED Requirements
### Requirement: WIP Status 三態計算
系統 SHALL 根據 IT Power BI 標準定義計算 WIP Status將每個 Lot 分類為 RUN、HOLD 或 QUEUE 三種狀態之一。
#### Scenario: 判斷 RUN 狀態
- **GIVEN** 一筆 WIP 資料
- **WHEN** `EQUIPMENTCOUNT > 0`
- **THEN** WIP Status = "RUN"WIP Status Sequence = 1
#### Scenario: 判斷 HOLD 狀態
- **GIVEN** 一筆 WIP 資料
- **WHEN** `EQUIPMENTCOUNT = 0` AND `CURRENTHOLDCOUNT > 0`
- **THEN** WIP Status = "HOLD"WIP Status Sequence = 3
#### Scenario: 判斷 QUEUE 狀態
- **GIVEN** 一筆 WIP 資料
- **WHEN** `EQUIPMENTCOUNT = 0` AND `CURRENTHOLDCOUNT = 0`
- **THEN** WIP Status = "QUEUE"WIP Status Sequence = 2
### Requirement: WIP Status 分組統計
系統 SHALL 在 Summary API 提供按 WIP Status 分組的統計數據。
#### Scenario: 回傳 WIP Status 分組統計
- **WHEN** 呼叫 Summary API
- **THEN** 回傳資料包含 `byWipStatus` 物件:
```json
{
"byWipStatus": {
"run": { "lots": 500, "qtyPcs": 30000 },
"queue": { "lots": 634, "qtyPcs": 21789 },
"hold": { "lots": 100, "qtyPcs": 5000 }
}
}
```
### Requirement: 保留 DUMMY 過濾
系統 SHALL 在所有 WIP Status 計算中保留現有的 DUMMY 過濾條件。
#### Scenario: 排除 DUMMY Lots
- **GIVEN** WIP Status 統計查詢
- **WHEN** 資料中有 LOTID 包含 "DUMMY" 的記錄
- **THEN** 該記錄不納入任何 WIP Status 統計

View File

@@ -0,0 +1,85 @@
## Tasks
### 後端修改
- [x] **修改 wip_service.py::get_wip_summary()**
- 新增 WIP Status 分組統計 SQLRUN/QUEUE/HOLD 的 LOTS 和 QTY
- 使用 CASE WHEN 計算EQUIPMENTCOUNT > 0 → RUNCURRENTHOLDCOUNT > 0 → HOLDelse → QUEUE
- 修改回傳格式,新增 `byWipStatus` 欄位
-`total_qty` 改名為 `totalQtyPcs`
-`sys_date` 改名為 `dataUpdateDate`
- [x] **移除 wip_service.py 中的 hold_lots/hold_qty**
- 從 get_wip_summary() 回傳中移除獨立的 hold_lots 和 hold_qty已整合至 byWipStatus.hold
- [x] **更新 wip_routes.py API 回傳格式**
- 確保 `/api/wip/overview/summary` 回傳 camelCase 格式
### 前端修改
- [x] **修改 wip_overview.html Summary Cards HTML**
- 將 4 個 KPI 卡片改為 2 個Total Lots, Total QTY
- 移除 Hold Lots 和 Hold QTY 卡片
- [x] **新增 WIP Status Cards HTML**
- 在 KPI 卡片下方新增 3 個 WIP Status 卡片容器
- RUN 卡片:包含 lots 數和 pcs 數
- QUEUE 卡片:包含 lots 數和 pcs 數
- HOLD 卡片:包含 lots 數和 pcs 數
- [x] **新增 WIP Status Cards CSS**
- 定義 `.wip-status-row` 三欄 grid 佈局
- 定義 `.wip-status-card` 基本樣式
- 定義 `.run` 綠色樣式border: #22C55E, bg: #F0FDF4
- 定義 `.queue` 黃色樣式border: #F59E0B, bg: #FFFBEB
- 定義 `.hold` 紅色樣式border: #EF4444, bg: #FEF2F2
- 確保 lots 和 qty 數字同樣大小顯示
- [x] **修改 wip_overview.html renderSummary() JavaScript**
- 更新 renderSummary() 處理新的 API 回傳格式
- 新增 WIP Status 卡片的數據更新邏輯
- 移除 holdLots/holdQty 的更新(改用 byWipStatus.hold
### 測試驗證
- [x] **驗證後端 API 回傳格式**
- 手動測試 `/api/wip/overview/summary` 回傳正確的 byWipStatus 結構
- 確認 RUN + QUEUE + HOLD 的 lots 總和等於 totalLots
- [x] **驗證前端顯示**
- 確認 WIP Status 卡片顯示正確顏色
- 確認數字格式正確(千分位)
- 確認 lots 和 qty 數字同樣大小
- 測試響應式佈局(小螢幕)
### WIP Detail 頁面對齊(追加)
- [x] **修改 wip_service.py::get_wip_detail()**
- 新增 WIP Status 欄位至每筆 lot 記錄(使用 CASE WHEN 計算)
- 修改 summary 回傳totalLots, runLots, queueLots, holdLots
- [x] **修改 wip_detail.html KPI 卡片**
- 改為 RUN / QUEUE / HOLD 三個狀態卡片
- 套用對應顏色樣式(綠/黃/紅)
- [x] **修改 wip_detail.html 資料表格**
- 新增 WIP Status 欄位顯示 RUN/QUEUE/HOLD
- 移除舊的 On Equipment/Waiting/Hold 邏輯
### 修復與優化(追加)
- [x] **修復 URL 雙重編碼問題**
- 移除 navigateToDetail() 中的 encodeURIComponent()
- 讓 URLSearchParams 自動處理編碼
- [x] **修復 race condition**
- 將 visibilitychange 事件監聽移至 init() 內部
- [x] **增加 API 逾時時間**
- wip_detail.html: 30s → 60s
- wip_overview.html: 30s → 60s
- fetchPackages 改為非阻塞載入
- [x] **Matrix 表格樣式優化**
- Workcenter 欄位固定顯示sticky + border
- Package 標題列固定顯示sticky + border

View File

@@ -40,8 +40,8 @@ def api_overview_summary():
lotid: Optional LOTID filter (fuzzy match)
include_dummy: Include DUMMY lots (default: false)
Returns:
JSON with total_lots, total_qty, hold_lots, hold_qty, sys_date
Returns:
JSON with totalLots, totalQtyPcs, byWipStatus, dataUpdateDate
"""
workorder = request.args.get('workorder', '').strip() or None
lotid = request.args.get('lotid', '').strip() or None

View File

@@ -82,12 +82,11 @@ def get_wip_summary(
lotid: Optional LOTID filter (fuzzy match)
Returns:
Dict with summary stats:
- total_lots: Total number of lots
- total_qty: Total quantity
- hold_lots: Number of hold lots
- hold_qty: Hold quantity
- sys_date: Data timestamp
Dict with summary stats (camelCase):
- totalLots: Total number of lots
- totalQtyPcs: Total quantity
- byWipStatus: Grouped counts for RUN/QUEUE/HOLD
- dataUpdateDate: Data timestamp
"""
try:
conditions = _build_base_conditions(include_dummy, workorder, lotid)
@@ -96,10 +95,18 @@ def get_wip_summary(
sql = f"""
SELECT
COUNT(*) as TOTAL_LOTS,
SUM(QTY) as TOTAL_QTY,
SUM(CASE WHEN STATUS = 'HOLD' THEN 1 ELSE 0 END) as HOLD_LOTS,
SUM(CASE WHEN STATUS = 'HOLD' THEN QTY ELSE 0 END) as HOLD_QTY,
MAX(SYS_DATE) as SYS_DATE
SUM(QTY) as TOTAL_QTY_PCS,
SUM(CASE WHEN COALESCE(EQUIPMENTCOUNT, 0) > 0 THEN 1 ELSE 0 END) as RUN_LOTS,
SUM(CASE WHEN COALESCE(EQUIPMENTCOUNT, 0) > 0 THEN QTY ELSE 0 END) as RUN_QTY_PCS,
SUM(CASE WHEN COALESCE(EQUIPMENTCOUNT, 0) = 0
AND COALESCE(CURRENTHOLDCOUNT, 0) > 0 THEN 1 ELSE 0 END) as HOLD_LOTS,
SUM(CASE WHEN COALESCE(EQUIPMENTCOUNT, 0) = 0
AND COALESCE(CURRENTHOLDCOUNT, 0) > 0 THEN QTY ELSE 0 END) as HOLD_QTY_PCS,
SUM(CASE WHEN COALESCE(EQUIPMENTCOUNT, 0) = 0
AND COALESCE(CURRENTHOLDCOUNT, 0) = 0 THEN 1 ELSE 0 END) as QUEUE_LOTS,
SUM(CASE WHEN COALESCE(EQUIPMENTCOUNT, 0) = 0
AND COALESCE(CURRENTHOLDCOUNT, 0) = 0 THEN QTY ELSE 0 END) as QUEUE_QTY_PCS,
MAX(SYS_DATE) as DATA_UPDATE_DATE
FROM {WIP_VIEW}
{where_clause}
"""
@@ -110,11 +117,23 @@ def get_wip_summary(
row = df.iloc[0]
return {
'total_lots': int(row['TOTAL_LOTS'] or 0),
'total_qty': int(row['TOTAL_QTY'] or 0),
'hold_lots': int(row['HOLD_LOTS'] or 0),
'hold_qty': int(row['HOLD_QTY'] or 0),
'sys_date': str(row['SYS_DATE']) if row['SYS_DATE'] else None
'totalLots': int(row['TOTAL_LOTS'] or 0),
'totalQtyPcs': int(row['TOTAL_QTY_PCS'] or 0),
'byWipStatus': {
'run': {
'lots': int(row['RUN_LOTS'] or 0),
'qtyPcs': int(row['RUN_QTY_PCS'] or 0)
},
'queue': {
'lots': int(row['QUEUE_LOTS'] or 0),
'qtyPcs': int(row['QUEUE_QTY_PCS'] or 0)
},
'hold': {
'lots': int(row['HOLD_LOTS'] or 0),
'qtyPcs': int(row['HOLD_QTY_PCS'] or 0)
}
},
'dataUpdateDate': str(row['DATA_UPDATE_DATE']) if row['DATA_UPDATE_DATE'] else None
}
except Exception as exc:
print(f"WIP summary query failed: {exc}")
@@ -313,13 +332,15 @@ def get_wip_detail(
where_clause = f"WHERE {' AND '.join(conditions)}"
# Get summary
# Get summary with RUN/QUEUE/HOLD classification (IT standard)
summary_sql = f"""
SELECT
COUNT(*) as TOTAL_LOTS,
SUM(CASE WHEN EQUIPMENTNAME IS NOT NULL THEN 1 ELSE 0 END) as ON_EQUIPMENT_LOTS,
SUM(CASE WHEN EQUIPMENTNAME IS NULL THEN 1 ELSE 0 END) as WAITING_LOTS,
SUM(CASE WHEN STATUS = 'HOLD' THEN 1 ELSE 0 END) as HOLD_LOTS,
SUM(CASE WHEN COALESCE(EQUIPMENTCOUNT, 0) > 0 THEN 1 ELSE 0 END) as RUN_LOTS,
SUM(CASE WHEN COALESCE(EQUIPMENTCOUNT, 0) = 0
AND COALESCE(CURRENTHOLDCOUNT, 0) = 0 THEN 1 ELSE 0 END) as QUEUE_LOTS,
SUM(CASE WHEN COALESCE(EQUIPMENTCOUNT, 0) = 0
AND COALESCE(CURRENTHOLDCOUNT, 0) > 0 THEN 1 ELSE 0 END) as HOLD_LOTS,
MAX(SYS_DATE) as SYS_DATE
FROM {WIP_VIEW}
{where_clause}
@@ -335,10 +356,10 @@ def get_wip_detail(
sys_date = str(summary_row['SYS_DATE']) if summary_row['SYS_DATE'] else None
summary = {
'total_lots': total_count,
'on_equipment_lots': int(summary_row['ON_EQUIPMENT_LOTS'] or 0),
'waiting_lots': int(summary_row['WAITING_LOTS'] or 0),
'hold_lots': int(summary_row['HOLD_LOTS'] or 0)
'totalLots': total_count,
'runLots': int(summary_row['RUN_LOTS'] or 0),
'queueLots': int(summary_row['QUEUE_LOTS'] or 0),
'holdLots': int(summary_row['HOLD_LOTS'] or 0)
}
# Get unique specs for this workcenter (sorted by SPECSEQUENCE)
@@ -353,7 +374,7 @@ def get_wip_detail(
specs_df = read_sql_df(specs_sql)
specs = specs_df['SPECNAME'].tolist() if specs_df is not None and not specs_df.empty else []
# Get paginated lot details
# Get paginated lot details with WIP Status (IT standard)
offset = (page - 1) * page_size
lots_sql = f"""
SELECT * FROM (
@@ -365,6 +386,9 @@ def get_wip_detail(
QTY,
PRODUCTLINENAME,
SPECNAME,
CASE WHEN COALESCE(EQUIPMENTCOUNT, 0) > 0 THEN 'RUN'
WHEN COALESCE(CURRENTHOLDCOUNT, 0) > 0 THEN 'HOLD'
ELSE 'QUEUE' END AS WIP_STATUS,
ROW_NUMBER() OVER (ORDER BY LOTID) as RN
FROM {WIP_VIEW}
{where_clause}
@@ -379,10 +403,10 @@ def get_wip_detail(
if lots_df is not None and not lots_df.empty:
for _, row in lots_df.iterrows():
lots.append({
'lot_id': _safe_value(row['LOTID']),
'lotId': _safe_value(row['LOTID']),
'equipment': _safe_value(row['EQUIPMENTNAME']),
'status': _safe_value(row['STATUS']),
'hold_reason': _safe_value(row['HOLDREASONNAME']),
'wipStatus': _safe_value(row['WIP_STATUS']),
'holdReason': _safe_value(row['HOLDREASONNAME']),
'qty': int(row['QTY'] or 0),
'package': _safe_value(row['PRODUCTLINENAME']),
'spec': _safe_value(row['SPECNAME'])

View File

@@ -328,6 +328,31 @@
animation: valueUpdate 0.5s ease;
}
/* Status Card Colors */
.summary-card.status-run {
background: #F0FDF4;
border-color: #22C55E;
}
.summary-card.status-run .summary-value {
color: #166534;
}
.summary-card.status-queue {
background: #FFFBEB;
border-color: #F59E0B;
}
.summary-card.status-queue .summary-value {
color: #92400E;
}
.summary-card.status-hold {
background: #FEF2F2;
border-color: #EF4444;
}
.summary-card.status-hold .summary-value {
color: #991B1B;
}
@keyframes valueUpdate {
0% { transform: scale(1); }
50% { transform: scale(1.05); background: rgba(102, 126, 234, 0.1); }
@@ -439,14 +464,23 @@
color: #155724;
}
/* Status styles */
.status-active {
color: var(--success);
/* WIP Status styles in table */
.wip-status-run {
color: #166534;
background: #F0FDF4;
font-weight: 600;
}
.status-hold {
color: var(--danger);
font-weight: bold;
.wip-status-queue {
color: #92400E;
background: #FFFBEB;
font-weight: 600;
}
.wip-status-hold {
color: #991B1B;
background: #FEF2F2;
font-weight: 600;
}
/* Pagination */
@@ -594,17 +628,17 @@
<div class="summary-label">Total Lots</div>
<div class="summary-value" id="totalLots">-</div>
</div>
<div class="summary-card">
<div class="summary-label">On Equipment</div>
<div class="summary-value success" id="onEquipmentLots">-</div>
<div class="summary-card status-run">
<div class="summary-label">RUN</div>
<div class="summary-value" id="runLots">-</div>
</div>
<div class="summary-card">
<div class="summary-label">Waiting</div>
<div class="summary-value warning" id="waitingLots">-</div>
<div class="summary-card status-queue">
<div class="summary-label">QUEUE</div>
<div class="summary-value" id="queueLots">-</div>
</div>
<div class="summary-card">
<div class="summary-label">Hold</div>
<div class="summary-value highlight" id="holdLots">-</div>
<div class="summary-card status-hold">
<div class="summary-label">HOLD</div>
<div class="summary-value" id="holdLots">-</div>
</div>
</div>
@@ -681,7 +715,7 @@
// ============================================================
// API Functions
// ============================================================
const API_TIMEOUT = 30000; // 30 seconds timeout
const API_TIMEOUT = 60000; // 60 seconds timeout
async function fetchWithTimeout(url, timeout = API_TIMEOUT) {
const controller = new AbortController();
@@ -768,10 +802,10 @@
function renderSummary(summary) {
if (!summary) return;
updateElementWithTransition('totalLots', summary.total_lots);
updateElementWithTransition('onEquipmentLots', summary.on_equipment_lots);
updateElementWithTransition('waitingLots', summary.waiting_lots);
updateElementWithTransition('holdLots', summary.hold_lots);
updateElementWithTransition('totalLots', summary.totalLots);
updateElementWithTransition('runLots', summary.runLots);
updateElementWithTransition('queueLots', summary.queueLots);
updateElementWithTransition('holdLots', summary.holdLots);
}
function renderTable(data) {
@@ -790,7 +824,7 @@
// Fixed columns
html += '<th class="fixed-col">Lot ID</th>';
html += '<th class="fixed-col">Equipment</th>';
html += '<th class="fixed-col">Status</th>';
html += '<th class="fixed-col">WIP Status</th>';
html += '<th class="fixed-col">Package</th>';
// Spec columns
@@ -804,16 +838,16 @@
html += '<tr>';
// Fixed columns
html += `<td class="fixed-col">${lot.lot_id || '-'}</td>`;
html += `<td class="fixed-col">${lot.equipment || '<span style="color: var(--warning);">Waiting</span>'}</td>`;
html += `<td class="fixed-col">${lot.lotId || '-'}</td>`;
html += `<td class="fixed-col">${lot.equipment || '<span style="color: var(--muted);">-</span>'}</td>`;
// Status with hold reason
if (lot.status === 'HOLD') {
const holdText = lot.hold_reason ? `Hold (${lot.hold_reason})` : 'Hold';
html += `<td class="fixed-col status-hold">${holdText}</td>`;
} else {
html += `<td class="fixed-col status-active">${lot.status || 'Active'}</td>`;
// WIP Status with color and hold reason
const statusClass = `wip-status-${(lot.wipStatus || 'queue').toLowerCase()}`;
let statusText = lot.wipStatus || 'QUEUE';
if (lot.wipStatus === 'HOLD' && lot.holdReason) {
statusText = `HOLD (${lot.holdReason})`;
}
html += `<td class="fixed-col ${statusClass}">${statusText}</td>`;
html += `<td class="fixed-col">${lot.package || '-'}</td>`;
@@ -888,13 +922,17 @@
document.getElementById('refreshSuccess').classList.remove('active');
try {
// Load packages for filter (only on first load)
// Load packages for filter (non-blocking - don't fail if this times out)
if (state.packages.length === 0) {
state.packages = await fetchPackages();
populatePackageFilter(state.packages);
try {
state.packages = await fetchPackages();
populatePackageFilter(state.packages);
} catch (pkgError) {
console.warn('Failed to load packages filter:', pkgError);
}
}
// Load detail data
// Load detail data (main data - this is critical)
state.data = await fetchDetail();
renderSummary(state.data.summary);
@@ -1050,14 +1088,6 @@
loadAllData(false);
}
// Handle page visibility
document.addEventListener('visibilitychange', () => {
if (!document.hidden) {
loadAllData(false);
startAutoRefresh();
}
});
// ============================================================
// Initialize
// ============================================================
@@ -1095,6 +1125,14 @@
document.getElementById('pageTitle').textContent = `WIP Detail - ${state.workcenter}`;
loadAllData(true);
startAutoRefresh();
// Handle page visibility (must be after workcenter is set)
document.addEventListener('visibilitychange', () => {
if (!document.hidden && state.workcenter) {
loadAllData(false);
startAutoRefresh();
}
});
} else {
document.getElementById('tableContainer').innerHTML =
'<div class="placeholder">No workcenter available</div>';

View File

@@ -279,7 +279,7 @@
/* Summary Cards */
.summary-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-columns: repeat(2, 1fr);
gap: 14px;
margin-bottom: 16px;
}
@@ -320,6 +320,74 @@
100% { transform: scale(1); }
}
/* WIP Status Cards */
.wip-status-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 14px;
margin-bottom: 16px;
}
.wip-status-card {
background: var(--card-bg);
border-radius: 10px;
padding: 12px 16px;
border: 2px solid;
box-shadow: var(--shadow);
}
.wip-status-card.run {
background: #F0FDF4;
border-color: #22C55E;
}
.wip-status-card.queue {
background: #FFFBEB;
border-color: #F59E0B;
}
.wip-status-card.hold {
background: #FEF2F2;
border-color: #EF4444;
}
.wip-status-card .status-header {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 700;
margin-bottom: 8px;
}
.wip-status-card.run .status-header { color: #166534; }
.wip-status-card.queue .status-header { color: #92400E; }
.wip-status-card.hold .status-header { color: #991B1B; }
.wip-status-card .status-header .dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: currentColor;
}
.wip-status-card .status-values {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 24px;
}
.wip-status-card .status-values span {
font-size: 24px;
font-weight: 700;
color: var(--text);
}
.wip-status-card .status-values span:first-child {
font-weight: 800;
}
/* Content Grid */
.content-grid {
display: grid;
@@ -352,6 +420,12 @@
overflow-x: auto;
}
.card-body.matrix-container {
padding-left: 0;
padding-top: 0;
padding-bottom: 0;
}
/* Matrix Table */
.matrix-table {
width: 100%;
@@ -373,14 +447,18 @@
position: sticky;
top: 0;
z-index: 1;
border-bottom: 2px solid #cbd5e1;
}
.matrix-table th:first-child {
text-align: left;
position: sticky;
left: 0;
z-index: 2;
top: 0;
z-index: 3;
background: #e5e7eb;
border-right: 2px solid #cbd5e1;
border-bottom: 2px solid #cbd5e1;
}
.matrix-table td:first-child {
@@ -390,6 +468,7 @@
left: 0;
background: #f9fafb;
z-index: 1;
border-right: 2px solid #cbd5e1;
}
.matrix-table tbody tr:hover td {
@@ -405,6 +484,10 @@
font-weight: bold;
}
.matrix-table .total-row td:first-child {
border-right: 2px solid #cbd5e1;
}
.matrix-table .total-col {
background: #e5e7eb;
font-weight: bold;
@@ -491,12 +574,18 @@
.summary-row {
grid-template-columns: repeat(2, 1fr);
}
.wip-status-row {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.summary-row {
grid-template-columns: 1fr;
}
.wip-status-row {
grid-template-columns: 1fr;
}
.filters {
flex-direction: column;
align-items: stretch;
@@ -554,13 +643,30 @@
<div class="summary-label">Total QTY</div>
<div class="summary-value" id="totalQty">-</div>
</div>
<div class="summary-card">
<div class="summary-label">Hold Lots</div>
<div class="summary-value highlight" id="holdLots">-</div>
</div>
<!-- WIP Status Cards -->
<div class="wip-status-row">
<div class="wip-status-card run">
<div class="status-header"><span class="dot"></span>RUN</div>
<div class="status-values">
<span id="runLots">-</span>
<span id="runQty">-</span>
</div>
</div>
<div class="summary-card">
<div class="summary-label">Hold QTY</div>
<div class="summary-value highlight" id="holdQty">-</div>
<div class="wip-status-card queue">
<div class="status-header"><span class="dot"></span>QUEUE</div>
<div class="status-values">
<span id="queueLots">-</span>
<span id="queueQty">-</span>
</div>
</div>
<div class="wip-status-card hold">
<div class="status-header"><span class="dot"></span>HOLD</div>
<div class="status-values">
<span id="holdLots">-</span>
<span id="holdQty">-</span>
</div>
</div>
</div>
@@ -571,7 +677,7 @@
<div class="card-header">
<div class="card-title">Workcenter x Package Matrix (QTY)</div>
</div>
<div class="card-body" style="max-height: 500px; overflow: auto;">
<div class="card-body matrix-container" style="max-height: 500px; overflow: auto;">
<div id="matrixContainer">
<div class="placeholder">Loading...</div>
</div>
@@ -631,7 +737,14 @@
function updateElementWithTransition(elementId, newValue) {
const el = document.getElementById(elementId);
const oldValue = el.textContent;
const formattedNew = formatNumber(newValue);
let formattedNew;
if (typeof newValue === 'number') {
formattedNew = formatNumber(newValue);
} else if (newValue === null || newValue === undefined) {
formattedNew = '-';
} else {
formattedNew = newValue;
}
if (oldValue !== formattedNew) {
el.textContent = formattedNew;
@@ -791,7 +904,7 @@
// ============================================================
// API Functions
// ============================================================
const API_TIMEOUT = 30000; // 30 seconds timeout
const API_TIMEOUT = 60000; // 60 seconds timeout
async function fetchWithTimeout(url, timeout = API_TIMEOUT) {
const controller = new AbortController();
@@ -849,13 +962,44 @@
function renderSummary(data) {
if (!data) return;
updateElementWithTransition('totalLots', data.total_lots);
updateElementWithTransition('totalQty', data.total_qty);
updateElementWithTransition('holdLots', data.hold_lots);
updateElementWithTransition('holdQty', data.hold_qty);
updateElementWithTransition('totalLots', data.totalLots);
updateElementWithTransition('totalQty', data.totalQtyPcs);
if (data.sys_date) {
document.getElementById('lastUpdate').textContent = `Last Update: ${data.sys_date}`;
const ws = data.byWipStatus || {};
const runLots = ws.run?.lots;
const runQty = ws.run?.qtyPcs;
const queueLots = ws.queue?.lots;
const queueQty = ws.queue?.qtyPcs;
const holdLots = ws.hold?.lots;
const holdQty = ws.hold?.qtyPcs;
updateElementWithTransition(
'runLots',
runLots === null || runLots === undefined ? '-' : `${formatNumber(runLots)} lots`
);
updateElementWithTransition(
'runQty',
runQty === null || runQty === undefined ? '-' : `${formatNumber(runQty)} pcs`
);
updateElementWithTransition(
'queueLots',
queueLots === null || queueLots === undefined ? '-' : `${formatNumber(queueLots)} lots`
);
updateElementWithTransition(
'queueQty',
queueQty === null || queueQty === undefined ? '-' : `${formatNumber(queueQty)} pcs`
);
updateElementWithTransition(
'holdLots',
holdLots === null || holdLots === undefined ? '-' : `${formatNumber(holdLots)} lots`
);
updateElementWithTransition(
'holdQty',
holdQty === null || holdQty === undefined ? '-' : `${formatNumber(holdQty)} pcs`
);
if (data.dataUpdateDate) {
document.getElementById('lastUpdate').textContent = `Last Update: ${data.dataUpdateDate}`;
}
}
@@ -881,7 +1025,7 @@
// Data rows
data.workcenters.forEach(wc => {
html += '<tr>';
html += `<td class="clickable" onclick="navigateToDetail('${encodeURIComponent(wc)}')">${wc}</td>`;
html += `<td class="clickable" onclick="navigateToDetail('${wc.replace(/'/g, "\\'")}')">${wc}</td>`;
displayPackages.forEach(pkg => {
const qty = data.matrix[wc]?.[pkg] || 0;

View File

@@ -26,24 +26,27 @@ class TestWipRoutesBase(unittest.TestCase):
class TestOverviewSummaryRoute(TestWipRoutesBase):
"""Test GET /api/wip/overview/summary endpoint."""
@patch('mes_dashboard.routes.wip_routes.get_wip_summary')
def test_returns_success_with_data(self, mock_get_summary):
"""Should return success=True with summary data."""
mock_get_summary.return_value = {
'total_lots': 9073,
'total_qty': 858878718,
'hold_lots': 120,
'hold_qty': 8213395,
'sys_date': '2026-01-26 19:18:29'
}
@patch('mes_dashboard.routes.wip_routes.get_wip_summary')
def test_returns_success_with_data(self, mock_get_summary):
"""Should return success=True with summary data."""
mock_get_summary.return_value = {
'totalLots': 9073,
'totalQtyPcs': 858878718,
'byWipStatus': {
'run': {'lots': 8000, 'qtyPcs': 800000000},
'queue': {'lots': 953, 'qtyPcs': 504645323},
'hold': {'lots': 120, 'qtyPcs': 8213395}
},
'dataUpdateDate': '2026-01-26 19:18:29'
}
response = self.client.get('/api/wip/overview/summary')
data = json.loads(response.data)
self.assertEqual(response.status_code, 200)
self.assertTrue(data['success'])
self.assertEqual(data['data']['total_lots'], 9073)
self.assertEqual(data['data']['hold_lots'], 120)
self.assertEqual(response.status_code, 200)
self.assertTrue(data['success'])
self.assertEqual(data['data']['totalLots'], 9073)
self.assertEqual(data['data']['byWipStatus']['hold']['lots'], 120)
@patch('mes_dashboard.routes.wip_routes.get_wip_summary')
def test_returns_error_on_failure(self, mock_get_summary):

View File

@@ -101,26 +101,30 @@ class TestBuildBaseConditions(unittest.TestCase):
class TestGetWipSummary(unittest.TestCase):
"""Test get_wip_summary function."""
@patch('mes_dashboard.services.wip_service.read_sql_df')
def test_returns_summary_dict_on_success(self, mock_read_sql):
"""Should return dict with summary fields when query succeeds."""
mock_df = pd.DataFrame({
'TOTAL_LOTS': [9073],
'TOTAL_QTY': [858878718],
'HOLD_LOTS': [120],
'HOLD_QTY': [8213395],
'SYS_DATE': ['2026-01-26 19:18:29']
})
mock_read_sql.return_value = mock_df
result = get_wip_summary()
self.assertIsNotNone(result)
self.assertEqual(result['total_lots'], 9073)
self.assertEqual(result['total_qty'], 858878718)
self.assertEqual(result['hold_lots'], 120)
self.assertEqual(result['hold_qty'], 8213395)
self.assertIn('sys_date', result)
@patch('mes_dashboard.services.wip_service.read_sql_df')
def test_returns_summary_dict_on_success(self, mock_read_sql):
"""Should return dict with summary fields when query succeeds."""
mock_df = pd.DataFrame({
'TOTAL_LOTS': [9073],
'TOTAL_QTY_PCS': [858878718],
'RUN_LOTS': [8000],
'RUN_QTY_PCS': [800000000],
'QUEUE_LOTS': [953],
'QUEUE_QTY_PCS': [504645323],
'HOLD_LOTS': [120],
'HOLD_QTY_PCS': [8213395],
'DATA_UPDATE_DATE': ['2026-01-26 19:18:29']
})
mock_read_sql.return_value = mock_df
result = get_wip_summary()
self.assertIsNotNone(result)
self.assertEqual(result['totalLots'], 9073)
self.assertEqual(result['totalQtyPcs'], 858878718)
self.assertEqual(result['byWipStatus']['hold']['lots'], 120)
self.assertEqual(result['byWipStatus']['hold']['qtyPcs'], 8213395)
self.assertIn('dataUpdateDate', result)
@patch('mes_dashboard.services.wip_service.read_sql_df')
def test_returns_none_on_empty_result(self, mock_read_sql):
@@ -141,24 +145,29 @@ class TestGetWipSummary(unittest.TestCase):
self.assertIsNone(result)
@patch('mes_dashboard.services.wip_service.read_sql_df')
def test_handles_null_values(self, mock_read_sql):
"""Should handle NULL values gracefully."""
mock_df = pd.DataFrame({
'TOTAL_LOTS': [None],
'TOTAL_QTY': [None],
'HOLD_LOTS': [None],
'HOLD_QTY': [None],
'SYS_DATE': [None]
})
mock_read_sql.return_value = mock_df
result = get_wip_summary()
self.assertIsNotNone(result)
self.assertEqual(result['total_lots'], 0)
self.assertEqual(result['total_qty'], 0)
self.assertEqual(result['hold_lots'], 0)
self.assertEqual(result['hold_qty'], 0)
def test_handles_null_values(self, mock_read_sql):
"""Should handle NULL values gracefully."""
mock_df = pd.DataFrame({
'TOTAL_LOTS': [None],
'TOTAL_QTY_PCS': [None],
'RUN_LOTS': [None],
'RUN_QTY_PCS': [None],
'QUEUE_LOTS': [None],
'QUEUE_QTY_PCS': [None],
'HOLD_LOTS': [None],
'HOLD_QTY_PCS': [None],
'DATA_UPDATE_DATE': [None]
})
mock_read_sql.return_value = mock_df
result = get_wip_summary()
self.assertIsNotNone(result)
self.assertEqual(result['totalLots'], 0)
self.assertEqual(result['totalQtyPcs'], 0)
self.assertEqual(result['byWipStatus']['run']['lots'], 0)
self.assertEqual(result['byWipStatus']['queue']['lots'], 0)
self.assertEqual(result['byWipStatus']['hold']['lots'], 0)
class TestGetWipMatrix(unittest.TestCase):
@@ -598,14 +607,20 @@ class TestDummyExclusionInAllFunctions(unittest.TestCase):
"""Test DUMMY exclusion is applied in all WIP functions."""
@patch('mes_dashboard.services.wip_service.read_sql_df')
def test_get_wip_summary_excludes_dummy_by_default(self, mock_read_sql):
"""get_wip_summary should exclude DUMMY by default."""
mock_df = pd.DataFrame({
'TOTAL_LOTS': [100], 'TOTAL_QTY': [1000],
'HOLD_LOTS': [10], 'HOLD_QTY': [100],
'SYS_DATE': ['2026-01-26']
})
mock_read_sql.return_value = mock_df
def test_get_wip_summary_excludes_dummy_by_default(self, mock_read_sql):
"""get_wip_summary should exclude DUMMY by default."""
mock_df = pd.DataFrame({
'TOTAL_LOTS': [100],
'TOTAL_QTY_PCS': [1000],
'RUN_LOTS': [80],
'RUN_QTY_PCS': [800],
'QUEUE_LOTS': [10],
'QUEUE_QTY_PCS': [100],
'HOLD_LOTS': [10],
'HOLD_QTY_PCS': [100],
'DATA_UPDATE_DATE': ['2026-01-26']
})
mock_read_sql.return_value = mock_df
get_wip_summary()
@@ -613,14 +628,20 @@ class TestDummyExclusionInAllFunctions(unittest.TestCase):
self.assertIn("LOTID NOT LIKE '%DUMMY%'", call_args)
@patch('mes_dashboard.services.wip_service.read_sql_df')
def test_get_wip_summary_includes_dummy_when_specified(self, mock_read_sql):
"""get_wip_summary should include DUMMY when specified."""
mock_df = pd.DataFrame({
'TOTAL_LOTS': [100], 'TOTAL_QTY': [1000],
'HOLD_LOTS': [10], 'HOLD_QTY': [100],
'SYS_DATE': ['2026-01-26']
})
mock_read_sql.return_value = mock_df
def test_get_wip_summary_includes_dummy_when_specified(self, mock_read_sql):
"""get_wip_summary should include DUMMY when specified."""
mock_df = pd.DataFrame({
'TOTAL_LOTS': [100],
'TOTAL_QTY_PCS': [1000],
'RUN_LOTS': [80],
'RUN_QTY_PCS': [800],
'QUEUE_LOTS': [10],
'QUEUE_QTY_PCS': [100],
'HOLD_LOTS': [10],
'HOLD_QTY_PCS': [100],
'DATA_UPDATE_DATE': ['2026-01-26']
})
mock_read_sql.return_value = mock_df
get_wip_summary(include_dummy=True)
@@ -689,14 +710,20 @@ class TestMultipleFilterConditions(unittest.TestCase):
"""Test multiple filter conditions work together."""
@patch('mes_dashboard.services.wip_service.read_sql_df')
def test_get_wip_summary_with_all_filters(self, mock_read_sql):
"""get_wip_summary should combine all filter conditions."""
mock_df = pd.DataFrame({
'TOTAL_LOTS': [50], 'TOTAL_QTY': [500],
'HOLD_LOTS': [5], 'HOLD_QTY': [50],
'SYS_DATE': ['2026-01-26']
})
mock_read_sql.return_value = mock_df
def test_get_wip_summary_with_all_filters(self, mock_read_sql):
"""get_wip_summary should combine all filter conditions."""
mock_df = pd.DataFrame({
'TOTAL_LOTS': [50],
'TOTAL_QTY_PCS': [500],
'RUN_LOTS': [40],
'RUN_QTY_PCS': [400],
'QUEUE_LOTS': [5],
'QUEUE_QTY_PCS': [50],
'HOLD_LOTS': [5],
'HOLD_QTY_PCS': [50],
'DATA_UPDATE_DATE': ['2026-01-26']
})
mock_read_sql.return_value = mock_df
get_wip_summary(workorder='GA26', lotid='A00')
@@ -766,12 +793,12 @@ class TestWipServiceIntegration:
"""
@pytest.mark.integration
def test_get_wip_summary_integration(self):
"""Integration test for get_wip_summary."""
result = get_wip_summary()
assert result is not None
assert result['total_lots'] > 0
assert 'sys_date' in result
def test_get_wip_summary_integration(self):
"""Integration test for get_wip_summary."""
result = get_wip_summary()
assert result is not None
assert result['totalLots'] > 0
assert 'dataUpdateDate' in result
@pytest.mark.integration
def test_get_wip_matrix_integration(self):
@@ -850,8 +877,8 @@ class TestWipServiceIntegration:
assert result_with_dummy is not None
# If there are DUMMY lots, with_dummy should have more
# (or equal if no DUMMY lots exist)
assert result_with_dummy['total_lots'] >= result_without_dummy['total_lots']
# (or equal if no DUMMY lots exist)
assert result_with_dummy['totalLots'] >= result_without_dummy['totalLots']
@pytest.mark.integration
def test_workorder_filter_integration(self):
@@ -862,12 +889,12 @@ class TestWipServiceIntegration:
# Search for a workorder that exists
workorders = search_workorders('GA', limit=1)
if workorders and len(workorders) > 0:
# Filter by that workorder
filtered_result = get_wip_summary(workorder=workorders[0])
assert filtered_result is not None
# Filtered count should be less than or equal to total
assert filtered_result['total_lots'] <= all_result['total_lots']
if workorders and len(workorders) > 0:
# Filter by that workorder
filtered_result = get_wip_summary(workorder=workorders[0])
assert filtered_result is not None
# Filtered count should be less than or equal to total
assert filtered_result['totalLots'] <= all_result['totalLots']
if __name__ == "__main__":