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:
271
openspec/changes/archive/2026-01-27-wip-it-alignment/design.md
Normal file
271
openspec/changes/archive/2026-01-27-wip-it-alignment/design.md
Normal 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. **響應式測試**: 確認不同螢幕寬度下的卡片排列
|
||||
@@ -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 等
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -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 統計
|
||||
@@ -0,0 +1,85 @@
|
||||
## Tasks
|
||||
|
||||
### 後端修改
|
||||
|
||||
- [x] **修改 wip_service.py::get_wip_summary()**
|
||||
- 新增 WIP Status 分組統計 SQL(RUN/QUEUE/HOLD 的 LOTS 和 QTY)
|
||||
- 使用 CASE WHEN 計算:EQUIPMENTCOUNT > 0 → RUN,CURRENTHOLDCOUNT > 0 → HOLD,else → 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)
|
||||
@@ -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
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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>';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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__":
|
||||
|
||||
Reference in New Issue
Block a user