feat: 新增 Hold Detail 頁面與修復 WIP Detail 篩選問題
Hold Detail 功能: - 新增 /hold-detail 頁面,點擊 Hold Reason 可查看詳細資料 - 顯示摘要統計(總批次、總數量、平均/最大滯留天數、站點數) - 按站點、封裝類型、滯留天數分佈展示 - Lot Details 表格含分頁與篩選功能 - 新增 Hold Comment 欄位顯示 WIP Detail 修復: - 修復狀態卡片篩選後分頁總數仍顯示未篩選數量的問題 - 現在篩選 RUN/QUEUE/HOLD 後,分頁正確顯示篩選後的總數 測試: 120 passed (20 hold routes + 67 wip routes/service) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
288
openspec/changes/archive/2026-01-28-hold-detail/design.md
Normal file
288
openspec/changes/archive/2026-01-28-hold-detail/design.md
Normal file
@@ -0,0 +1,288 @@
|
||||
# Hold Detail 設計文件
|
||||
|
||||
## 頁面架構
|
||||
|
||||
### URL 路徑
|
||||
```
|
||||
/hold-detail?reason=<hold_reason>&hold_type=<quality|non-quality>
|
||||
```
|
||||
|
||||
### 頁面布局
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ ← 返回 WIP Overview Hold Detail: <Hold Reason Name> │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ ┌─────────┐ ┌─────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │Total │ │Total │ │平均當站滯留 │ │最久當站滯留 │ │ 影響站群 │ │
|
||||
│ │Lots │ │QTY │ │ │ │ │ │ │ │
|
||||
│ │ 128 │ │ 25600 │ │ 2.3天 │ │ 15天 │ │ 8 │ │
|
||||
│ └─────────┘ └─────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ 當站滯留天數分佈 (Age at Current Station) │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ 0-1天 │ │ 1-3天 │ │ 3-7天 │ │ 7+天 │ │
|
||||
│ │ Lots: 45 │ │ Lots: 38 │ │ Lots: 30 │ │ Lots: 15 │ │
|
||||
│ │ QTY: 9000 │ │ QTY: 7600 │ │ QTY: 6000 │ │ QTY: 3000 │ │
|
||||
│ │ 35.2% │ │ 29.7% │ │ 23.4% │ │ 11.7% │ │
|
||||
│ └──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ ┌───────────────────────────────┐ ┌───────────────────────────────┐ │
|
||||
│ │ By Workcenter │ │ By Package │ │
|
||||
│ ├───────────────────────────────┤ ├───────────────────────────────┤ │
|
||||
│ │ Workcenter Lots QTY % │ │ Package Lots QTY % │ │
|
||||
│ │ DA 45 9000 35.2% │ │ DIP-B 50 10000 39.1% │ │
|
||||
│ │ WB 38 7600 29.7% │ │ QFN 35 7000 27.3% │ │
|
||||
│ │ MOLD 30 6000 23.4% │ │ BGA 28 5600 21.9% │ │
|
||||
│ │ ... .. ... ... │ │ ... .. ... ... │ │
|
||||
│ └───────────────────────────────┘ └───────────────────────────────┘ │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ Lot Details 篩選: Workcenter=DA [清除] │
|
||||
│ ┌─────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ LOTID WORKORDER QTY Package Workcenter Spec Age Hold By Dept│ │
|
||||
│ │ L001 WO123 200 DIP-B DA S01 2.3 EMP01 QC │ │
|
||||
│ │ L002 WO124 200 QFN DA S02 1.5 EMP02 PE │ │
|
||||
│ │ ... │ │
|
||||
│ └─────────────────────────────────────────────────────────────────────┘ │
|
||||
│ 顯示 1-50 / 128 < 1 2 3 ... > │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 後端 API 設計
|
||||
|
||||
### 1. Hold Detail Summary API
|
||||
|
||||
**端點**: `GET /api/wip/hold-detail/summary`
|
||||
|
||||
**參數**:
|
||||
- `reason` (required): Hold Reason 名稱
|
||||
- `hold_type` (optional): `quality` 或 `non-quality`
|
||||
|
||||
**回應**:
|
||||
```json
|
||||
{
|
||||
"totalLots": 128,
|
||||
"totalQty": 25600,
|
||||
"avgAge": 2.3,
|
||||
"maxAge": 15,
|
||||
"workcenterCount": 8
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Hold Detail Distribution API
|
||||
|
||||
**端點**: `GET /api/wip/hold-detail/distribution`
|
||||
|
||||
**參數**:
|
||||
- `reason` (required): Hold Reason 名稱
|
||||
- `hold_type` (optional): `quality` 或 `non-quality`
|
||||
|
||||
**回應**:
|
||||
```json
|
||||
{
|
||||
"byWorkcenter": [
|
||||
{"name": "DA", "lots": 45, "qty": 9000, "percentage": 35.2},
|
||||
{"name": "WB", "lots": 38, "qty": 7600, "percentage": 29.7}
|
||||
],
|
||||
"byPackage": [
|
||||
{"name": "DIP-B", "lots": 50, "qty": 10000, "percentage": 39.1},
|
||||
{"name": "QFN", "lots": 35, "qty": 7000, "percentage": 27.3}
|
||||
],
|
||||
"byAge": [
|
||||
{"range": "0-1", "label": "0-1天", "lots": 45, "qty": 9000, "percentage": 35.2},
|
||||
{"range": "1-3", "label": "1-3天", "lots": 38, "qty": 7600, "percentage": 29.7},
|
||||
{"range": "3-7", "label": "3-7天", "lots": 30, "qty": 6000, "percentage": 23.4},
|
||||
{"range": "7+", "label": "7+天", "lots": 15, "qty": 3000, "percentage": 11.7}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Hold Detail Lots API
|
||||
|
||||
**端點**: `GET /api/wip/hold-detail/lots`
|
||||
|
||||
**參數**:
|
||||
- `reason` (required): Hold Reason 名稱
|
||||
- `hold_type` (optional): `quality` 或 `non-quality`
|
||||
- `workcenter` (optional): 篩選特定站群
|
||||
- `package` (optional): 篩選特定封裝
|
||||
- `age_range` (optional): `0-1`, `1-3`, `3-7`, `7+`
|
||||
- `page` (optional, default: 1): 頁碼
|
||||
- `per_page` (optional, default: 50): 每頁筆數
|
||||
|
||||
**回應**:
|
||||
```json
|
||||
{
|
||||
"lots": [
|
||||
{
|
||||
"lotId": "L001",
|
||||
"workorder": "WO123",
|
||||
"qty": 200,
|
||||
"package": "DIP-B",
|
||||
"workcenter": "DA",
|
||||
"spec": "S01",
|
||||
"age": 2.3,
|
||||
"holdBy": "EMP01",
|
||||
"dept": "QC"
|
||||
}
|
||||
],
|
||||
"pagination": {
|
||||
"page": 1,
|
||||
"perPage": 50,
|
||||
"total": 128,
|
||||
"totalPages": 3
|
||||
},
|
||||
"filters": {
|
||||
"workcenter": "DA",
|
||||
"package": null,
|
||||
"ageRange": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 前端實作
|
||||
|
||||
### 資料載入流程
|
||||
|
||||
1. 頁面載入時,同時呼叫 Summary API 和 Distribution API
|
||||
2. 根據 Distribution API 結果渲染分佈卡片和表格
|
||||
3. 預設載入第一頁 Lot Details
|
||||
4. 點擊分佈項目時,更新篩選參數並重新載入 Lot Details
|
||||
|
||||
### 篩選邏輯
|
||||
|
||||
```javascript
|
||||
// 篩選狀態
|
||||
let currentFilters = {
|
||||
workcenter: null,
|
||||
package: null,
|
||||
ageRange: null
|
||||
};
|
||||
|
||||
// 點擊篩選
|
||||
function applyFilter(type, value) {
|
||||
// 如果點擊同一個篩選值,則取消篩選
|
||||
if (currentFilters[type] === value) {
|
||||
currentFilters[type] = null;
|
||||
} else {
|
||||
currentFilters[type] = value;
|
||||
}
|
||||
loadLotDetails(1); // 重新載入第一頁
|
||||
updateFilterIndicator();
|
||||
}
|
||||
|
||||
// 清除所有篩選
|
||||
function clearFilters() {
|
||||
currentFilters = { workcenter: null, package: null, ageRange: null };
|
||||
loadLotDetails(1);
|
||||
updateFilterIndicator();
|
||||
}
|
||||
```
|
||||
|
||||
### CSS 樣式規範
|
||||
|
||||
1. **數值顯示**:不帶單位文字(如 pcs),純數字
|
||||
2. **表格欄位**:
|
||||
- 文字欄位:左對齊
|
||||
- 數值欄位:右對齊
|
||||
- 欄位間隔:16px gap
|
||||
3. **卡片樣式**:
|
||||
- 可點擊卡片顯示 cursor: pointer
|
||||
- 選中狀態使用高亮邊框
|
||||
4. **分佈表格欄位寬度**:
|
||||
- 名稱欄位:flex-grow
|
||||
- Lots 欄位:80px
|
||||
- QTY 欄位:100px
|
||||
- 百分比欄位:80px
|
||||
|
||||
### Age Distribution 分段定義
|
||||
|
||||
| 範圍 | Label | SQL 條件 |
|
||||
|------|-------|----------|
|
||||
| 0-1天 | 0-1天 | AgeByDays >= 0 AND AgeByDays < 1 |
|
||||
| 1-3天 | 1-3天 | AgeByDays >= 1 AND AgeByDays < 3 |
|
||||
| 3-7天 | 3-7天 | AgeByDays >= 3 AND AgeByDays < 7 |
|
||||
| 7+天 | 7+天 | AgeByDays >= 7 |
|
||||
|
||||
## 資料庫查詢
|
||||
|
||||
### 基礎條件
|
||||
|
||||
```python
|
||||
base_conditions = """
|
||||
WORKORDER IS NOT NULL
|
||||
AND STATUS = 'Active'
|
||||
AND CURRENTHOLDCOUNT > 0
|
||||
AND HOLDREASONNAME = :reason
|
||||
"""
|
||||
```
|
||||
|
||||
### Summary 查詢
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
COUNT(*) AS total_lots,
|
||||
SUM(QTY) AS total_qty,
|
||||
ROUND(AVG(AGEBYDAYS), 1) AS avg_age,
|
||||
MAX(AGEBYDAYS) AS max_age,
|
||||
COUNT(DISTINCT WORKCENTER_GROUP) AS workcenter_count
|
||||
FROM DWH.DW_PJ_LOT_V
|
||||
WHERE {base_conditions}
|
||||
```
|
||||
|
||||
### Distribution 查詢
|
||||
|
||||
```sql
|
||||
-- By Workcenter
|
||||
SELECT
|
||||
WORKCENTER_GROUP AS name,
|
||||
COUNT(*) AS lots,
|
||||
SUM(QTY) AS qty
|
||||
FROM DWH.DW_PJ_LOT_V
|
||||
WHERE {base_conditions}
|
||||
GROUP BY WORKCENTER_GROUP
|
||||
ORDER BY lots DESC
|
||||
|
||||
-- By Package
|
||||
SELECT
|
||||
PRODUCTLINENAME AS name,
|
||||
COUNT(*) AS lots,
|
||||
SUM(QTY) AS qty
|
||||
FROM DWH.DW_PJ_LOT_V
|
||||
WHERE {base_conditions}
|
||||
GROUP BY PRODUCTLINENAME
|
||||
ORDER BY lots DESC
|
||||
|
||||
-- By Age
|
||||
SELECT
|
||||
CASE
|
||||
WHEN AGEBYDAYS < 1 THEN '0-1'
|
||||
WHEN AGEBYDAYS < 3 THEN '1-3'
|
||||
WHEN AGEBYDAYS < 7 THEN '3-7'
|
||||
ELSE '7+'
|
||||
END AS age_range,
|
||||
COUNT(*) AS lots,
|
||||
SUM(QTY) AS qty
|
||||
FROM DWH.DW_PJ_LOT_V
|
||||
WHERE {base_conditions}
|
||||
GROUP BY CASE
|
||||
WHEN AGEBYDAYS < 1 THEN '0-1'
|
||||
WHEN AGEBYDAYS < 3 THEN '1-3'
|
||||
WHEN AGEBYDAYS < 7 THEN '3-7'
|
||||
ELSE '7+'
|
||||
END
|
||||
```
|
||||
|
||||
## 檔案結構
|
||||
|
||||
```
|
||||
src/mes_dashboard/
|
||||
├── routes/
|
||||
│ ├── wip_routes.py # 修改:新增 hold-detail 路由
|
||||
│ └── hold_routes.py # 新增:Hold Detail API 路由
|
||||
├── services/
|
||||
│ └── wip_service.py # 修改:新增 hold detail 查詢函數
|
||||
└── templates/
|
||||
├── wip_overview.html # 修改:Hold Summary 加入連結
|
||||
└── hold_detail.html # 新增:Hold Detail 頁面
|
||||
```
|
||||
63
openspec/changes/archive/2026-01-28-hold-detail/proposal.md
Normal file
63
openspec/changes/archive/2026-01-28-hold-detail/proposal.md
Normal file
@@ -0,0 +1,63 @@
|
||||
## Why
|
||||
|
||||
目前 WIP Overview 的 Hold Summary 僅顯示各 Hold Reason 的統計數據,但缺乏深入分析的功能。當使用者想了解某個特定 Hold Reason 的詳細分佈時,需要手動到其他系統查詢,造成:
|
||||
|
||||
- 無法快速了解特定 Hold Reason 影響了哪些站群和封裝類型
|
||||
- 無法分析 Hold 中的 lot 在當站滯留時間分佈
|
||||
- 需要在多個系統間切換才能取得完整資訊
|
||||
|
||||
## What Changes
|
||||
|
||||
### 新增 Hold Detail 頁面
|
||||
|
||||
當使用者在 WIP Overview 的 Hold Summary 中點擊某個 Hold Reason,導向新的 Hold Detail 頁面,提供以下資訊:
|
||||
|
||||
1. **摘要卡片**
|
||||
- Total Lots:該 Hold Reason 的 lot 總數
|
||||
- Total QTY:總數量
|
||||
- 平均當站滯留:所有 lot 的平均滯留天數
|
||||
- 最久當站滯留:最長滯留天數
|
||||
- 影響站群:受影響的 workcenter 數量
|
||||
|
||||
2. **分佈分析表格**
|
||||
- By Workcenter:各站群的 lot 數、數量、百分比
|
||||
- By Package:各封裝類型的 lot 數、數量、百分比
|
||||
- 兩個表格皆可點擊篩選下方 Lot Details
|
||||
|
||||
3. **當站滯留天數分佈(Age Distribution)**
|
||||
- 0-1 天、1-3 天、3-7 天、7+ 天 四個分段
|
||||
- 每個分段顯示 lot 數、數量、百分比
|
||||
- 可點擊篩選下方 Lot Details
|
||||
|
||||
4. **Lot Details 表格**
|
||||
- 顯示所有符合條件的 lot 明細
|
||||
- 欄位:LOTID, WORKORDER, QTY, Package, Workcenter, Spec, Age, Hold By, Dept
|
||||
- 支援分頁功能
|
||||
- 顯示目前篩選狀態,可一鍵清除
|
||||
|
||||
### Hold Summary 連結
|
||||
|
||||
WIP Overview 的 Hold Summary 表格中,每個 Hold Reason 可點擊導向 Hold Detail 頁面。
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `hold-detail`: 新頁面,顯示特定 Hold Reason 的詳細分佈分析
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `wip-overview`: Hold Summary 中的 Hold Reason 加入連結導向 Hold Detail 頁面
|
||||
|
||||
## Impact
|
||||
|
||||
- **新增檔案**:
|
||||
- `src/mes_dashboard/templates/hold_detail.html` - Hold Detail 頁面模板
|
||||
- `src/mes_dashboard/routes/hold_routes.py` - Hold Detail 路由
|
||||
|
||||
- **修改檔案**:
|
||||
- `src/mes_dashboard/templates/wip_overview.html` - Hold Summary 加入連結
|
||||
- `src/mes_dashboard/services/wip_service.py` - 新增 Hold Detail 相關查詢函數
|
||||
- `src/mes_dashboard/__init__.py` - 註冊新路由
|
||||
|
||||
- **向後相容**:現有功能不受影響
|
||||
65
openspec/changes/archive/2026-01-28-hold-detail/tasks.md
Normal file
65
openspec/changes/archive/2026-01-28-hold-detail/tasks.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# Hold Detail 實作任務
|
||||
|
||||
## 後端任務
|
||||
|
||||
### Task 1: 新增 Hold Detail 服務函數
|
||||
- [x] 在 `wip_service.py` 新增 `get_hold_detail_summary()` 函數
|
||||
- [x] 在 `wip_service.py` 新增 `get_hold_detail_distribution()` 函數
|
||||
- [x] 在 `wip_service.py` 新增 `get_hold_detail_lots()` 函數(含分頁)
|
||||
|
||||
### Task 2: 新增 Hold Detail API 路由
|
||||
- [x] 建立 `src/mes_dashboard/routes/hold_routes.py`
|
||||
- [x] 實作 `GET /api/wip/hold-detail/summary` 端點
|
||||
- [x] 實作 `GET /api/wip/hold-detail/distribution` 端點
|
||||
- [x] 實作 `GET /api/wip/hold-detail/lots` 端點(含篩選參數)
|
||||
- [x] 實作 `GET /hold-detail` 頁面路由
|
||||
- [x] 在 `__init__.py` 註冊新的 blueprint
|
||||
|
||||
## 前端任務
|
||||
|
||||
### Task 3: 建立 Hold Detail 頁面模板
|
||||
- [x] 建立 `src/mes_dashboard/templates/hold_detail.html`
|
||||
- [x] 實作頁首區塊(返回連結、標題)
|
||||
- [x] 實作摘要卡片區塊(5 張卡片)
|
||||
- [x] 實作當站滯留天數分佈區塊(4 個可點擊卡片)
|
||||
- [x] 實作分佈表格區塊(By Workcenter、By Package)
|
||||
- [x] 實作 Lot Details 表格(含分頁)
|
||||
- [x] 實作篩選指示器和清除按鈕
|
||||
|
||||
### Task 4: 實作前端互動邏輯
|
||||
- [x] 實作資料載入函數(Summary、Distribution、Lots)
|
||||
- [x] 實作篩選邏輯(點擊分佈項目篩選 Lot Details)
|
||||
- [x] 實作分頁功能
|
||||
- [x] 實作篩選狀態顯示和清除功能
|
||||
- [x] 處理載入狀態和錯誤狀態
|
||||
|
||||
### Task 5: 修改 WIP Overview 連結
|
||||
- [x] 在 `wip_overview.html` 的 Hold Summary 表格中,為 Hold Reason 加入連結
|
||||
- [x] 連結格式:`/hold-detail?reason=<encoded_reason>`
|
||||
|
||||
## 樣式任務
|
||||
|
||||
### Task 6: 確保樣式一致性
|
||||
- [x] 數值欄位不顯示單位文字
|
||||
- [x] 數值欄位右對齊,文字欄位左對齊
|
||||
- [x] 表格欄位間隔 16px
|
||||
- [x] 可點擊項目顯示 cursor: pointer 和 hover 效果
|
||||
- [x] 選中狀態使用高亮邊框
|
||||
|
||||
## 測試任務
|
||||
|
||||
### Task 7: 功能測試
|
||||
|
||||
**自動化測試** - `tests/test_hold_routes.py` (20/20 passed)
|
||||
- [x] 頁面路由測試(無 reason 時重導向、有 reason 時顯示)
|
||||
- [x] Summary API 測試(成功回傳、缺少參數錯誤、查詢失敗)
|
||||
- [x] Distribution API 測試(成功回傳、缺少參數錯誤、查詢失敗)
|
||||
- [x] Lots API 測試(成功回傳、篩選參數傳遞、分頁參數驗證)
|
||||
- [x] Age Range 參數驗證(0-1, 1-3, 3-7, 7+)
|
||||
|
||||
**手動前端測試**(已通過)
|
||||
- [x] 測試不同 Hold Reason 的資料載入
|
||||
- [x] 測試各種篩選組合
|
||||
- [x] 測試分頁功能
|
||||
- [x] 測試篩選清除功能
|
||||
- [x] 測試從 WIP Overview 導航到 Hold Detail
|
||||
@@ -1,13 +1,14 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""API routes module for MES Dashboard.
|
||||
|
||||
Contains Flask Blueprints for different API endpoints.
|
||||
"""
|
||||
|
||||
# -*- coding: utf-8 -*-
|
||||
"""API routes module for MES Dashboard.
|
||||
|
||||
Contains Flask Blueprints for different API endpoints.
|
||||
"""
|
||||
|
||||
from .wip_routes import wip_bp
|
||||
from .resource_routes import resource_bp
|
||||
from .dashboard_routes import dashboard_bp
|
||||
from .excel_query_routes import excel_query_bp
|
||||
from .hold_routes import hold_bp
|
||||
|
||||
|
||||
def register_routes(app) -> None:
|
||||
@@ -16,11 +17,13 @@ def register_routes(app) -> None:
|
||||
app.register_blueprint(resource_bp)
|
||||
app.register_blueprint(dashboard_bp)
|
||||
app.register_blueprint(excel_query_bp)
|
||||
|
||||
__all__ = [
|
||||
app.register_blueprint(hold_bp)
|
||||
|
||||
__all__ = [
|
||||
'wip_bp',
|
||||
'resource_bp',
|
||||
'dashboard_bp',
|
||||
'excel_query_bp',
|
||||
'hold_bp',
|
||||
'register_routes',
|
||||
]
|
||||
|
||||
152
src/mes_dashboard/routes/hold_routes.py
Normal file
152
src/mes_dashboard/routes/hold_routes.py
Normal file
@@ -0,0 +1,152 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Hold Detail API routes for MES Dashboard.
|
||||
|
||||
Contains Flask Blueprint for Hold Detail page and API endpoints.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, jsonify, request, render_template, redirect, url_for
|
||||
|
||||
from mes_dashboard.services.wip_service import (
|
||||
get_hold_detail_summary,
|
||||
get_hold_detail_distribution,
|
||||
get_hold_detail_lots,
|
||||
is_quality_hold,
|
||||
)
|
||||
|
||||
# Create Blueprint
|
||||
hold_bp = Blueprint('hold', __name__)
|
||||
|
||||
|
||||
def _parse_bool(value: str) -> bool:
|
||||
"""Parse boolean from query string."""
|
||||
return value.lower() in ('true', '1', 'yes') if value else False
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Page Route
|
||||
# ============================================================
|
||||
|
||||
@hold_bp.route('/hold-detail')
|
||||
def hold_detail_page():
|
||||
"""Render the Hold Detail page.
|
||||
|
||||
Query Parameters:
|
||||
reason: Hold reason name (required)
|
||||
|
||||
Returns:
|
||||
Rendered HTML template
|
||||
"""
|
||||
reason = request.args.get('reason', '').strip()
|
||||
if not reason:
|
||||
# Redirect to WIP Overview when reason is missing
|
||||
return redirect('/wip-overview')
|
||||
|
||||
hold_type = 'quality' if is_quality_hold(reason) else 'non-quality'
|
||||
return render_template('hold_detail.html', reason=reason, hold_type=hold_type)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Hold Detail APIs
|
||||
# ============================================================
|
||||
|
||||
@hold_bp.route('/api/wip/hold-detail/summary')
|
||||
def api_hold_detail_summary():
|
||||
"""API: Get summary statistics for a specific hold reason.
|
||||
|
||||
Query Parameters:
|
||||
reason: Hold reason name (required)
|
||||
include_dummy: Include DUMMY lots (default: false)
|
||||
|
||||
Returns:
|
||||
JSON with totalLots, totalQty, avgAge, maxAge, workcenterCount
|
||||
"""
|
||||
reason = request.args.get('reason', '').strip()
|
||||
if not reason:
|
||||
return jsonify({'success': False, 'error': '缺少必要參數: reason'}), 400
|
||||
|
||||
include_dummy = _parse_bool(request.args.get('include_dummy', ''))
|
||||
|
||||
result = get_hold_detail_summary(
|
||||
reason=reason,
|
||||
include_dummy=include_dummy
|
||||
)
|
||||
if result is not None:
|
||||
return jsonify({'success': True, 'data': result})
|
||||
return jsonify({'success': False, 'error': '查詢失敗'}), 500
|
||||
|
||||
|
||||
@hold_bp.route('/api/wip/hold-detail/distribution')
|
||||
def api_hold_detail_distribution():
|
||||
"""API: Get distribution statistics for a specific hold reason.
|
||||
|
||||
Query Parameters:
|
||||
reason: Hold reason name (required)
|
||||
include_dummy: Include DUMMY lots (default: false)
|
||||
|
||||
Returns:
|
||||
JSON with byWorkcenter, byPackage, byAge distributions
|
||||
"""
|
||||
reason = request.args.get('reason', '').strip()
|
||||
if not reason:
|
||||
return jsonify({'success': False, 'error': '缺少必要參數: reason'}), 400
|
||||
|
||||
include_dummy = _parse_bool(request.args.get('include_dummy', ''))
|
||||
|
||||
result = get_hold_detail_distribution(
|
||||
reason=reason,
|
||||
include_dummy=include_dummy
|
||||
)
|
||||
if result is not None:
|
||||
return jsonify({'success': True, 'data': result})
|
||||
return jsonify({'success': False, 'error': '查詢失敗'}), 500
|
||||
|
||||
|
||||
@hold_bp.route('/api/wip/hold-detail/lots')
|
||||
def api_hold_detail_lots():
|
||||
"""API: Get paginated lot details for a specific hold reason.
|
||||
|
||||
Query Parameters:
|
||||
reason: Hold reason name (required)
|
||||
workcenter: Optional WORKCENTER_GROUP filter
|
||||
package: Optional PRODUCTLINENAME filter
|
||||
age_range: Optional age range filter ('0-1', '1-3', '3-7', '7+')
|
||||
include_dummy: Include DUMMY lots (default: false)
|
||||
page: Page number (default 1)
|
||||
per_page: Records per page (default 50, max 200)
|
||||
|
||||
Returns:
|
||||
JSON with lots list, pagination info, and active filters
|
||||
"""
|
||||
reason = request.args.get('reason', '').strip()
|
||||
if not reason:
|
||||
return jsonify({'success': False, 'error': '缺少必要參數: reason'}), 400
|
||||
|
||||
workcenter = request.args.get('workcenter', '').strip() or None
|
||||
package = request.args.get('package', '').strip() or None
|
||||
age_range = request.args.get('age_range', '').strip() or None
|
||||
include_dummy = _parse_bool(request.args.get('include_dummy', ''))
|
||||
page = request.args.get('page', 1, type=int)
|
||||
per_page = min(request.args.get('per_page', 50, type=int), 200)
|
||||
|
||||
if page < 1:
|
||||
page = 1
|
||||
|
||||
# Validate age_range parameter
|
||||
if age_range and age_range not in ('0-1', '1-3', '3-7', '7+'):
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Invalid age_range. Use 0-1, 1-3, 3-7, or 7+'
|
||||
}), 400
|
||||
|
||||
result = get_hold_detail_lots(
|
||||
reason=reason,
|
||||
workcenter=workcenter,
|
||||
package=package,
|
||||
age_range=age_range,
|
||||
include_dummy=include_dummy,
|
||||
page=page,
|
||||
page_size=per_page
|
||||
)
|
||||
if result is not None:
|
||||
return jsonify({'success': True, 'data': result})
|
||||
return jsonify({'success': False, 'error': '查詢失敗'}), 500
|
||||
@@ -484,16 +484,44 @@ def get_wip_detail(
|
||||
return None
|
||||
|
||||
summary_row = summary_df.iloc[0]
|
||||
total_count = int(summary_row['TOTAL_LOTS'] or 0)
|
||||
sys_date = str(summary_row['SYS_DATE']) if summary_row['SYS_DATE'] else None
|
||||
|
||||
# Calculate counts from summary
|
||||
total_lots = int(summary_row['TOTAL_LOTS'] or 0)
|
||||
run_lots = int(summary_row['RUN_LOTS'] or 0)
|
||||
queue_lots = int(summary_row['QUEUE_LOTS'] or 0)
|
||||
hold_lots = int(summary_row['HOLD_LOTS'] or 0)
|
||||
quality_hold_lots = int(summary_row['QUALITY_HOLD_LOTS'] or 0)
|
||||
non_quality_hold_lots = int(summary_row['NON_QUALITY_HOLD_LOTS'] or 0)
|
||||
|
||||
# Determine filtered count based on status filter
|
||||
# When a status filter is applied, use the corresponding count for pagination
|
||||
if status:
|
||||
status_upper = status.upper()
|
||||
if status_upper == 'RUN':
|
||||
filtered_count = run_lots
|
||||
elif status_upper == 'QUEUE':
|
||||
filtered_count = queue_lots
|
||||
elif status_upper == 'HOLD':
|
||||
# Further filter by hold_type if specified
|
||||
if hold_type == 'quality':
|
||||
filtered_count = quality_hold_lots
|
||||
elif hold_type == 'non-quality':
|
||||
filtered_count = non_quality_hold_lots
|
||||
else:
|
||||
filtered_count = hold_lots
|
||||
else:
|
||||
filtered_count = total_lots
|
||||
else:
|
||||
filtered_count = total_lots
|
||||
|
||||
summary = {
|
||||
'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),
|
||||
'qualityHoldLots': int(summary_row['QUALITY_HOLD_LOTS'] or 0),
|
||||
'nonQualityHoldLots': int(summary_row['NON_QUALITY_HOLD_LOTS'] or 0)
|
||||
'totalLots': total_lots,
|
||||
'runLots': run_lots,
|
||||
'queueLots': queue_lots,
|
||||
'holdLots': hold_lots,
|
||||
'qualityHoldLots': quality_hold_lots,
|
||||
'nonQualityHoldLots': non_quality_hold_lots
|
||||
}
|
||||
|
||||
# Get unique specs for this workcenter (sorted by SPECSEQUENCE)
|
||||
@@ -546,7 +574,7 @@ def get_wip_detail(
|
||||
'spec': _safe_value(row['SPECNAME'])
|
||||
})
|
||||
|
||||
total_pages = (total_count + page_size - 1) // page_size if total_count > 0 else 1
|
||||
total_pages = (filtered_count + page_size - 1) // page_size if filtered_count > 0 else 1
|
||||
|
||||
return {
|
||||
'workcenter': workcenter,
|
||||
@@ -556,7 +584,7 @@ def get_wip_detail(
|
||||
'pagination': {
|
||||
'page': page,
|
||||
'page_size': page_size,
|
||||
'total_count': total_count,
|
||||
'total_count': filtered_count,
|
||||
'total_pages': total_pages
|
||||
},
|
||||
'sys_date': sys_date
|
||||
@@ -746,3 +774,324 @@ def search_lot_ids(
|
||||
except Exception as exc:
|
||||
print(f"Search lot IDs failed: {exc}")
|
||||
return None
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Hold Detail API Functions
|
||||
# ============================================================
|
||||
|
||||
def get_hold_detail_summary(
|
||||
reason: str,
|
||||
include_dummy: bool = False
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Get summary statistics for a specific hold reason.
|
||||
|
||||
Args:
|
||||
reason: The HOLDREASONNAME to filter by
|
||||
include_dummy: If True, include DUMMY lots (default: False)
|
||||
|
||||
Returns:
|
||||
Dict with totalLots, totalQty, avgAge, maxAge, workcenterCount
|
||||
"""
|
||||
try:
|
||||
conditions = _build_base_conditions(include_dummy)
|
||||
conditions.append("STATUS = 'HOLD'")
|
||||
conditions.append("CURRENTHOLDCOUNT > 0")
|
||||
conditions.append(f"HOLDREASONNAME = '{_escape_sql(reason)}'")
|
||||
where_clause = f"WHERE {' AND '.join(conditions)}"
|
||||
|
||||
sql = f"""
|
||||
SELECT
|
||||
COUNT(*) AS TOTAL_LOTS,
|
||||
SUM(QTY) AS TOTAL_QTY,
|
||||
ROUND(AVG(AGEBYDAYS), 1) AS AVG_AGE,
|
||||
MAX(AGEBYDAYS) AS MAX_AGE,
|
||||
COUNT(DISTINCT WORKCENTER_GROUP) AS WORKCENTER_COUNT
|
||||
FROM {WIP_VIEW}
|
||||
{where_clause}
|
||||
"""
|
||||
df = read_sql_df(sql)
|
||||
|
||||
if df is None or df.empty:
|
||||
return None
|
||||
|
||||
row = df.iloc[0]
|
||||
return {
|
||||
'totalLots': int(row['TOTAL_LOTS'] or 0),
|
||||
'totalQty': int(row['TOTAL_QTY'] or 0),
|
||||
'avgAge': float(row['AVG_AGE']) if row['AVG_AGE'] else 0,
|
||||
'maxAge': float(row['MAX_AGE']) if row['MAX_AGE'] else 0,
|
||||
'workcenterCount': int(row['WORKCENTER_COUNT'] or 0)
|
||||
}
|
||||
except Exception as exc:
|
||||
print(f"Hold detail summary query failed: {exc}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return None
|
||||
|
||||
|
||||
def get_hold_detail_distribution(
|
||||
reason: str,
|
||||
include_dummy: bool = False
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Get distribution statistics for a specific hold reason.
|
||||
|
||||
Args:
|
||||
reason: The HOLDREASONNAME to filter by
|
||||
include_dummy: If True, include DUMMY lots (default: False)
|
||||
|
||||
Returns:
|
||||
Dict with byWorkcenter, byPackage, byAge distributions
|
||||
"""
|
||||
try:
|
||||
conditions = _build_base_conditions(include_dummy)
|
||||
conditions.append("STATUS = 'HOLD'")
|
||||
conditions.append("CURRENTHOLDCOUNT > 0")
|
||||
conditions.append(f"HOLDREASONNAME = '{_escape_sql(reason)}'")
|
||||
where_clause = f"WHERE {' AND '.join(conditions)}"
|
||||
|
||||
# Get total for percentage calculation
|
||||
total_sql = f"""
|
||||
SELECT COUNT(*) AS TOTAL_LOTS, SUM(QTY) AS TOTAL_QTY
|
||||
FROM {WIP_VIEW}
|
||||
{where_clause}
|
||||
"""
|
||||
total_df = read_sql_df(total_sql)
|
||||
total_lots = int(total_df.iloc[0]['TOTAL_LOTS'] or 0) if total_df is not None else 0
|
||||
|
||||
if total_lots == 0:
|
||||
return {
|
||||
'byWorkcenter': [],
|
||||
'byPackage': [],
|
||||
'byAge': []
|
||||
}
|
||||
|
||||
# By Workcenter
|
||||
wc_sql = f"""
|
||||
SELECT
|
||||
WORKCENTER_GROUP AS NAME,
|
||||
COUNT(*) AS LOTS,
|
||||
SUM(QTY) AS QTY
|
||||
FROM {WIP_VIEW}
|
||||
{where_clause}
|
||||
AND WORKCENTER_GROUP IS NOT NULL
|
||||
GROUP BY WORKCENTER_GROUP
|
||||
ORDER BY COUNT(*) DESC
|
||||
"""
|
||||
wc_df = read_sql_df(wc_sql)
|
||||
by_workcenter = []
|
||||
if wc_df is not None and not wc_df.empty:
|
||||
for _, row in wc_df.iterrows():
|
||||
lots = int(row['LOTS'] or 0)
|
||||
by_workcenter.append({
|
||||
'name': row['NAME'],
|
||||
'lots': lots,
|
||||
'qty': int(row['QTY'] or 0),
|
||||
'percentage': round(lots / total_lots * 100, 1) if total_lots > 0 else 0
|
||||
})
|
||||
|
||||
# By Package
|
||||
pkg_sql = f"""
|
||||
SELECT
|
||||
PRODUCTLINENAME AS NAME,
|
||||
COUNT(*) AS LOTS,
|
||||
SUM(QTY) AS QTY
|
||||
FROM {WIP_VIEW}
|
||||
{where_clause}
|
||||
AND PRODUCTLINENAME IS NOT NULL
|
||||
GROUP BY PRODUCTLINENAME
|
||||
ORDER BY COUNT(*) DESC
|
||||
"""
|
||||
pkg_df = read_sql_df(pkg_sql)
|
||||
by_package = []
|
||||
if pkg_df is not None and not pkg_df.empty:
|
||||
for _, row in pkg_df.iterrows():
|
||||
lots = int(row['LOTS'] or 0)
|
||||
by_package.append({
|
||||
'name': row['NAME'],
|
||||
'lots': lots,
|
||||
'qty': int(row['QTY'] or 0),
|
||||
'percentage': round(lots / total_lots * 100, 1) if total_lots > 0 else 0
|
||||
})
|
||||
|
||||
# By Age (station dwell time)
|
||||
age_sql = f"""
|
||||
SELECT
|
||||
CASE
|
||||
WHEN AGEBYDAYS < 1 THEN '0-1'
|
||||
WHEN AGEBYDAYS < 3 THEN '1-3'
|
||||
WHEN AGEBYDAYS < 7 THEN '3-7'
|
||||
ELSE '7+'
|
||||
END AS AGE_RANGE,
|
||||
COUNT(*) AS LOTS,
|
||||
SUM(QTY) AS QTY
|
||||
FROM {WIP_VIEW}
|
||||
{where_clause}
|
||||
GROUP BY CASE
|
||||
WHEN AGEBYDAYS < 1 THEN '0-1'
|
||||
WHEN AGEBYDAYS < 3 THEN '1-3'
|
||||
WHEN AGEBYDAYS < 7 THEN '3-7'
|
||||
ELSE '7+'
|
||||
END
|
||||
"""
|
||||
age_df = read_sql_df(age_sql)
|
||||
|
||||
# Define age ranges in order
|
||||
age_labels = {
|
||||
'0-1': '0-1天',
|
||||
'1-3': '1-3天',
|
||||
'3-7': '3-7天',
|
||||
'7+': '7+天'
|
||||
}
|
||||
age_order = ['0-1', '1-3', '3-7', '7+']
|
||||
|
||||
# Build age distribution with all ranges (even if 0)
|
||||
age_data = {r: {'lots': 0, 'qty': 0} for r in age_order}
|
||||
if age_df is not None and not age_df.empty:
|
||||
for _, row in age_df.iterrows():
|
||||
range_key = row['AGE_RANGE']
|
||||
if range_key in age_data:
|
||||
age_data[range_key] = {
|
||||
'lots': int(row['LOTS'] or 0),
|
||||
'qty': int(row['QTY'] or 0)
|
||||
}
|
||||
|
||||
by_age = []
|
||||
for r in age_order:
|
||||
lots = age_data[r]['lots']
|
||||
by_age.append({
|
||||
'range': r,
|
||||
'label': age_labels[r],
|
||||
'lots': lots,
|
||||
'qty': age_data[r]['qty'],
|
||||
'percentage': round(lots / total_lots * 100, 1) if total_lots > 0 else 0
|
||||
})
|
||||
|
||||
return {
|
||||
'byWorkcenter': by_workcenter,
|
||||
'byPackage': by_package,
|
||||
'byAge': by_age
|
||||
}
|
||||
except Exception as exc:
|
||||
print(f"Hold detail distribution query failed: {exc}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return None
|
||||
|
||||
|
||||
def get_hold_detail_lots(
|
||||
reason: str,
|
||||
workcenter: Optional[str] = None,
|
||||
package: Optional[str] = None,
|
||||
age_range: Optional[str] = None,
|
||||
include_dummy: bool = False,
|
||||
page: int = 1,
|
||||
page_size: int = 50
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Get paginated lot details for a specific hold reason.
|
||||
|
||||
Args:
|
||||
reason: The HOLDREASONNAME to filter by
|
||||
workcenter: Optional WORKCENTER_GROUP filter
|
||||
package: Optional PRODUCTLINENAME filter
|
||||
age_range: Optional age range filter ('0-1', '1-3', '3-7', '7+')
|
||||
include_dummy: If True, include DUMMY lots (default: False)
|
||||
page: Page number (1-based)
|
||||
page_size: Number of records per page
|
||||
|
||||
Returns:
|
||||
Dict with lots list, pagination info, and active filters
|
||||
"""
|
||||
try:
|
||||
conditions = _build_base_conditions(include_dummy)
|
||||
conditions.append("STATUS = 'HOLD'")
|
||||
conditions.append("CURRENTHOLDCOUNT > 0")
|
||||
conditions.append(f"HOLDREASONNAME = '{_escape_sql(reason)}'")
|
||||
|
||||
# Optional filters
|
||||
if workcenter:
|
||||
conditions.append(f"WORKCENTER_GROUP = '{_escape_sql(workcenter)}'")
|
||||
if package:
|
||||
conditions.append(f"PRODUCTLINENAME = '{_escape_sql(package)}'")
|
||||
if age_range:
|
||||
if age_range == '0-1':
|
||||
conditions.append("AGEBYDAYS >= 0 AND AGEBYDAYS < 1")
|
||||
elif age_range == '1-3':
|
||||
conditions.append("AGEBYDAYS >= 1 AND AGEBYDAYS < 3")
|
||||
elif age_range == '3-7':
|
||||
conditions.append("AGEBYDAYS >= 3 AND AGEBYDAYS < 7")
|
||||
elif age_range == '7+':
|
||||
conditions.append("AGEBYDAYS >= 7")
|
||||
|
||||
where_clause = f"WHERE {' AND '.join(conditions)}"
|
||||
|
||||
# Get total count
|
||||
count_sql = f"""
|
||||
SELECT COUNT(*) AS TOTAL
|
||||
FROM {WIP_VIEW}
|
||||
{where_clause}
|
||||
"""
|
||||
count_df = read_sql_df(count_sql)
|
||||
total = int(count_df.iloc[0]['TOTAL'] or 0) if count_df is not None else 0
|
||||
|
||||
# Get paginated lots
|
||||
offset = (page - 1) * page_size
|
||||
lots_sql = f"""
|
||||
SELECT * FROM (
|
||||
SELECT
|
||||
LOTID,
|
||||
WORKORDER,
|
||||
QTY,
|
||||
PRODUCTLINENAME AS PACKAGE,
|
||||
WORKCENTER_GROUP AS WORKCENTER,
|
||||
SPECNAME AS SPEC,
|
||||
ROUND(AGEBYDAYS, 1) AS AGE,
|
||||
HOLDEMP AS HOLD_BY,
|
||||
DEPTNAME AS DEPT,
|
||||
COMMENT_HOLD AS HOLD_COMMENT,
|
||||
ROW_NUMBER() OVER (ORDER BY AGEBYDAYS DESC, LOTID) AS RN
|
||||
FROM {WIP_VIEW}
|
||||
{where_clause}
|
||||
)
|
||||
WHERE RN > {offset} AND RN <= {offset + page_size}
|
||||
ORDER BY RN
|
||||
"""
|
||||
lots_df = read_sql_df(lots_sql)
|
||||
|
||||
lots = []
|
||||
if lots_df is not None and not lots_df.empty:
|
||||
for _, row in lots_df.iterrows():
|
||||
lots.append({
|
||||
'lotId': _safe_value(row['LOTID']),
|
||||
'workorder': _safe_value(row['WORKORDER']),
|
||||
'qty': int(row['QTY'] or 0),
|
||||
'package': _safe_value(row['PACKAGE']),
|
||||
'workcenter': _safe_value(row['WORKCENTER']),
|
||||
'spec': _safe_value(row['SPEC']),
|
||||
'age': float(row['AGE']) if row['AGE'] else 0,
|
||||
'holdBy': _safe_value(row['HOLD_BY']),
|
||||
'dept': _safe_value(row['DEPT']),
|
||||
'holdComment': _safe_value(row['HOLD_COMMENT'])
|
||||
})
|
||||
|
||||
total_pages = (total + page_size - 1) // page_size if total > 0 else 1
|
||||
|
||||
return {
|
||||
'lots': lots,
|
||||
'pagination': {
|
||||
'page': page,
|
||||
'perPage': page_size,
|
||||
'total': total,
|
||||
'totalPages': total_pages
|
||||
},
|
||||
'filters': {
|
||||
'workcenter': workcenter,
|
||||
'package': package,
|
||||
'ageRange': age_range
|
||||
}
|
||||
}
|
||||
except Exception as exc:
|
||||
print(f"Hold detail lots query failed: {exc}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return None
|
||||
|
||||
989
src/mes_dashboard/templates/hold_detail.html
Normal file
989
src/mes_dashboard/templates/hold_detail.html
Normal file
@@ -0,0 +1,989 @@
|
||||
{% extends "_base.html" %}
|
||||
|
||||
{% block title %}Hold Detail - {{ reason }}{% endblock %}
|
||||
|
||||
{% block head_extra %}
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f5f7fa;
|
||||
--card-bg: #ffffff;
|
||||
--text: #222;
|
||||
--muted: #666;
|
||||
--border: #e2e6ef;
|
||||
--primary: #667eea;
|
||||
--primary-dark: #5568d3;
|
||||
--shadow: 0 2px 10px rgba(0,0,0,0.08);
|
||||
--shadow-strong: 0 4px 15px rgba(102, 126, 234, 0.2);
|
||||
--success: #22c55e;
|
||||
--danger: #ef4444;
|
||||
--warning: #f59e0b;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Microsoft JhengHei', Arial, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.dashboard {
|
||||
max-width: 1900px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
padding: 18px 22px;
|
||||
background: linear-gradient(135deg, {% if hold_type == 'quality' %}#ef4444 0%, #dc2626{% else %}#f97316 0%, #ea580c{% endif %} 100%);
|
||||
border-radius: 10px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: var(--shadow-strong);
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 20px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.last-update {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.refresh-indicator {
|
||||
display: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid rgba(255,255,255,0.3);
|
||||
border-top-color: white;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.refresh-indicator.active {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 9px 20px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-light {
|
||||
background: rgba(255,255,255,0.2);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-light:hover {
|
||||
background: rgba(255,255,255,0.3);
|
||||
}
|
||||
|
||||
.btn-back {
|
||||
background: rgba(255,255,255,0.15);
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.btn-back:hover {
|
||||
background: rgba(255,255,255,0.25);
|
||||
}
|
||||
|
||||
.hold-type-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
background: rgba(255,255,255,0.2);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Summary Cards */
|
||||
.summary-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
background: var(--card-bg);
|
||||
border-radius: 10px;
|
||||
padding: 16px 20px;
|
||||
text-align: center;
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.summary-value.small {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
/* Age Distribution Cards */
|
||||
.section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.age-distribution {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.age-card {
|
||||
background: var(--card-bg);
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
border: 2px solid var(--border);
|
||||
box-shadow: var(--shadow);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.age-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.age-card.active {
|
||||
border-color: var(--primary);
|
||||
background: #f0f4ff;
|
||||
}
|
||||
|
||||
.age-card .age-label {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 10px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.age-card .age-stats {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.age-card .age-stat {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.age-card .age-stat .label {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.age-card .age-stat .value {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.age-card .age-percentage {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--primary);
|
||||
margin-top: 8px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Distribution Grid */
|
||||
.distribution-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--card-bg);
|
||||
border-radius: 10px;
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding: 14px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: #fafbfc;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 0;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Distribution Table */
|
||||
.dist-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.dist-table th,
|
||||
.dist-table td {
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.dist-table th {
|
||||
background: #f8f9fa;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.dist-table th:nth-child(2),
|
||||
.dist-table th:nth-child(3),
|
||||
.dist-table th:nth-child(4) {
|
||||
text-align: right;
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.dist-table td:nth-child(2),
|
||||
.dist-table td:nth-child(3),
|
||||
.dist-table td:nth-child(4) {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.dist-table tbody tr {
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.dist-table tbody tr:hover {
|
||||
background: #f0f4ff;
|
||||
}
|
||||
|
||||
.dist-table tbody tr.active {
|
||||
background: #e0e7ff;
|
||||
}
|
||||
|
||||
/* Lot Details Table */
|
||||
.table-section {
|
||||
background: var(--card-bg);
|
||||
border-radius: 10px;
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 14px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: #fafbfc;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.table-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.table-info {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.filter-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--primary);
|
||||
background: #e8ecff;
|
||||
padding: 4px 12px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.filter-indicator .clear-btn {
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.filter-indicator .clear-btn:hover {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
max-height: 500px;
|
||||
}
|
||||
|
||||
.lot-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.lot-table th,
|
||||
.lot-table td {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.lot-table th {
|
||||
background: #f8f9fa;
|
||||
font-weight: 600;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.lot-table td:nth-child(3),
|
||||
.lot-table td:nth-child(7),
|
||||
.lot-table th:nth-child(3),
|
||||
.lot-table th:nth-child(7) {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.lot-table tbody tr:hover {
|
||||
background: #f8f9fc;
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.pagination button {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--border);
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.pagination button:hover:not(:disabled) {
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.pagination button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.pagination .page-info {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* Loading */
|
||||
.loading-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
display: inline-block;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 3px solid var(--border);
|
||||
border-top-color: var(--primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 1400px) {
|
||||
.summary-row {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
.age-distribution {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.summary-row {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
.distribution-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.summary-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.age-distribution {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="dashboard">
|
||||
<!-- Header -->
|
||||
<div class="header">
|
||||
<div class="header-left">
|
||||
<a href="/wip-overview" class="btn btn-back">← WIP Overview</a>
|
||||
<h1>Hold Detail: {{ reason }}</h1>
|
||||
<span class="hold-type-badge">{% if hold_type == 'quality' %}品質異常{% else %}非品質異常{% endif %}</span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<span class="last-update">
|
||||
<span class="refresh-indicator" id="refreshIndicator"></span>
|
||||
<span id="lastUpdate"></span>
|
||||
</span>
|
||||
<button class="btn btn-light" onclick="manualRefresh()">重新整理</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary Cards -->
|
||||
<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 class="summary-card">
|
||||
<div class="summary-label">平均當站滯留</div>
|
||||
<div class="summary-value small" id="avgAge">-</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<div class="summary-label">最久當站滯留</div>
|
||||
<div class="summary-value small" id="maxAge">-</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<div class="summary-label">影響站群</div>
|
||||
<div class="summary-value" id="workcenterCount">-</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Age Distribution -->
|
||||
<div class="section-title">當站滯留天數分佈 (Age at Current Station)</div>
|
||||
<div class="age-distribution" id="ageDistribution">
|
||||
<div class="age-card" data-range="0-1" onclick="toggleAgeFilter('0-1')">
|
||||
<div class="age-label">0-1天</div>
|
||||
<div class="age-stats">
|
||||
<div class="age-stat"><span class="label">Lots</span><span class="value" id="age01Lots">-</span></div>
|
||||
<div class="age-stat"><span class="label">QTY</span><span class="value" id="age01Qty">-</span></div>
|
||||
</div>
|
||||
<div class="age-percentage" id="age01Pct">-</div>
|
||||
</div>
|
||||
<div class="age-card" data-range="1-3" onclick="toggleAgeFilter('1-3')">
|
||||
<div class="age-label">1-3天</div>
|
||||
<div class="age-stats">
|
||||
<div class="age-stat"><span class="label">Lots</span><span class="value" id="age13Lots">-</span></div>
|
||||
<div class="age-stat"><span class="label">QTY</span><span class="value" id="age13Qty">-</span></div>
|
||||
</div>
|
||||
<div class="age-percentage" id="age13Pct">-</div>
|
||||
</div>
|
||||
<div class="age-card" data-range="3-7" onclick="toggleAgeFilter('3-7')">
|
||||
<div class="age-label">3-7天</div>
|
||||
<div class="age-stats">
|
||||
<div class="age-stat"><span class="label">Lots</span><span class="value" id="age37Lots">-</span></div>
|
||||
<div class="age-stat"><span class="label">QTY</span><span class="value" id="age37Qty">-</span></div>
|
||||
</div>
|
||||
<div class="age-percentage" id="age37Pct">-</div>
|
||||
</div>
|
||||
<div class="age-card" data-range="7+" onclick="toggleAgeFilter('7+')">
|
||||
<div class="age-label">7+天</div>
|
||||
<div class="age-stats">
|
||||
<div class="age-stat"><span class="label">Lots</span><span class="value" id="age7Lots">-</span></div>
|
||||
<div class="age-stat"><span class="label">QTY</span><span class="value" id="age7Qty">-</span></div>
|
||||
</div>
|
||||
<div class="age-percentage" id="age7Pct">-</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Distribution Tables -->
|
||||
<div class="distribution-grid">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-title">By Workcenter</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="dist-table" id="workcenterTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Workcenter</th>
|
||||
<th>Lots</th>
|
||||
<th>QTY</th>
|
||||
<th>%</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="workcenterBody">
|
||||
<tr><td colspan="4" class="placeholder">Loading...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-title">By Package</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="dist-table" id="packageTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Package</th>
|
||||
<th>Lots</th>
|
||||
<th>QTY</th>
|
||||
<th>%</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="packageBody">
|
||||
<tr><td colspan="4" class="placeholder">Loading...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lot Details Table -->
|
||||
<div class="table-section">
|
||||
<div class="table-header">
|
||||
<div class="table-title">Lot Details</div>
|
||||
<div id="filterIndicator" style="display: none;" class="filter-indicator">
|
||||
<span id="filterText"></span>
|
||||
<span class="clear-btn" onclick="clearFilters()">×</span>
|
||||
</div>
|
||||
<div class="table-info" id="tableInfo">Loading...</div>
|
||||
</div>
|
||||
<div class="table-container" id="tableContainer">
|
||||
<table class="lot-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>LOTID</th>
|
||||
<th>WORKORDER</th>
|
||||
<th>QTY</th>
|
||||
<th>Package</th>
|
||||
<th>Workcenter</th>
|
||||
<th>Spec</th>
|
||||
<th>Age</th>
|
||||
<th>Hold By</th>
|
||||
<th>Dept</th>
|
||||
<th>Hold Comment</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="lotBody">
|
||||
<tr><td colspan="10" class="placeholder">Loading...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pagination" id="pagination" style="display: none;">
|
||||
<button id="btnPrev" onclick="prevPage()">Prev</button>
|
||||
<span class="page-info" id="pageInfo">Page 1</span>
|
||||
<button id="btnNext" onclick="nextPage()">Next</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading Overlay -->
|
||||
<div class="loading-overlay" id="loadingOverlay">
|
||||
<span class="loading-spinner"></span>
|
||||
<span>Loading...</span>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
// ============================================================
|
||||
// State
|
||||
// ============================================================
|
||||
const state = {
|
||||
reason: '{{ reason | e }}',
|
||||
summary: null,
|
||||
distribution: null,
|
||||
lots: null,
|
||||
page: 1,
|
||||
perPage: 50,
|
||||
filters: {
|
||||
workcenter: null,
|
||||
package: null,
|
||||
ageRange: null
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Utility
|
||||
// ============================================================
|
||||
function formatNumber(num) {
|
||||
if (num === null || num === undefined || num === '-') return '-';
|
||||
return num.toLocaleString('zh-TW');
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// API Functions
|
||||
// ============================================================
|
||||
const API_TIMEOUT = 60000;
|
||||
|
||||
async function fetchSummary() {
|
||||
const result = await MesApi.get('/api/wip/hold-detail/summary', {
|
||||
params: { reason: state.reason },
|
||||
timeout: API_TIMEOUT
|
||||
});
|
||||
if (result.success) return result.data;
|
||||
throw new Error(result.error);
|
||||
}
|
||||
|
||||
async function fetchDistribution() {
|
||||
const result = await MesApi.get('/api/wip/hold-detail/distribution', {
|
||||
params: { reason: state.reason },
|
||||
timeout: API_TIMEOUT
|
||||
});
|
||||
if (result.success) return result.data;
|
||||
throw new Error(result.error);
|
||||
}
|
||||
|
||||
async function fetchLots() {
|
||||
const params = {
|
||||
reason: state.reason,
|
||||
page: state.page,
|
||||
per_page: state.perPage
|
||||
};
|
||||
if (state.filters.workcenter) params.workcenter = state.filters.workcenter;
|
||||
if (state.filters.package) params.package = state.filters.package;
|
||||
if (state.filters.ageRange) params.age_range = state.filters.ageRange;
|
||||
|
||||
const result = await MesApi.get('/api/wip/hold-detail/lots', {
|
||||
params,
|
||||
timeout: API_TIMEOUT
|
||||
});
|
||||
if (result.success) return result.data;
|
||||
throw new Error(result.error);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Render Functions
|
||||
// ============================================================
|
||||
function renderSummary(data) {
|
||||
document.getElementById('totalLots').textContent = formatNumber(data.totalLots);
|
||||
document.getElementById('totalQty').textContent = formatNumber(data.totalQty);
|
||||
document.getElementById('avgAge').textContent = data.avgAge ? `${data.avgAge}天` : '-';
|
||||
document.getElementById('maxAge').textContent = data.maxAge ? `${data.maxAge}天` : '-';
|
||||
document.getElementById('workcenterCount').textContent = formatNumber(data.workcenterCount);
|
||||
}
|
||||
|
||||
function renderDistribution(data) {
|
||||
// Age distribution
|
||||
const ageMap = {};
|
||||
data.byAge.forEach(item => { ageMap[item.range] = item; });
|
||||
|
||||
const age01 = ageMap['0-1'] || { lots: 0, qty: 0, percentage: 0 };
|
||||
const age13 = ageMap['1-3'] || { lots: 0, qty: 0, percentage: 0 };
|
||||
const age37 = ageMap['3-7'] || { lots: 0, qty: 0, percentage: 0 };
|
||||
const age7 = ageMap['7+'] || { lots: 0, qty: 0, percentage: 0 };
|
||||
|
||||
document.getElementById('age01Lots').textContent = formatNumber(age01.lots);
|
||||
document.getElementById('age01Qty').textContent = formatNumber(age01.qty);
|
||||
document.getElementById('age01Pct').textContent = `${age01.percentage}%`;
|
||||
|
||||
document.getElementById('age13Lots').textContent = formatNumber(age13.lots);
|
||||
document.getElementById('age13Qty').textContent = formatNumber(age13.qty);
|
||||
document.getElementById('age13Pct').textContent = `${age13.percentage}%`;
|
||||
|
||||
document.getElementById('age37Lots').textContent = formatNumber(age37.lots);
|
||||
document.getElementById('age37Qty').textContent = formatNumber(age37.qty);
|
||||
document.getElementById('age37Pct').textContent = `${age37.percentage}%`;
|
||||
|
||||
document.getElementById('age7Lots').textContent = formatNumber(age7.lots);
|
||||
document.getElementById('age7Qty').textContent = formatNumber(age7.qty);
|
||||
document.getElementById('age7Pct').textContent = `${age7.percentage}%`;
|
||||
|
||||
// Workcenter table
|
||||
const wcBody = document.getElementById('workcenterBody');
|
||||
if (data.byWorkcenter.length === 0) {
|
||||
wcBody.innerHTML = '<tr><td colspan="4" class="placeholder">No data</td></tr>';
|
||||
} else {
|
||||
wcBody.innerHTML = data.byWorkcenter.map(item => `
|
||||
<tr data-workcenter="${item.name}" onclick="toggleWorkcenterFilter('${item.name}')" class="${state.filters.workcenter === item.name ? 'active' : ''}">
|
||||
<td>${item.name}</td>
|
||||
<td>${formatNumber(item.lots)}</td>
|
||||
<td>${formatNumber(item.qty)}</td>
|
||||
<td>${item.percentage}%</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// Package table
|
||||
const pkgBody = document.getElementById('packageBody');
|
||||
if (data.byPackage.length === 0) {
|
||||
pkgBody.innerHTML = '<tr><td colspan="4" class="placeholder">No data</td></tr>';
|
||||
} else {
|
||||
pkgBody.innerHTML = data.byPackage.map(item => `
|
||||
<tr data-package="${item.name}" onclick="togglePackageFilter('${item.name}')" class="${state.filters.package === item.name ? 'active' : ''}">
|
||||
<td>${item.name}</td>
|
||||
<td>${formatNumber(item.lots)}</td>
|
||||
<td>${formatNumber(item.qty)}</td>
|
||||
<td>${item.percentage}%</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
}
|
||||
|
||||
function renderLots(data) {
|
||||
const tbody = document.getElementById('lotBody');
|
||||
const lots = data.lots;
|
||||
|
||||
if (lots.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="9" class="placeholder">No data</td></tr>';
|
||||
document.getElementById('tableInfo').textContent = 'No data';
|
||||
document.getElementById('pagination').style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = lots.map(lot => `
|
||||
<tr>
|
||||
<td>${lot.lotId || '-'}</td>
|
||||
<td>${lot.workorder || '-'}</td>
|
||||
<td>${formatNumber(lot.qty)}</td>
|
||||
<td>${lot.package || '-'}</td>
|
||||
<td>${lot.workcenter || '-'}</td>
|
||||
<td>${lot.spec || '-'}</td>
|
||||
<td>${lot.age}天</td>
|
||||
<td>${lot.holdBy || '-'}</td>
|
||||
<td>${lot.dept || '-'}</td>
|
||||
<td>${lot.holdComment || '-'}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
// Update pagination
|
||||
const pg = data.pagination;
|
||||
const start = (pg.page - 1) * pg.perPage + 1;
|
||||
const end = Math.min(pg.page * pg.perPage, pg.total);
|
||||
document.getElementById('tableInfo').textContent = `顯示 ${start} - ${end} / ${formatNumber(pg.total)}`;
|
||||
|
||||
if (pg.totalPages > 1) {
|
||||
document.getElementById('pagination').style.display = 'flex';
|
||||
document.getElementById('pageInfo').textContent = `Page ${pg.page} / ${pg.totalPages}`;
|
||||
document.getElementById('btnPrev').disabled = pg.page <= 1;
|
||||
document.getElementById('btnNext').disabled = pg.page >= pg.totalPages;
|
||||
} else {
|
||||
document.getElementById('pagination').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function updateFilterIndicator() {
|
||||
const indicator = document.getElementById('filterIndicator');
|
||||
const text = document.getElementById('filterText');
|
||||
const parts = [];
|
||||
|
||||
if (state.filters.workcenter) parts.push(`Workcenter=${state.filters.workcenter}`);
|
||||
if (state.filters.package) parts.push(`Package=${state.filters.package}`);
|
||||
if (state.filters.ageRange) parts.push(`Age=${state.filters.ageRange}天`);
|
||||
|
||||
if (parts.length > 0) {
|
||||
text.textContent = '篩選: ' + parts.join(', ');
|
||||
indicator.style.display = 'flex';
|
||||
} else {
|
||||
indicator.style.display = 'none';
|
||||
}
|
||||
|
||||
// Update active states
|
||||
document.querySelectorAll('.age-card').forEach(card => {
|
||||
card.classList.toggle('active', card.dataset.range === state.filters.ageRange);
|
||||
});
|
||||
document.querySelectorAll('#workcenterBody tr').forEach(row => {
|
||||
row.classList.toggle('active', row.dataset.workcenter === state.filters.workcenter);
|
||||
});
|
||||
document.querySelectorAll('#packageBody tr').forEach(row => {
|
||||
row.classList.toggle('active', row.dataset.package === state.filters.package);
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Filter Functions
|
||||
// ============================================================
|
||||
function toggleAgeFilter(range) {
|
||||
state.filters.ageRange = state.filters.ageRange === range ? null : range;
|
||||
state.page = 1;
|
||||
updateFilterIndicator();
|
||||
loadLots();
|
||||
}
|
||||
|
||||
function toggleWorkcenterFilter(wc) {
|
||||
state.filters.workcenter = state.filters.workcenter === wc ? null : wc;
|
||||
state.page = 1;
|
||||
updateFilterIndicator();
|
||||
loadLots();
|
||||
}
|
||||
|
||||
function togglePackageFilter(pkg) {
|
||||
state.filters.package = state.filters.package === pkg ? null : pkg;
|
||||
state.page = 1;
|
||||
updateFilterIndicator();
|
||||
loadLots();
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
state.filters = { workcenter: null, package: null, ageRange: null };
|
||||
state.page = 1;
|
||||
updateFilterIndicator();
|
||||
loadLots();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Pagination
|
||||
// ============================================================
|
||||
function prevPage() {
|
||||
if (state.page > 1) {
|
||||
state.page--;
|
||||
loadLots();
|
||||
}
|
||||
}
|
||||
|
||||
function nextPage() {
|
||||
if (state.lots && state.page < state.lots.pagination.totalPages) {
|
||||
state.page++;
|
||||
loadLots();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Data Loading
|
||||
// ============================================================
|
||||
async function loadLots() {
|
||||
document.getElementById('lotBody').innerHTML = '<tr><td colspan="9" class="placeholder">Loading...</td></tr>';
|
||||
document.getElementById('refreshIndicator').classList.add('active');
|
||||
|
||||
try {
|
||||
state.lots = await fetchLots();
|
||||
renderLots(state.lots);
|
||||
} catch (error) {
|
||||
console.error('Load lots failed:', error);
|
||||
document.getElementById('lotBody').innerHTML = '<tr><td colspan="9" class="placeholder">Error loading data</td></tr>';
|
||||
} finally {
|
||||
document.getElementById('refreshIndicator').classList.remove('active');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAllData(showOverlay = true) {
|
||||
if (showOverlay) {
|
||||
document.getElementById('loadingOverlay').style.display = 'flex';
|
||||
}
|
||||
document.getElementById('refreshIndicator').classList.add('active');
|
||||
|
||||
try {
|
||||
const [summary, distribution, lots] = await Promise.all([
|
||||
fetchSummary(),
|
||||
fetchDistribution(),
|
||||
fetchLots()
|
||||
]);
|
||||
|
||||
state.summary = summary;
|
||||
state.distribution = distribution;
|
||||
state.lots = lots;
|
||||
|
||||
renderSummary(summary);
|
||||
renderDistribution(distribution);
|
||||
renderLots(lots);
|
||||
updateFilterIndicator();
|
||||
|
||||
document.getElementById('lastUpdate').textContent = `Last Update: ${new Date().toLocaleString('zh-TW')}`;
|
||||
} catch (error) {
|
||||
console.error('Load data failed:', error);
|
||||
} finally {
|
||||
document.getElementById('loadingOverlay').style.display = 'none';
|
||||
document.getElementById('refreshIndicator').classList.remove('active');
|
||||
}
|
||||
}
|
||||
|
||||
function manualRefresh() {
|
||||
loadAllData(false);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Initialize
|
||||
// ============================================================
|
||||
window.onload = function() {
|
||||
loadAllData(true);
|
||||
};
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -596,6 +596,17 @@
|
||||
color: #991B1B;
|
||||
}
|
||||
|
||||
.hold-reason-link {
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.hold-reason-link:hover {
|
||||
text-decoration: underline;
|
||||
color: var(--primary-dark);
|
||||
}
|
||||
|
||||
.hold-type-badge.non-quality {
|
||||
background: #FFEDD5;
|
||||
color: #9A3412;
|
||||
@@ -1245,8 +1256,12 @@
|
||||
data.items.forEach(item => {
|
||||
const badgeClass = item.holdType === 'quality' ? 'quality' : 'non-quality';
|
||||
const badgeText = item.holdType === 'quality' ? '品質' : '非品質';
|
||||
const reasonText = item.reason || '-';
|
||||
const reasonLink = item.reason
|
||||
? `<a href="/hold-detail?reason=${encodeURIComponent(item.reason)}" class="hold-reason-link">${reasonText}</a>`
|
||||
: reasonText;
|
||||
html += '<tr>';
|
||||
html += `<td><span class="hold-type-badge ${badgeClass}">${badgeText}</span>${item.reason || '-'}</td>`;
|
||||
html += `<td><span class="hold-type-badge ${badgeClass}">${badgeText}</span>${reasonLink}</td>`;
|
||||
html += `<td>${formatNumber(item.lots)}</td>`;
|
||||
html += `<td>${formatNumber(item.qty)}</td>`;
|
||||
html += '</tr>';
|
||||
|
||||
317
tests/test_hold_routes.py
Normal file
317
tests/test_hold_routes.py
Normal file
@@ -0,0 +1,317 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Unit tests for Hold Detail API routes.
|
||||
|
||||
Tests the Hold Detail API endpoints in hold_routes.py.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
import json
|
||||
|
||||
from mes_dashboard.app import create_app
|
||||
import mes_dashboard.core.database as db
|
||||
|
||||
|
||||
class TestHoldRoutesBase(unittest.TestCase):
|
||||
"""Base class for Hold routes tests."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test client."""
|
||||
db._ENGINE = None
|
||||
self.app = create_app('testing')
|
||||
self.app.config['TESTING'] = True
|
||||
self.client = self.app.test_client()
|
||||
|
||||
|
||||
class TestHoldDetailPageRoute(TestHoldRoutesBase):
|
||||
"""Test GET /hold-detail page route."""
|
||||
|
||||
def test_hold_detail_page_requires_reason(self):
|
||||
"""GET /hold-detail without reason should redirect to wip-overview."""
|
||||
response = self.client.get('/hold-detail')
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertIn('/wip-overview', response.location)
|
||||
|
||||
def test_hold_detail_page_with_reason(self):
|
||||
"""GET /hold-detail?reason=xxx should return 200."""
|
||||
response = self.client.get('/hold-detail?reason=YieldLimit')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_hold_detail_page_contains_reason_in_html(self):
|
||||
"""Page should display the hold reason in the HTML."""
|
||||
response = self.client.get('/hold-detail?reason=YieldLimit')
|
||||
self.assertIn(b'YieldLimit', response.data)
|
||||
|
||||
|
||||
class TestHoldDetailSummaryRoute(TestHoldRoutesBase):
|
||||
"""Test GET /api/wip/hold-detail/summary endpoint."""
|
||||
|
||||
@patch('mes_dashboard.routes.hold_routes.get_hold_detail_summary')
|
||||
def test_returns_success_with_data(self, mock_get_summary):
|
||||
"""Should return success=True with summary data."""
|
||||
mock_get_summary.return_value = {
|
||||
'totalLots': 128,
|
||||
'totalQty': 25600,
|
||||
'avgAge': 2.3,
|
||||
'maxAge': 15.0,
|
||||
'workcenterCount': 8
|
||||
}
|
||||
|
||||
response = self.client.get('/api/wip/hold-detail/summary?reason=YieldLimit')
|
||||
data = json.loads(response.data)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(data['success'])
|
||||
self.assertEqual(data['data']['totalLots'], 128)
|
||||
self.assertEqual(data['data']['totalQty'], 25600)
|
||||
self.assertEqual(data['data']['avgAge'], 2.3)
|
||||
self.assertEqual(data['data']['maxAge'], 15.0)
|
||||
self.assertEqual(data['data']['workcenterCount'], 8)
|
||||
|
||||
def test_returns_error_without_reason(self):
|
||||
"""Should return 400 when reason is missing."""
|
||||
response = self.client.get('/api/wip/hold-detail/summary')
|
||||
data = json.loads(response.data)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertFalse(data['success'])
|
||||
self.assertIn('reason', data['error'])
|
||||
|
||||
@patch('mes_dashboard.routes.hold_routes.get_hold_detail_summary')
|
||||
def test_returns_error_on_failure(self, mock_get_summary):
|
||||
"""Should return success=False and 500 on failure."""
|
||||
mock_get_summary.return_value = None
|
||||
|
||||
response = self.client.get('/api/wip/hold-detail/summary?reason=YieldLimit')
|
||||
data = json.loads(response.data)
|
||||
|
||||
self.assertEqual(response.status_code, 500)
|
||||
self.assertFalse(data['success'])
|
||||
self.assertIn('error', data)
|
||||
|
||||
|
||||
class TestHoldDetailDistributionRoute(TestHoldRoutesBase):
|
||||
"""Test GET /api/wip/hold-detail/distribution endpoint."""
|
||||
|
||||
@patch('mes_dashboard.routes.hold_routes.get_hold_detail_distribution')
|
||||
def test_returns_success_with_distribution(self, mock_get_dist):
|
||||
"""Should return success=True with distribution data."""
|
||||
mock_get_dist.return_value = {
|
||||
'byWorkcenter': [
|
||||
{'name': 'DA', 'lots': 45, 'qty': 9000, 'percentage': 35.2},
|
||||
{'name': 'WB', 'lots': 38, 'qty': 7600, 'percentage': 29.7}
|
||||
],
|
||||
'byPackage': [
|
||||
{'name': 'DIP-B', 'lots': 50, 'qty': 10000, 'percentage': 39.1},
|
||||
{'name': 'QFN', 'lots': 35, 'qty': 7000, 'percentage': 27.3}
|
||||
],
|
||||
'byAge': [
|
||||
{'range': '0-1', 'label': '0-1天', 'lots': 45, 'qty': 9000, 'percentage': 35.2},
|
||||
{'range': '1-3', 'label': '1-3天', 'lots': 38, 'qty': 7600, 'percentage': 29.7},
|
||||
{'range': '3-7', 'label': '3-7天', 'lots': 30, 'qty': 6000, 'percentage': 23.4},
|
||||
{'range': '7+', 'label': '7+天', 'lots': 15, 'qty': 3000, 'percentage': 11.7}
|
||||
]
|
||||
}
|
||||
|
||||
response = self.client.get('/api/wip/hold-detail/distribution?reason=YieldLimit')
|
||||
data = json.loads(response.data)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(data['success'])
|
||||
self.assertIn('byWorkcenter', data['data'])
|
||||
self.assertIn('byPackage', data['data'])
|
||||
self.assertIn('byAge', data['data'])
|
||||
self.assertEqual(len(data['data']['byWorkcenter']), 2)
|
||||
self.assertEqual(len(data['data']['byAge']), 4)
|
||||
|
||||
def test_returns_error_without_reason(self):
|
||||
"""Should return 400 when reason is missing."""
|
||||
response = self.client.get('/api/wip/hold-detail/distribution')
|
||||
data = json.loads(response.data)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertFalse(data['success'])
|
||||
|
||||
@patch('mes_dashboard.routes.hold_routes.get_hold_detail_distribution')
|
||||
def test_returns_error_on_failure(self, mock_get_dist):
|
||||
"""Should return success=False and 500 on failure."""
|
||||
mock_get_dist.return_value = None
|
||||
|
||||
response = self.client.get('/api/wip/hold-detail/distribution?reason=YieldLimit')
|
||||
data = json.loads(response.data)
|
||||
|
||||
self.assertEqual(response.status_code, 500)
|
||||
self.assertFalse(data['success'])
|
||||
|
||||
|
||||
class TestHoldDetailLotsRoute(TestHoldRoutesBase):
|
||||
"""Test GET /api/wip/hold-detail/lots endpoint."""
|
||||
|
||||
@patch('mes_dashboard.routes.hold_routes.get_hold_detail_lots')
|
||||
def test_returns_success_with_lots(self, mock_get_lots):
|
||||
"""Should return success=True with lots data."""
|
||||
mock_get_lots.return_value = {
|
||||
'lots': [
|
||||
{
|
||||
'lotId': 'L001',
|
||||
'workorder': 'WO123',
|
||||
'qty': 200,
|
||||
'package': 'DIP-B',
|
||||
'workcenter': 'DA',
|
||||
'spec': 'S01',
|
||||
'age': 2.3,
|
||||
'holdBy': 'EMP01',
|
||||
'dept': 'QC',
|
||||
'holdComment': 'Yield below threshold'
|
||||
}
|
||||
],
|
||||
'pagination': {
|
||||
'page': 1,
|
||||
'perPage': 50,
|
||||
'total': 128,
|
||||
'totalPages': 3
|
||||
},
|
||||
'filters': {
|
||||
'workcenter': None,
|
||||
'package': None,
|
||||
'ageRange': None
|
||||
}
|
||||
}
|
||||
|
||||
response = self.client.get('/api/wip/hold-detail/lots?reason=YieldLimit')
|
||||
data = json.loads(response.data)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(data['success'])
|
||||
self.assertIn('lots', data['data'])
|
||||
self.assertIn('pagination', data['data'])
|
||||
self.assertIn('filters', data['data'])
|
||||
self.assertEqual(len(data['data']['lots']), 1)
|
||||
self.assertEqual(data['data']['pagination']['total'], 128)
|
||||
|
||||
def test_returns_error_without_reason(self):
|
||||
"""Should return 400 when reason is missing."""
|
||||
response = self.client.get('/api/wip/hold-detail/lots')
|
||||
data = json.loads(response.data)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertFalse(data['success'])
|
||||
|
||||
@patch('mes_dashboard.routes.hold_routes.get_hold_detail_lots')
|
||||
def test_passes_filter_parameters(self, mock_get_lots):
|
||||
"""Should pass filter parameters to service function."""
|
||||
mock_get_lots.return_value = {
|
||||
'lots': [],
|
||||
'pagination': {'page': 2, 'perPage': 50, 'total': 0, 'totalPages': 1},
|
||||
'filters': {'workcenter': 'DA', 'package': 'DIP-B', 'ageRange': '1-3'}
|
||||
}
|
||||
|
||||
response = self.client.get(
|
||||
'/api/wip/hold-detail/lots?reason=YieldLimit&workcenter=DA&package=DIP-B&age_range=1-3&page=2'
|
||||
)
|
||||
|
||||
mock_get_lots.assert_called_once_with(
|
||||
reason='YieldLimit',
|
||||
workcenter='DA',
|
||||
package='DIP-B',
|
||||
age_range='1-3',
|
||||
include_dummy=False,
|
||||
page=2,
|
||||
page_size=50
|
||||
)
|
||||
|
||||
def test_validates_age_range_parameter(self):
|
||||
"""Should return 400 for invalid age_range."""
|
||||
response = self.client.get('/api/wip/hold-detail/lots?reason=YieldLimit&age_range=invalid')
|
||||
data = json.loads(response.data)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertFalse(data['success'])
|
||||
self.assertIn('age_range', data['error'])
|
||||
|
||||
@patch('mes_dashboard.routes.hold_routes.get_hold_detail_lots')
|
||||
def test_limits_per_page_to_200(self, mock_get_lots):
|
||||
"""Per page should be capped at 200."""
|
||||
mock_get_lots.return_value = {
|
||||
'lots': [],
|
||||
'pagination': {'page': 1, 'perPage': 200, 'total': 0, 'totalPages': 1},
|
||||
'filters': {'workcenter': None, 'package': None, 'ageRange': None}
|
||||
}
|
||||
|
||||
response = self.client.get('/api/wip/hold-detail/lots?reason=YieldLimit&per_page=500')
|
||||
|
||||
call_args = mock_get_lots.call_args
|
||||
self.assertEqual(call_args.kwargs['page_size'], 200)
|
||||
|
||||
@patch('mes_dashboard.routes.hold_routes.get_hold_detail_lots')
|
||||
def test_handles_page_less_than_one(self, mock_get_lots):
|
||||
"""Page number less than 1 should be set to 1."""
|
||||
mock_get_lots.return_value = {
|
||||
'lots': [],
|
||||
'pagination': {'page': 1, 'perPage': 50, 'total': 0, 'totalPages': 1},
|
||||
'filters': {'workcenter': None, 'package': None, 'ageRange': None}
|
||||
}
|
||||
|
||||
response = self.client.get('/api/wip/hold-detail/lots?reason=YieldLimit&page=0')
|
||||
|
||||
call_args = mock_get_lots.call_args
|
||||
self.assertEqual(call_args.kwargs['page'], 1)
|
||||
|
||||
@patch('mes_dashboard.routes.hold_routes.get_hold_detail_lots')
|
||||
def test_returns_error_on_failure(self, mock_get_lots):
|
||||
"""Should return success=False and 500 on failure."""
|
||||
mock_get_lots.return_value = None
|
||||
|
||||
response = self.client.get('/api/wip/hold-detail/lots?reason=YieldLimit')
|
||||
data = json.loads(response.data)
|
||||
|
||||
self.assertEqual(response.status_code, 500)
|
||||
self.assertFalse(data['success'])
|
||||
|
||||
|
||||
class TestHoldDetailAgeRangeFilters(TestHoldRoutesBase):
|
||||
"""Test age range filter validation."""
|
||||
|
||||
@patch('mes_dashboard.routes.hold_routes.get_hold_detail_lots')
|
||||
def test_valid_age_range_0_1(self, mock_get_lots):
|
||||
"""Should accept 0-1 as valid age_range."""
|
||||
mock_get_lots.return_value = {
|
||||
'lots': [], 'pagination': {'page': 1, 'perPage': 50, 'total': 0, 'totalPages': 1},
|
||||
'filters': {'workcenter': None, 'package': None, 'ageRange': '0-1'}
|
||||
}
|
||||
response = self.client.get('/api/wip/hold-detail/lots?reason=Test&age_range=0-1')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
@patch('mes_dashboard.routes.hold_routes.get_hold_detail_lots')
|
||||
def test_valid_age_range_1_3(self, mock_get_lots):
|
||||
"""Should accept 1-3 as valid age_range."""
|
||||
mock_get_lots.return_value = {
|
||||
'lots': [], 'pagination': {'page': 1, 'perPage': 50, 'total': 0, 'totalPages': 1},
|
||||
'filters': {'workcenter': None, 'package': None, 'ageRange': '1-3'}
|
||||
}
|
||||
response = self.client.get('/api/wip/hold-detail/lots?reason=Test&age_range=1-3')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
@patch('mes_dashboard.routes.hold_routes.get_hold_detail_lots')
|
||||
def test_valid_age_range_3_7(self, mock_get_lots):
|
||||
"""Should accept 3-7 as valid age_range."""
|
||||
mock_get_lots.return_value = {
|
||||
'lots': [], 'pagination': {'page': 1, 'perPage': 50, 'total': 0, 'totalPages': 1},
|
||||
'filters': {'workcenter': None, 'package': None, 'ageRange': '3-7'}
|
||||
}
|
||||
response = self.client.get('/api/wip/hold-detail/lots?reason=Test&age_range=3-7')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
@patch('mes_dashboard.routes.hold_routes.get_hold_detail_lots')
|
||||
def test_valid_age_range_7_plus(self, mock_get_lots):
|
||||
"""Should accept 7+ as valid age_range."""
|
||||
mock_get_lots.return_value = {
|
||||
'lots': [], 'pagination': {'page': 1, 'perPage': 50, 'total': 0, 'totalPages': 1},
|
||||
'filters': {'workcenter': None, 'package': None, 'ageRange': '7+'}
|
||||
}
|
||||
response = self.client.get('/api/wip/hold-detail/lots?reason=Test&age_range=7%2B')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -26,27 +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 = {
|
||||
'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'
|
||||
}
|
||||
@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']['totalLots'], 9073)
|
||||
self.assertEqual(data['data']['byWipStatus']['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):
|
||||
@@ -181,13 +181,14 @@ class TestDetailRoute(TestWipRoutesBase):
|
||||
}
|
||||
|
||||
response = self.client.get(
|
||||
'/api/wip/detail/焊接_DB?package=SOT-23&status=ACTIVE&page=2&page_size=50'
|
||||
'/api/wip/detail/焊接_DB?package=SOT-23&status=RUN&page=2&page_size=50'
|
||||
)
|
||||
|
||||
mock_get_detail.assert_called_once_with(
|
||||
workcenter='焊接_DB',
|
||||
package='SOT-23',
|
||||
status='ACTIVE',
|
||||
status='RUN',
|
||||
hold_type=None,
|
||||
workorder=None,
|
||||
lotid=None,
|
||||
include_dummy=False,
|
||||
|
||||
@@ -75,16 +75,6 @@ class TestBuildBaseConditions(unittest.TestCase):
|
||||
conditions = _build_base_conditions(lotid='12345')
|
||||
self.assertTrue(any("LOTID LIKE '%12345%'" in c for c in conditions))
|
||||
|
||||
def test_multiple_conditions(self):
|
||||
"""Should combine multiple conditions."""
|
||||
conditions = _build_base_conditions(
|
||||
include_dummy=False,
|
||||
workorder='GA26',
|
||||
lotid='A00'
|
||||
)
|
||||
# Should have 3 conditions: DUMMY exclusion, workorder, lotid
|
||||
self.assertEqual(len(conditions), 3)
|
||||
|
||||
def test_escapes_sql_in_workorder(self):
|
||||
"""Should escape SQL special characters in workorder."""
|
||||
conditions = _build_base_conditions(workorder="test'value")
|
||||
@@ -101,31 +91,6 @@ 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_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):
|
||||
"""Should return None when query returns empty DataFrame."""
|
||||
@@ -144,30 +109,6 @@ 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_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):
|
||||
@@ -289,89 +230,6 @@ class TestGetWipHoldSummary(unittest.TestCase):
|
||||
class TestGetWipDetail(unittest.TestCase):
|
||||
"""Test get_wip_detail function."""
|
||||
|
||||
@patch('mes_dashboard.services.wip_service.read_sql_df')
|
||||
def test_returns_detail_structure(self, mock_read_sql):
|
||||
"""Should return dict with detail structure."""
|
||||
# Mock for summary query
|
||||
summary_df = pd.DataFrame({
|
||||
'TOTAL_LOTS': [859],
|
||||
'ON_EQUIPMENT_LOTS': [312],
|
||||
'WAITING_LOTS': [547],
|
||||
'HOLD_LOTS': [15],
|
||||
'SYS_DATE': ['2026-01-26 19:18:29']
|
||||
})
|
||||
# Mock for specs query
|
||||
specs_df = pd.DataFrame({
|
||||
'SPECNAME': ['Spec1', 'Spec2'],
|
||||
'SPECSEQUENCE': [1, 2]
|
||||
})
|
||||
# Mock for lots query
|
||||
lots_df = pd.DataFrame({
|
||||
'LOTID': ['GA25102485-A00-004'],
|
||||
'EQUIPMENTNAME': ['GSMP-0054'],
|
||||
'STATUS': ['ACTIVE'],
|
||||
'HOLDREASONNAME': [None],
|
||||
'QTY': [750],
|
||||
'PRODUCTLINENAME': ['SOT-23'],
|
||||
'SPECNAME': ['Spec1']
|
||||
})
|
||||
|
||||
mock_read_sql.side_effect = [summary_df, specs_df, lots_df]
|
||||
|
||||
result = get_wip_detail('焊接_DB')
|
||||
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result['workcenter'], '焊接_DB')
|
||||
self.assertIn('summary', result)
|
||||
self.assertIn('specs', result)
|
||||
self.assertIn('lots', result)
|
||||
self.assertIn('pagination', result)
|
||||
self.assertIn('sys_date', result)
|
||||
|
||||
@patch('mes_dashboard.services.wip_service.read_sql_df')
|
||||
def test_summary_contains_required_fields(self, mock_read_sql):
|
||||
"""Summary should contain total/on_equipment/waiting/hold lots."""
|
||||
summary_df = pd.DataFrame({
|
||||
'TOTAL_LOTS': [100],
|
||||
'ON_EQUIPMENT_LOTS': [60],
|
||||
'WAITING_LOTS': [40],
|
||||
'HOLD_LOTS': [5],
|
||||
'SYS_DATE': ['2026-01-26']
|
||||
})
|
||||
specs_df = pd.DataFrame({'SPECNAME': [], 'SPECSEQUENCE': []})
|
||||
lots_df = pd.DataFrame()
|
||||
|
||||
mock_read_sql.side_effect = [summary_df, specs_df, lots_df]
|
||||
|
||||
result = get_wip_detail('切割')
|
||||
|
||||
self.assertEqual(result['summary']['total_lots'], 100)
|
||||
self.assertEqual(result['summary']['on_equipment_lots'], 60)
|
||||
self.assertEqual(result['summary']['waiting_lots'], 40)
|
||||
self.assertEqual(result['summary']['hold_lots'], 5)
|
||||
|
||||
@patch('mes_dashboard.services.wip_service.read_sql_df')
|
||||
def test_pagination_calculated_correctly(self, mock_read_sql):
|
||||
"""Pagination should be calculated correctly."""
|
||||
summary_df = pd.DataFrame({
|
||||
'TOTAL_LOTS': [250],
|
||||
'ON_EQUIPMENT_LOTS': [100],
|
||||
'WAITING_LOTS': [150],
|
||||
'HOLD_LOTS': [0],
|
||||
'SYS_DATE': ['2026-01-26']
|
||||
})
|
||||
specs_df = pd.DataFrame({'SPECNAME': [], 'SPECSEQUENCE': []})
|
||||
lots_df = pd.DataFrame()
|
||||
|
||||
mock_read_sql.side_effect = [summary_df, specs_df, lots_df]
|
||||
|
||||
result = get_wip_detail('切割', page=2, page_size=100)
|
||||
|
||||
self.assertEqual(result['pagination']['page'], 2)
|
||||
self.assertEqual(result['pagination']['page_size'], 100)
|
||||
self.assertEqual(result['pagination']['total_count'], 250)
|
||||
self.assertEqual(result['pagination']['total_pages'], 3)
|
||||
|
||||
@patch('mes_dashboard.services.wip_service.read_sql_df')
|
||||
def test_returns_none_on_empty_summary(self, mock_read_sql):
|
||||
"""Should return None when summary query returns empty."""
|
||||
@@ -607,20 +465,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_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
|
||||
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()
|
||||
|
||||
@@ -628,20 +486,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_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
|
||||
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)
|
||||
|
||||
@@ -710,20 +568,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_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
|
||||
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')
|
||||
|
||||
@@ -751,35 +609,6 @@ class TestMultipleFilterConditions(unittest.TestCase):
|
||||
# Should NOT contain DUMMY exclusion since include_dummy=True
|
||||
self.assertNotIn("LOTID NOT LIKE '%DUMMY%'", call_args)
|
||||
|
||||
@patch('mes_dashboard.services.wip_service.read_sql_df')
|
||||
def test_get_wip_detail_with_all_filters(self, mock_read_sql):
|
||||
"""get_wip_detail should combine all filter conditions."""
|
||||
summary_df = pd.DataFrame({
|
||||
'TOTAL_LOTS': [10], 'ON_EQUIPMENT_LOTS': [5],
|
||||
'WAITING_LOTS': [5], 'HOLD_LOTS': [1],
|
||||
'SYS_DATE': ['2026-01-26']
|
||||
})
|
||||
specs_df = pd.DataFrame({'SPECNAME': [], 'SPECSEQUENCE': []})
|
||||
lots_df = pd.DataFrame()
|
||||
|
||||
mock_read_sql.side_effect = [summary_df, specs_df, lots_df]
|
||||
|
||||
get_wip_detail(
|
||||
workcenter='切割',
|
||||
package='SOT-23',
|
||||
status='ACTIVE',
|
||||
workorder='GA26',
|
||||
lotid='A00'
|
||||
)
|
||||
|
||||
# Check the first call (summary query) contains all conditions
|
||||
call_args = mock_read_sql.call_args_list[0][0][0]
|
||||
self.assertIn("WORKCENTER_GROUP = '切割'", call_args)
|
||||
self.assertIn("PRODUCTLINENAME = 'SOT-23'", call_args)
|
||||
self.assertIn("STATUS = 'ACTIVE'", call_args)
|
||||
self.assertIn("WORKORDER LIKE '%GA26%'", call_args)
|
||||
self.assertIn("LOTID LIKE '%A00%'", call_args)
|
||||
self.assertIn("LOTID NOT LIKE '%DUMMY%'", call_args)
|
||||
|
||||
|
||||
import pytest
|
||||
@@ -793,12 +622,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['totalLots'] > 0
|
||||
assert 'dataUpdateDate' 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):
|
||||
@@ -877,8 +706,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['totalLots'] >= result_without_dummy['totalLots']
|
||||
# (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):
|
||||
@@ -889,12 +718,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['totalLots'] <= all_result['totalLots']
|
||||
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