feat: Hold 狀態分類為品質異常與非品質異常
將 WIP Overview 與 Detail 頁面的 HOLD 狀態拆分為兩個獨立卡片: - 品質異常 Hold(紅色):預設分類,未知原因也歸此類 - 非品質異常 Hold(橘色):11 個特定 Hold Reason Backend: - 新增 NON_QUALITY_HOLD_REASONS 常數 Set 與 is_quality_hold() 輔助函數 - API 新增 hold_type 參數(quality/non-quality)支援篩選 - Summary 回應新增 qualityHold/nonQualityHold 統計欄位 - Hold Summary 表格每筆資料新增 holdType 欄位 Frontend: - WIP Overview: 3 欄改為 4 欄,新增兩張 Hold 卡片與類型標籤 - WIP Detail: 4 欄改為 5 欄,新增兩張 Hold 卡片 - 點擊卡片可篩選對應 Hold 類型的資料 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,357 @@
|
||||
# Technical Design
|
||||
|
||||
## Decision: Hold 分類邏輯位置
|
||||
|
||||
**選擇**: 後端 Python 層處理分類
|
||||
|
||||
**原因**:
|
||||
- 單一真相來源:分類邏輯只在後端維護,前端直接使用
|
||||
- 效能:SQL 層直接分類,減少資料傳輸
|
||||
- 維護性:未來新增/修改 Hold Reason 只需改後端
|
||||
|
||||
**位置**: `src/mes_dashboard/services/wip_service.py`
|
||||
|
||||
---
|
||||
|
||||
## Decision: 非品質異常 Hold Reason 定義
|
||||
|
||||
**選擇**: Python Set 常數
|
||||
|
||||
**實作**:
|
||||
```python
|
||||
# wip_service.py 頂部定義
|
||||
NON_QUALITY_HOLD_REASONS = {
|
||||
'IQC檢驗(久存品驗證)(QC)',
|
||||
'大中/安波幅50pcs樣品留樣(PD)',
|
||||
'工程驗證(PE)',
|
||||
'工程驗證(RD)',
|
||||
'指定機台生產',
|
||||
'特殊需求(X-Ray全檢)',
|
||||
'特殊需求管控',
|
||||
'第一次量產QC品質確認(QC)',
|
||||
'需綁尾數(PD)',
|
||||
'樣品需求留存打樣(樣品)',
|
||||
'盤點(收線)需求',
|
||||
}
|
||||
|
||||
def is_quality_hold(reason: str) -> bool:
|
||||
"""判斷是否為品質異常 Hold"""
|
||||
return reason not in NON_QUALITY_HOLD_REASONS
|
||||
```
|
||||
|
||||
**原因**:
|
||||
- Set 查詢 O(1) 效能
|
||||
- 易於維護和擴充
|
||||
- 明確的函數名稱 `is_quality_hold()`
|
||||
|
||||
---
|
||||
|
||||
## API Changes
|
||||
|
||||
### 1. Summary API (`/api/wip/overview/summary`)
|
||||
|
||||
**現有回應**:
|
||||
```json
|
||||
{
|
||||
"byWipStatus": {
|
||||
"hold": { "lots": 150, "qtyPcs": 5000 }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**新增回應欄位**:
|
||||
```json
|
||||
{
|
||||
"byWipStatus": {
|
||||
"hold": { "lots": 150, "qtyPcs": 5000 },
|
||||
"qualityHold": { "lots": 80, "qtyPcs": 3000 },
|
||||
"nonQualityHold": { "lots": 70, "qtyPcs": 2000 }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**SQL 修改** (`get_wip_summary`):
|
||||
```sql
|
||||
-- 新增品質異常統計
|
||||
SUM(CASE WHEN COALESCE(EQUIPMENTCOUNT, 0) = 0
|
||||
AND COALESCE(CURRENTHOLDCOUNT, 0) > 0
|
||||
AND HOLDREASONNAME NOT IN (...non-quality list...)
|
||||
THEN 1 ELSE 0 END) as QUALITY_HOLD_LOTS,
|
||||
-- 新增非品質異常統計
|
||||
SUM(CASE WHEN COALESCE(EQUIPMENTCOUNT, 0) = 0
|
||||
AND COALESCE(CURRENTHOLDCOUNT, 0) > 0
|
||||
AND HOLDREASONNAME IN (...non-quality list...)
|
||||
THEN 1 ELSE 0 END) as NON_QUALITY_HOLD_LOTS,
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Matrix API (`/api/wip/overview/matrix`)
|
||||
|
||||
**新增參數**: `hold_type` (optional)
|
||||
|
||||
| 參數值 | 行為 |
|
||||
|--------|------|
|
||||
| (空) | 顯示所有 Hold(現有行為) |
|
||||
| `quality` | 只顯示品質異常 Hold |
|
||||
| `non-quality` | 只顯示非品質異常 Hold |
|
||||
|
||||
**SQL 修改** (`get_wip_matrix`):
|
||||
```python
|
||||
if status == 'HOLD':
|
||||
if hold_type == 'quality':
|
||||
conditions.append(f"HOLDREASONNAME NOT IN ({non_quality_list})")
|
||||
elif hold_type == 'non-quality':
|
||||
conditions.append(f"HOLDREASONNAME IN ({non_quality_list})")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. Hold Summary API (`/api/wip/overview/hold`)
|
||||
|
||||
**現有回應**:
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{ "reason": "品管異常", "lots": 50, "qty": 2000 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**新增回應欄位**:
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{ "reason": "品管異常", "lots": 50, "qty": 2000, "holdType": "quality" },
|
||||
{ "reason": "工程驗證(PE)", "lots": 30, "qty": 1000, "holdType": "non-quality" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Python 修改** (`get_wip_hold_summary`):
|
||||
```python
|
||||
for _, row in df.iterrows():
|
||||
items.append({
|
||||
'reason': row['REASON'],
|
||||
'lots': int(row['LOTS'] or 0),
|
||||
'qty': int(row['QTY'] or 0),
|
||||
'holdType': 'quality' if is_quality_hold(row['REASON']) else 'non-quality'
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. Detail API (`/api/wip/detail/<workcenter>`)
|
||||
|
||||
**新增參數**: `hold_type` (optional)
|
||||
|
||||
| 參數值 | 行為 |
|
||||
|--------|------|
|
||||
| (空) | 顯示所有 Hold(現有行為) |
|
||||
| `quality` | 只顯示品質異常 Hold lots |
|
||||
| `non-quality` | 只顯示非品質異常 Hold lots |
|
||||
|
||||
**Summary 回應新增**:
|
||||
```json
|
||||
{
|
||||
"summary": {
|
||||
"holdLots": 150,
|
||||
"qualityHoldLots": 80,
|
||||
"nonQualityHoldLots": 70
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Frontend Changes
|
||||
|
||||
### WIP Overview 卡片配置
|
||||
|
||||
**現有**: 3 張卡片 (RUN, QUEUE, HOLD)
|
||||
|
||||
**改為**: 4 張卡片 (RUN, QUEUE, 品質異常 Hold, 非品質異常 Hold)
|
||||
|
||||
**Grid 調整**:
|
||||
```css
|
||||
.wip-status-row {
|
||||
grid-template-columns: repeat(4, 1fr); /* 從 3 改為 4 */
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.wip-status-row {
|
||||
grid-template-columns: repeat(2, 1fr); /* 維持 2 欄 */
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**卡片樣式**:
|
||||
|
||||
| 卡片 | 背景色 | 邊框色 | 文字色 |
|
||||
|------|--------|--------|--------|
|
||||
| RUN | #F0FDF4 | #22C55E | #166534 |
|
||||
| QUEUE | #FFFBEB | #F59E0B | #92400E |
|
||||
| 品質異常 Hold | #FEF2F2 | #EF4444 | #991B1B |
|
||||
| 非品質異常 Hold | #FFF7ED | #F97316 | #9A3412 |
|
||||
|
||||
**卡片 HTML 結構**:
|
||||
```html
|
||||
<div class="wip-status-card quality-hold" onclick="toggleStatusFilter('quality-hold')">
|
||||
<div class="status-header"><span class="dot"></span>品質異常</div>
|
||||
<div class="status-values">
|
||||
<span id="qualityHoldLots">-</span>
|
||||
<span id="qualityHoldQty">-</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wip-status-card non-quality-hold" onclick="toggleStatusFilter('non-quality-hold')">
|
||||
<div class="status-header"><span class="dot"></span>非品質異常</div>
|
||||
<div class="status-values">
|
||||
<span id="nonQualityHoldLots">-</span>
|
||||
<span id="nonQualityHoldQty">-</span>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### WIP Detail 卡片配置
|
||||
|
||||
**現有**: 4 張卡片 (Total, RUN, QUEUE, HOLD)
|
||||
|
||||
**改為**: 5 張卡片 (Total, RUN, QUEUE, 品質異常 Hold, 非品質異常 Hold)
|
||||
|
||||
**Grid 調整**:
|
||||
```css
|
||||
.summary-row {
|
||||
grid-template-columns: repeat(5, 1fr); /* 從 4 改為 5 */
|
||||
}
|
||||
|
||||
@media (max-width: 1400px) {
|
||||
.summary-row {
|
||||
grid-template-columns: repeat(3, 1fr); /* 3 欄 wrap */
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.summary-row {
|
||||
grid-template-columns: 1fr; /* 單欄 */
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Hold Summary 表格改版
|
||||
|
||||
**現有**:
|
||||
| Hold Reason | Lots | QTY |
|
||||
|-------------|------|-----|
|
||||
| 品管異常 | 50 | 2000 |
|
||||
|
||||
**改為**:
|
||||
| Hold Reason | Lots | QTY |
|
||||
|-------------|------|-----|
|
||||
| [品質] 品管異常 | 50 | 2000 |
|
||||
| [非品質] 工程驗證(PE) | 30 | 1000 |
|
||||
|
||||
**樣式**:
|
||||
```css
|
||||
.hold-type-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.hold-type-badge.quality {
|
||||
background: #FEE2E2;
|
||||
color: #991B1B;
|
||||
}
|
||||
|
||||
.hold-type-badge.non-quality {
|
||||
background: #FFEDD5;
|
||||
color: #9A3412;
|
||||
}
|
||||
```
|
||||
|
||||
**Render 邏輯**:
|
||||
```javascript
|
||||
function renderHold(data) {
|
||||
data.items.forEach(item => {
|
||||
const badgeClass = item.holdType === 'quality' ? 'quality' : 'non-quality';
|
||||
const badgeText = item.holdType === 'quality' ? '品質' : '非品質';
|
||||
html += `<td><span class="hold-type-badge ${badgeClass}">${badgeText}</span>${item.reason}</td>`;
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 狀態篩選邏輯
|
||||
|
||||
**現有狀態** (`activeStatusFilter`):
|
||||
```javascript
|
||||
// 可能值: null | 'run' | 'queue' | 'hold'
|
||||
```
|
||||
|
||||
**改為**:
|
||||
```javascript
|
||||
// 可能值: null | 'run' | 'queue' | 'quality-hold' | 'non-quality-hold'
|
||||
```
|
||||
|
||||
**toggleStatusFilter 修改**:
|
||||
```javascript
|
||||
function toggleStatusFilter(status) {
|
||||
if (activeStatusFilter === status) {
|
||||
activeStatusFilter = null;
|
||||
} else {
|
||||
activeStatusFilter = status;
|
||||
}
|
||||
updateCardStyles();
|
||||
updateMatrixTitle();
|
||||
loadMatrixOnly();
|
||||
}
|
||||
```
|
||||
|
||||
**fetchMatrix 修改**:
|
||||
```javascript
|
||||
async function fetchMatrix(signal = null) {
|
||||
const params = buildQueryParams();
|
||||
|
||||
if (activeStatusFilter) {
|
||||
if (activeStatusFilter === 'quality-hold') {
|
||||
params.status = 'HOLD';
|
||||
params.hold_type = 'quality';
|
||||
} else if (activeStatusFilter === 'non-quality-hold') {
|
||||
params.status = 'HOLD';
|
||||
params.hold_type = 'non-quality';
|
||||
} else {
|
||||
params.status = activeStatusFilter.toUpperCase();
|
||||
}
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 向後相容性
|
||||
|
||||
| 項目 | 相容性 |
|
||||
|------|--------|
|
||||
| API 回應格式 | ✓ 新增欄位,現有欄位不變 |
|
||||
| API 參數 | ✓ 新增 `hold_type`,現有參數不變 |
|
||||
| URL 結構 | ✓ 不變 |
|
||||
| CSS class | ✓ 新增 class,現有不變 |
|
||||
|
||||
---
|
||||
|
||||
## 測試要點
|
||||
|
||||
1. **分類正確性**: 驗證 11 個非品質異常 Reason 正確分類
|
||||
2. **統計一致性**: `qualityHold + nonQualityHold = hold`
|
||||
3. **篩選功能**: 點擊卡片正確篩選 Matrix/Table
|
||||
4. **UI 響應式**: 4/5 張卡片在不同螢幕寬度正確排列
|
||||
5. **Hold Summary**: 標籤顯示正確
|
||||
@@ -0,0 +1,80 @@
|
||||
## Why
|
||||
|
||||
目前 WIP Overview 和 WIP Detail 中的 HOLD 卡片將所有 Hold 原因混在一起顯示。但實務上,Hold 原因可分為兩類:
|
||||
|
||||
1. **品質異常 Hold**:需要品質工程師介入處理的問題(如:缺陷、不良率異常等)
|
||||
2. **非品質異常 Hold**:流程性 Hold,不需品質介入(如:IQC 驗證、工程驗證、樣品留存等)
|
||||
|
||||
這兩類 Hold 的處理流程和優先級不同,混在一起會造成:
|
||||
- 品質問題被淹沒在大量流程性 Hold 中
|
||||
- 難以快速識別真正需要處理的品質異常
|
||||
- Hold Summary 無法區分異常類型,無法有效分析
|
||||
|
||||
## What Changes
|
||||
|
||||
### WIP Overview 改動
|
||||
|
||||
1. **卡片拆分**:原本 1 張 HOLD 卡片 → 2 張獨立卡片
|
||||
- 「品質異常 Hold」卡片(紅色)
|
||||
- 「非品質異常 Hold」卡片(橙色)
|
||||
|
||||
2. **Hold Summary 標示**:在每個 Hold Reason 前面加入類型標籤
|
||||
- 品質異常:`[品質] Hold Reason Name`
|
||||
- 非品質異常:`[非品質] Hold Reason Name`
|
||||
|
||||
3. **篩選功能**:點擊拆分後的卡片可分別篩選
|
||||
- 點擊「品質異常 Hold」→ Matrix 只顯示品質異常 Hold
|
||||
- 點擊「非品質異常 Hold」→ Matrix 只顯示非品質異常 Hold
|
||||
|
||||
### WIP Detail 改動
|
||||
|
||||
1. **卡片拆分**:原本 1 張 HOLD 卡片 → 2 張獨立卡片
|
||||
- 「品質異常 Hold」卡片(紅色)
|
||||
- 「非品質異常 Hold」卡片(橙色)
|
||||
|
||||
2. **篩選功能**:點擊拆分後的卡片可分別篩選
|
||||
- 點擊「品質異常 Hold」→ Table 只顯示品質異常 Hold lots
|
||||
- 點擊「非品質異常 Hold」→ Table 只顯示非品質異常 Hold lots
|
||||
|
||||
### 後端 API 改動
|
||||
|
||||
1. **Summary API**:新增 `qualityHold` 和 `nonQualityHold` 分類統計
|
||||
2. **Matrix API**:支援 `holdType` 參數(`quality` / `non-quality`)
|
||||
3. **Hold Summary API**:新增 `holdType` 欄位標示每個 reason 的分類
|
||||
4. **Detail API**:支援 `holdType` 參數篩選
|
||||
|
||||
### 非品質異常 Hold Reason 清單
|
||||
|
||||
以下 Hold Reason 歸類為「非品質異常」,其餘皆為「品質異常」:
|
||||
|
||||
- IQC檢驗(久存品驗證)(QC)
|
||||
- 大中/安波幅50pcs樣品留樣(PD)
|
||||
- 工程驗證(PE)
|
||||
- 工程驗證(RD)
|
||||
- 指定機台生產
|
||||
- 特殊需求(X-Ray全檢)
|
||||
- 特殊需求管控
|
||||
- 第一次量產QC品質確認(QC)
|
||||
- 需綁尾數(PD)
|
||||
- 樣品需求留存打樣(樣品)
|
||||
- 盤點(收線)需求
|
||||
|
||||
## Capabilities
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `wip-overview`: 拆分 HOLD 卡片為品質/非品質異常,Hold Summary 加入類型標示
|
||||
- `wip-detail`: 拆分 HOLD 卡片為品質/非品質異常,支援分別篩選
|
||||
- `wip-service`: API 新增 Hold 類型分類邏輯和篩選參數
|
||||
|
||||
## Impact
|
||||
|
||||
- **修改檔案**:
|
||||
- `src/mes_dashboard/templates/wip_overview.html` - 卡片拆分、Hold Summary 改版
|
||||
- `src/mes_dashboard/templates/wip_detail.html` - 卡片拆分
|
||||
- `src/mes_dashboard/services/wip_service.py` - 新增 Hold 分類邏輯
|
||||
- `src/mes_dashboard/routes/wip_routes.py` - 新增 API 參數
|
||||
|
||||
- **無新增檔案**:所有改動皆在現有檔案中進行
|
||||
|
||||
- **向後相容**:現有 API 參數保持不變,新增參數為可選
|
||||
@@ -0,0 +1,109 @@
|
||||
# Implementation Tasks
|
||||
|
||||
## Phase 1: Backend - Hold 分類邏輯
|
||||
|
||||
### wip_service.py
|
||||
|
||||
- [x] 新增 `NON_QUALITY_HOLD_REASONS` 常數 Set(11 個非品質異常 Reason)
|
||||
- [x] 新增 `is_quality_hold(reason: str)` 輔助函數
|
||||
- [x] 新增 `_build_hold_type_sql_list()` 函數產生 SQL IN clause
|
||||
|
||||
### get_wip_summary() 修改
|
||||
|
||||
- [x] SQL 新增 `QUALITY_HOLD_LOTS` 和 `QUALITY_HOLD_QTY_PCS` 統計
|
||||
- [x] SQL 新增 `NON_QUALITY_HOLD_LOTS` 和 `NON_QUALITY_HOLD_QTY_PCS` 統計
|
||||
- [x] 回應新增 `qualityHold` 和 `nonQualityHold` 欄位
|
||||
|
||||
### get_wip_matrix() 修改
|
||||
|
||||
- [x] 新增 `hold_type` 參數(Optional[str])
|
||||
- [x] 當 `status='HOLD'` 且 `hold_type='quality'` 時,加入 `HOLDREASONNAME NOT IN (...)` 條件
|
||||
- [x] 當 `status='HOLD'` 且 `hold_type='non-quality'` 時,加入 `HOLDREASONNAME IN (...)` 條件
|
||||
|
||||
### get_wip_hold_summary() 修改
|
||||
|
||||
- [x] 回應每個 item 新增 `holdType` 欄位(`'quality'` 或 `'non-quality'`)
|
||||
|
||||
### get_wip_detail() 修改
|
||||
|
||||
- [x] 新增 `hold_type` 參數(Optional[str])
|
||||
- [x] Summary 統計新增 `qualityHoldLots` 和 `nonQualityHoldLots`
|
||||
- [x] 當 `status='HOLD'` 且有 `hold_type` 時,過濾對應 Hold 類型
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Backend - API Routes
|
||||
|
||||
### wip_routes.py
|
||||
|
||||
- [x] `/api/wip/overview/matrix` 新增 `hold_type` query parameter
|
||||
- [x] `/api/wip/detail/<workcenter>` 新增 `hold_type` query parameter
|
||||
- [x] 將 `hold_type` 傳遞給 service 函數
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Frontend - WIP Overview
|
||||
|
||||
### CSS 樣式 (wip_overview.html)
|
||||
|
||||
- [x] `.wip-status-row` grid 改為 4 欄 (`repeat(4, 1fr)`)
|
||||
- [x] 新增 `.wip-status-card.quality-hold` 樣式(紅色系)
|
||||
- [x] 新增 `.wip-status-card.non-quality-hold` 樣式(橘色系)
|
||||
- [x] 新增 `.hold-type-badge` 樣式(品質/非品質標籤)
|
||||
|
||||
### HTML 卡片 (wip_overview.html)
|
||||
|
||||
- [x] 移除原本的 `.wip-status-card.hold`
|
||||
- [x] 新增「品質異常」卡片 (`quality-hold`),綁定 `toggleStatusFilter('quality-hold')`
|
||||
- [x] 新增「非品質異常」卡片 (`non-quality-hold`),綁定 `toggleStatusFilter('non-quality-hold')`
|
||||
|
||||
### JavaScript 狀態管理 (wip_overview.html)
|
||||
|
||||
- [x] `renderSummary()` 更新:讀取 `qualityHold` 和 `nonQualityHold` 並顯示
|
||||
- [x] `toggleStatusFilter()` 更新:支援 `'quality-hold'` 和 `'non-quality-hold'`
|
||||
- [x] `updateCardStyles()` 更新:處理新的卡片 class
|
||||
- [x] `updateMatrixTitle()` 更新:顯示「品質異常 Hold Only」或「非品質異常 Hold Only」
|
||||
- [x] `fetchMatrix()` 更新:當 filter 為 hold 類型時,傳送 `status=HOLD` + `hold_type`
|
||||
|
||||
### Hold Summary 表格 (wip_overview.html)
|
||||
|
||||
- [x] `renderHold()` 更新:在 Reason 前面加入類型標籤 badge
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Frontend - WIP Detail
|
||||
|
||||
### CSS 樣式 (wip_detail.html)
|
||||
|
||||
- [x] `.summary-row` grid 改為 5 欄 (`repeat(5, 1fr)`)
|
||||
- [x] 新增 `.summary-card.status-quality-hold` 樣式(紅色系)
|
||||
- [x] 新增 `.summary-card.status-non-quality-hold` 樣式(橘色系)
|
||||
- [x] 更新 responsive breakpoints(1400px → 3 欄,768px → 1 欄)
|
||||
|
||||
### HTML 卡片 (wip_detail.html)
|
||||
|
||||
- [x] 移除原本的 `.summary-card.status-hold`
|
||||
- [x] 新增「品質異常」卡片,綁定 `toggleStatusFilter('quality-hold')`
|
||||
- [x] 新增「非品質異常」卡片,綁定 `toggleStatusFilter('non-quality-hold')`
|
||||
|
||||
### JavaScript 狀態管理 (wip_detail.html)
|
||||
|
||||
- [x] `renderSummary()` 更新:讀取並顯示 `qualityHoldLots` 和 `nonQualityHoldLots`
|
||||
- [x] `toggleStatusFilter()` 更新:支援 `'quality-hold'` 和 `'non-quality-hold'`
|
||||
- [x] `updateCardStyles()` 更新:處理新的卡片 class
|
||||
- [x] `updateTableTitle()` 更新:顯示「品質異常 Hold Only」或「非品質異常 Hold Only」
|
||||
- [x] `fetchDetail()` 更新:當 filter 為 hold 類型時,傳送 `status=HOLD` + `hold_type`
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: 驗證
|
||||
|
||||
- [x] 手動測試:WIP Overview 4 張卡片顯示正確數據
|
||||
- [x] 手動測試:WIP Overview 點擊品質異常卡片,Matrix 正確篩選
|
||||
- [x] 手動測試:WIP Overview 點擊非品質異常卡片,Matrix 正確篩選
|
||||
- [x] 手動測試:WIP Overview Hold Summary 顯示類型標籤
|
||||
- [x] 手動測試:WIP Detail 5 張卡片顯示正確數據
|
||||
- [x] 手動測試:WIP Detail 點擊品質異常卡片,Table 正確篩選
|
||||
- [x] 手動測試:WIP Detail 點擊非品質異常卡片,Table 正確篩選
|
||||
- [x] 驗證:`qualityHold + nonQualityHold = hold` 統計一致性
|
||||
- [x] 響應式測試:不同螢幕寬度下卡片正確排列
|
||||
@@ -66,6 +66,8 @@ def api_overview_matrix():
|
||||
lotid: Optional LOTID filter (fuzzy match)
|
||||
include_dummy: Include DUMMY lots (default: false)
|
||||
status: Optional WIP status filter ('RUN', 'QUEUE', 'HOLD')
|
||||
hold_type: Optional hold type filter ('quality', 'non-quality')
|
||||
Only effective when status='HOLD'
|
||||
|
||||
Returns:
|
||||
JSON with workcenters, packages, matrix, workcenter_totals,
|
||||
@@ -75,6 +77,7 @@ def api_overview_matrix():
|
||||
lotid = request.args.get('lotid', '').strip() or None
|
||||
include_dummy = _parse_bool(request.args.get('include_dummy', ''))
|
||||
status = request.args.get('status', '').strip().upper() or None
|
||||
hold_type = request.args.get('hold_type', '').strip().lower() or None
|
||||
|
||||
# Validate status parameter
|
||||
if status and status not in ('RUN', 'QUEUE', 'HOLD'):
|
||||
@@ -83,11 +86,19 @@ def api_overview_matrix():
|
||||
'error': 'Invalid status. Use RUN, QUEUE, or HOLD'
|
||||
}), 400
|
||||
|
||||
# Validate hold_type parameter
|
||||
if hold_type and hold_type not in ('quality', 'non-quality'):
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Invalid hold_type. Use quality or non-quality'
|
||||
}), 400
|
||||
|
||||
result = get_wip_matrix(
|
||||
include_dummy=include_dummy,
|
||||
workorder=workorder,
|
||||
lotid=lotid,
|
||||
status=status
|
||||
status=status,
|
||||
hold_type=hold_type
|
||||
)
|
||||
if result is not None:
|
||||
return jsonify({'success': True, 'data': result})
|
||||
@@ -134,6 +145,8 @@ def api_detail(workcenter: str):
|
||||
Query Parameters:
|
||||
package: Optional PRODUCTLINENAME filter
|
||||
status: Optional WIP status filter ('RUN', 'QUEUE', 'HOLD')
|
||||
hold_type: Optional hold type filter ('quality', 'non-quality')
|
||||
Only effective when status='HOLD'
|
||||
workorder: Optional WORKORDER filter (fuzzy match)
|
||||
lotid: Optional LOTID filter (fuzzy match)
|
||||
include_dummy: Include DUMMY lots (default: false)
|
||||
@@ -145,6 +158,7 @@ def api_detail(workcenter: str):
|
||||
"""
|
||||
package = request.args.get('package', '').strip() or None
|
||||
status = request.args.get('status', '').strip().upper() or None
|
||||
hold_type = request.args.get('hold_type', '').strip().lower() or None
|
||||
workorder = request.args.get('workorder', '').strip() or None
|
||||
lotid = request.args.get('lotid', '').strip() or None
|
||||
include_dummy = _parse_bool(request.args.get('include_dummy', ''))
|
||||
@@ -161,10 +175,18 @@ def api_detail(workcenter: str):
|
||||
'error': 'Invalid status. Use RUN, QUEUE, or HOLD'
|
||||
}), 400
|
||||
|
||||
# Validate hold_type parameter
|
||||
if hold_type and hold_type not in ('quality', 'non-quality'):
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Invalid hold_type. Use quality or non-quality'
|
||||
}), 400
|
||||
|
||||
result = get_wip_detail(
|
||||
workcenter=workcenter,
|
||||
package=package,
|
||||
status=status,
|
||||
hold_type=hold_type,
|
||||
workorder=workorder,
|
||||
lotid=lotid,
|
||||
include_dummy=include_dummy,
|
||||
|
||||
@@ -58,6 +58,49 @@ def _build_base_conditions(
|
||||
return conditions
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Hold Type Classification
|
||||
# ============================================================
|
||||
# Non-quality hold reasons (all other reasons are quality holds)
|
||||
NON_QUALITY_HOLD_REASONS = {
|
||||
'IQC檢驗(久存品驗證)(QC)',
|
||||
'大中/安波幅50pcs樣品留樣(PD)',
|
||||
'工程驗證(PE)',
|
||||
'工程驗證(RD)',
|
||||
'指定機台生產',
|
||||
'特殊需求(X-Ray全檢)',
|
||||
'特殊需求管控',
|
||||
'第一次量產QC品質確認(QC)',
|
||||
'需綁尾數(PD)',
|
||||
'樣品需求留存打樣(樣品)',
|
||||
'盤點(收線)需求',
|
||||
}
|
||||
|
||||
|
||||
def is_quality_hold(reason: str) -> bool:
|
||||
"""Check if a hold reason is quality-related.
|
||||
|
||||
Args:
|
||||
reason: The HOLDREASONNAME value
|
||||
|
||||
Returns:
|
||||
True if this is a quality hold, False if non-quality hold
|
||||
"""
|
||||
if reason is None:
|
||||
return True # Default to quality if reason is unknown
|
||||
return reason not in NON_QUALITY_HOLD_REASONS
|
||||
|
||||
|
||||
def _build_hold_type_sql_list() -> str:
|
||||
"""Build SQL IN clause list for non-quality hold reasons.
|
||||
|
||||
Returns:
|
||||
Comma-separated string of escaped reason names for SQL IN clause
|
||||
"""
|
||||
escaped = [f"'{_escape_sql(r)}'" for r in NON_QUALITY_HOLD_REASONS]
|
||||
return ', '.join(escaped)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Data Source Configuration
|
||||
# ============================================================
|
||||
@@ -91,6 +134,7 @@ def get_wip_summary(
|
||||
try:
|
||||
conditions = _build_base_conditions(include_dummy, workorder, lotid)
|
||||
where_clause = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
||||
non_quality_list = _build_hold_type_sql_list()
|
||||
|
||||
sql = f"""
|
||||
SELECT
|
||||
@@ -102,6 +146,22 @@ def get_wip_summary(
|
||||
AND COALESCE(CURRENTHOLDCOUNT, 0) > 0 THEN 1 ELSE 0 END) as HOLD_LOTS,
|
||||
SUM(CASE WHEN COALESCE(EQUIPMENTCOUNT, 0) = 0
|
||||
AND COALESCE(CURRENTHOLDCOUNT, 0) > 0 THEN QTY ELSE 0 END) as HOLD_QTY_PCS,
|
||||
SUM(CASE WHEN COALESCE(EQUIPMENTCOUNT, 0) = 0
|
||||
AND COALESCE(CURRENTHOLDCOUNT, 0) > 0
|
||||
AND (HOLDREASONNAME IS NULL OR HOLDREASONNAME NOT IN ({non_quality_list}))
|
||||
THEN 1 ELSE 0 END) as QUALITY_HOLD_LOTS,
|
||||
SUM(CASE WHEN COALESCE(EQUIPMENTCOUNT, 0) = 0
|
||||
AND COALESCE(CURRENTHOLDCOUNT, 0) > 0
|
||||
AND (HOLDREASONNAME IS NULL OR HOLDREASONNAME NOT IN ({non_quality_list}))
|
||||
THEN QTY ELSE 0 END) as QUALITY_HOLD_QTY_PCS,
|
||||
SUM(CASE WHEN COALESCE(EQUIPMENTCOUNT, 0) = 0
|
||||
AND COALESCE(CURRENTHOLDCOUNT, 0) > 0
|
||||
AND HOLDREASONNAME IN ({non_quality_list})
|
||||
THEN 1 ELSE 0 END) as NON_QUALITY_HOLD_LOTS,
|
||||
SUM(CASE WHEN COALESCE(EQUIPMENTCOUNT, 0) = 0
|
||||
AND COALESCE(CURRENTHOLDCOUNT, 0) > 0
|
||||
AND HOLDREASONNAME IN ({non_quality_list})
|
||||
THEN QTY ELSE 0 END) as NON_QUALITY_HOLD_QTY_PCS,
|
||||
SUM(CASE WHEN COALESCE(EQUIPMENTCOUNT, 0) = 0
|
||||
AND COALESCE(CURRENTHOLDCOUNT, 0) = 0 THEN 1 ELSE 0 END) as QUEUE_LOTS,
|
||||
SUM(CASE WHEN COALESCE(EQUIPMENTCOUNT, 0) = 0
|
||||
@@ -131,6 +191,14 @@ def get_wip_summary(
|
||||
'hold': {
|
||||
'lots': int(row['HOLD_LOTS'] or 0),
|
||||
'qtyPcs': int(row['HOLD_QTY_PCS'] or 0)
|
||||
},
|
||||
'qualityHold': {
|
||||
'lots': int(row['QUALITY_HOLD_LOTS'] or 0),
|
||||
'qtyPcs': int(row['QUALITY_HOLD_QTY_PCS'] or 0)
|
||||
},
|
||||
'nonQualityHold': {
|
||||
'lots': int(row['NON_QUALITY_HOLD_LOTS'] or 0),
|
||||
'qtyPcs': int(row['NON_QUALITY_HOLD_QTY_PCS'] or 0)
|
||||
}
|
||||
},
|
||||
'dataUpdateDate': str(row['DATA_UPDATE_DATE']) if row['DATA_UPDATE_DATE'] else None
|
||||
@@ -144,7 +212,8 @@ def get_wip_matrix(
|
||||
include_dummy: bool = False,
|
||||
workorder: Optional[str] = None,
|
||||
lotid: Optional[str] = None,
|
||||
status: Optional[str] = None
|
||||
status: Optional[str] = None,
|
||||
hold_type: Optional[str] = None
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Get workcenter x product line matrix for overview dashboard.
|
||||
|
||||
@@ -153,6 +222,8 @@ def get_wip_matrix(
|
||||
workorder: Optional WORKORDER filter (fuzzy match)
|
||||
lotid: Optional LOTID filter (fuzzy match)
|
||||
status: Optional WIP status filter ('RUN', 'QUEUE', 'HOLD')
|
||||
hold_type: Optional hold type filter ('quality', 'non-quality')
|
||||
Only effective when status='HOLD'
|
||||
|
||||
Returns:
|
||||
Dict with matrix data:
|
||||
@@ -175,6 +246,15 @@ def get_wip_matrix(
|
||||
conditions.append("EQUIPMENTCOUNT > 0")
|
||||
elif status_upper == 'HOLD':
|
||||
conditions.append("EQUIPMENTCOUNT = 0 AND CURRENTHOLDCOUNT > 0")
|
||||
# Hold type sub-filter
|
||||
if hold_type:
|
||||
non_quality_list = _build_hold_type_sql_list()
|
||||
if hold_type == 'quality':
|
||||
conditions.append(
|
||||
f"(HOLDREASONNAME IS NULL OR HOLDREASONNAME NOT IN ({non_quality_list}))"
|
||||
)
|
||||
elif hold_type == 'non-quality':
|
||||
conditions.append(f"HOLDREASONNAME IN ({non_quality_list})")
|
||||
elif status_upper == 'QUEUE':
|
||||
conditions.append("EQUIPMENTCOUNT = 0 AND CURRENTHOLDCOUNT = 0")
|
||||
where_clause = f"WHERE {' AND '.join(conditions)}"
|
||||
@@ -284,10 +364,12 @@ def get_wip_hold_summary(
|
||||
|
||||
items = []
|
||||
for _, row in df.iterrows():
|
||||
reason = row['REASON']
|
||||
items.append({
|
||||
'reason': row['REASON'],
|
||||
'reason': reason,
|
||||
'lots': int(row['LOTS'] or 0),
|
||||
'qty': int(row['QTY'] or 0)
|
||||
'qty': int(row['QTY'] or 0),
|
||||
'holdType': 'quality' if is_quality_hold(reason) else 'non-quality'
|
||||
})
|
||||
|
||||
return {'items': items}
|
||||
@@ -304,6 +386,7 @@ def get_wip_detail(
|
||||
workcenter: str,
|
||||
package: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
hold_type: Optional[str] = None,
|
||||
workorder: Optional[str] = None,
|
||||
lotid: Optional[str] = None,
|
||||
include_dummy: bool = False,
|
||||
@@ -316,6 +399,8 @@ def get_wip_detail(
|
||||
workcenter: WORKCENTER_GROUP name
|
||||
package: Optional PRODUCTLINENAME filter
|
||||
status: Optional WIP status filter ('RUN', 'QUEUE', 'HOLD')
|
||||
hold_type: Optional hold type filter ('quality', 'non-quality')
|
||||
Only effective when status='HOLD'
|
||||
workorder: Optional WORKORDER filter (fuzzy match)
|
||||
lotid: Optional LOTID filter (fuzzy match)
|
||||
include_dummy: If True, include DUMMY lots (default: False)
|
||||
@@ -325,7 +410,7 @@ def get_wip_detail(
|
||||
Returns:
|
||||
Dict with:
|
||||
- workcenter: The workcenter group name
|
||||
- summary: {totalLots, runLots, queueLots, holdLots}
|
||||
- summary: {totalLots, runLots, queueLots, holdLots, qualityHoldLots, nonQualityHoldLots}
|
||||
- specs: List of spec names (sorted by SPECSEQUENCE)
|
||||
- lots: List of lot details
|
||||
- pagination: {page, page_size, total_count, total_pages}
|
||||
@@ -346,12 +431,29 @@ def get_wip_detail(
|
||||
conditions.append("COALESCE(EQUIPMENTCOUNT, 0) > 0")
|
||||
elif status_upper == 'HOLD':
|
||||
conditions.append("COALESCE(EQUIPMENTCOUNT, 0) = 0 AND COALESCE(CURRENTHOLDCOUNT, 0) > 0")
|
||||
# Hold type sub-filter
|
||||
if hold_type:
|
||||
non_quality_list = _build_hold_type_sql_list()
|
||||
if hold_type == 'quality':
|
||||
conditions.append(
|
||||
f"(HOLDREASONNAME IS NULL OR HOLDREASONNAME NOT IN ({non_quality_list}))"
|
||||
)
|
||||
elif hold_type == 'non-quality':
|
||||
conditions.append(f"HOLDREASONNAME IN ({non_quality_list})")
|
||||
elif status_upper == 'QUEUE':
|
||||
conditions.append("COALESCE(EQUIPMENTCOUNT, 0) = 0 AND COALESCE(CURRENTHOLDCOUNT, 0) = 0")
|
||||
|
||||
where_clause = f"WHERE {' AND '.join(conditions)}"
|
||||
|
||||
# Get summary with RUN/QUEUE/HOLD classification (IT standard)
|
||||
# Note: summary always uses base_conditions (without hold_type filter) to show full breakdown
|
||||
summary_conditions = _build_base_conditions(include_dummy, workorder, lotid)
|
||||
summary_conditions.append(f"WORKCENTER_GROUP = '{_escape_sql(workcenter)}'")
|
||||
if package:
|
||||
summary_conditions.append(f"PRODUCTLINENAME = '{_escape_sql(package)}'")
|
||||
summary_where = f"WHERE {' AND '.join(summary_conditions)}"
|
||||
non_quality_list = _build_hold_type_sql_list()
|
||||
|
||||
summary_sql = f"""
|
||||
SELECT
|
||||
COUNT(*) as TOTAL_LOTS,
|
||||
@@ -360,9 +462,17 @@ def get_wip_detail(
|
||||
AND COALESCE(CURRENTHOLDCOUNT, 0) = 0 THEN 1 ELSE 0 END) as QUEUE_LOTS,
|
||||
SUM(CASE WHEN COALESCE(EQUIPMENTCOUNT, 0) = 0
|
||||
AND COALESCE(CURRENTHOLDCOUNT, 0) > 0 THEN 1 ELSE 0 END) as HOLD_LOTS,
|
||||
SUM(CASE WHEN COALESCE(EQUIPMENTCOUNT, 0) = 0
|
||||
AND COALESCE(CURRENTHOLDCOUNT, 0) > 0
|
||||
AND (HOLDREASONNAME IS NULL OR HOLDREASONNAME NOT IN ({non_quality_list}))
|
||||
THEN 1 ELSE 0 END) as QUALITY_HOLD_LOTS,
|
||||
SUM(CASE WHEN COALESCE(EQUIPMENTCOUNT, 0) = 0
|
||||
AND COALESCE(CURRENTHOLDCOUNT, 0) > 0
|
||||
AND HOLDREASONNAME IN ({non_quality_list})
|
||||
THEN 1 ELSE 0 END) as NON_QUALITY_HOLD_LOTS,
|
||||
MAX(SYS_DATE) as SYS_DATE
|
||||
FROM {WIP_VIEW}
|
||||
{where_clause}
|
||||
{summary_where}
|
||||
"""
|
||||
|
||||
summary_df = read_sql_df(summary_sql)
|
||||
@@ -378,7 +488,9 @@ def get_wip_detail(
|
||||
'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)
|
||||
'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)
|
||||
}
|
||||
|
||||
# Get unique specs for this workcenter (sorted by SPECSEQUENCE)
|
||||
|
||||
@@ -284,7 +284,7 @@
|
||||
/* Summary Cards */
|
||||
.summary-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
@@ -330,14 +330,16 @@
|
||||
/* Status Card Colors - Clickable */
|
||||
.summary-card.status-run,
|
||||
.summary-card.status-queue,
|
||||
.summary-card.status-hold {
|
||||
.summary-card.status-quality-hold,
|
||||
.summary-card.status-non-quality-hold {
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.summary-card.status-run:hover,
|
||||
.summary-card.status-queue:hover,
|
||||
.summary-card.status-hold:hover {
|
||||
.summary-card.status-quality-hold:hover,
|
||||
.summary-card.status-non-quality-hold:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
@@ -375,27 +377,45 @@
|
||||
box-shadow: 0 6px 25px rgba(245, 158, 11, 0.5);
|
||||
}
|
||||
|
||||
.summary-card.status-hold {
|
||||
.summary-card.status-quality-hold {
|
||||
background: #FEF2F2;
|
||||
border-color: #EF4444;
|
||||
}
|
||||
.summary-card.status-hold .summary-value {
|
||||
.summary-card.status-quality-hold .summary-value {
|
||||
color: #991B1B;
|
||||
}
|
||||
.summary-card.status-hold:hover {
|
||||
.summary-card.status-quality-hold:hover {
|
||||
box-shadow: 0 4px 15px rgba(239, 68, 68, 0.25);
|
||||
}
|
||||
.summary-card.status-hold.active {
|
||||
.summary-card.status-quality-hold.active {
|
||||
background: #FEE2E2;
|
||||
border-width: 3px;
|
||||
transform: scale(1.03);
|
||||
box-shadow: 0 6px 25px rgba(239, 68, 68, 0.5);
|
||||
}
|
||||
|
||||
.summary-card.status-non-quality-hold {
|
||||
background: #FFF7ED;
|
||||
border-color: #F97316;
|
||||
}
|
||||
.summary-card.status-non-quality-hold .summary-value {
|
||||
color: #9A3412;
|
||||
}
|
||||
.summary-card.status-non-quality-hold:hover {
|
||||
box-shadow: 0 4px 15px rgba(249, 115, 22, 0.25);
|
||||
}
|
||||
.summary-card.status-non-quality-hold.active {
|
||||
background: #FFEDD5;
|
||||
border-width: 3px;
|
||||
transform: scale(1.03);
|
||||
box-shadow: 0 6px 25px rgba(249, 115, 22, 0.5);
|
||||
}
|
||||
|
||||
/* Dim non-active cards when filtering */
|
||||
.summary-row.filtering .summary-card.status-run:not(.active),
|
||||
.summary-row.filtering .summary-card.status-queue:not(.active),
|
||||
.summary-row.filtering .summary-card.status-hold:not(.active) {
|
||||
.summary-row.filtering .summary-card.status-quality-hold:not(.active),
|
||||
.summary-row.filtering .summary-card.status-non-quality-hold:not(.active) {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
@@ -595,7 +615,13 @@
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 1200px) {
|
||||
@media (max-width: 1400px) {
|
||||
.summary-row {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.summary-row {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
@@ -675,9 +701,13 @@
|
||||
<div class="summary-label">QUEUE</div>
|
||||
<div class="summary-value" id="queueLots">-</div>
|
||||
</div>
|
||||
<div class="summary-card status-hold" onclick="toggleStatusFilter('hold')" title="Click to filter HOLD only">
|
||||
<div class="summary-label">HOLD</div>
|
||||
<div class="summary-value" id="holdLots">-</div>
|
||||
<div class="summary-card status-quality-hold" onclick="toggleStatusFilter('quality-hold')" title="Click to filter Quality Hold only">
|
||||
<div class="summary-label">品質異常</div>
|
||||
<div class="summary-value" id="qualityHoldLots">-</div>
|
||||
</div>
|
||||
<div class="summary-card status-non-quality-hold" onclick="toggleStatusFilter('non-quality-hold')" title="Click to filter Non-Quality Hold only">
|
||||
<div class="summary-label">非品質異常</div>
|
||||
<div class="summary-value" id="nonQualityHoldLots">-</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -728,7 +758,7 @@
|
||||
};
|
||||
|
||||
// WIP Status filter (separate from other filters)
|
||||
let activeStatusFilter = null; // null | 'run' | 'queue' | 'hold'
|
||||
let activeStatusFilter = null; // null | 'run' | 'queue' | 'quality-hold' | 'non-quality-hold'
|
||||
|
||||
// AbortController for cancelling in-flight requests
|
||||
let tableAbortController = null; // For loadTableOnly()
|
||||
@@ -782,8 +812,17 @@
|
||||
params.package = state.filters.package;
|
||||
}
|
||||
if (activeStatusFilter) {
|
||||
// Convert to API status format (RUN/QUEUE/HOLD)
|
||||
params.status = activeStatusFilter.toUpperCase();
|
||||
// Handle hold type filters
|
||||
if (activeStatusFilter === 'quality-hold') {
|
||||
params.status = 'HOLD';
|
||||
params.hold_type = 'quality';
|
||||
} else if (activeStatusFilter === 'non-quality-hold') {
|
||||
params.status = 'HOLD';
|
||||
params.hold_type = 'non-quality';
|
||||
} else {
|
||||
// Convert to API status format (RUN/QUEUE)
|
||||
params.status = activeStatusFilter.toUpperCase();
|
||||
}
|
||||
}
|
||||
if (state.filters.workorder) {
|
||||
params.workorder = state.filters.workorder;
|
||||
@@ -839,7 +878,8 @@
|
||||
updateElementWithTransition('totalLots', summary.totalLots);
|
||||
updateElementWithTransition('runLots', summary.runLots);
|
||||
updateElementWithTransition('queueLots', summary.queueLots);
|
||||
updateElementWithTransition('holdLots', summary.holdLots);
|
||||
updateElementWithTransition('qualityHoldLots', summary.qualityHoldLots);
|
||||
updateElementWithTransition('nonQualityHoldLots', summary.nonQualityHoldLots);
|
||||
}
|
||||
|
||||
function renderTable(data) {
|
||||
@@ -1142,7 +1182,7 @@
|
||||
|
||||
function updateCardStyles() {
|
||||
const row = document.getElementById('summaryRow');
|
||||
const statusCards = document.querySelectorAll('.summary-card.status-run, .summary-card.status-queue, .summary-card.status-hold');
|
||||
const statusCards = document.querySelectorAll('.summary-card.status-run, .summary-card.status-queue, .summary-card.status-quality-hold, .summary-card.status-non-quality-hold');
|
||||
|
||||
// Remove active from all status cards
|
||||
statusCards.forEach(card => {
|
||||
@@ -1169,7 +1209,14 @@
|
||||
const baseTitle = 'Lot Details';
|
||||
|
||||
if (activeStatusFilter) {
|
||||
const statusLabel = activeStatusFilter.toUpperCase();
|
||||
let statusLabel;
|
||||
if (activeStatusFilter === 'quality-hold') {
|
||||
statusLabel = '品質異常 Hold';
|
||||
} else if (activeStatusFilter === 'non-quality-hold') {
|
||||
statusLabel = '非品質異常 Hold';
|
||||
} else {
|
||||
statusLabel = activeStatusFilter.toUpperCase();
|
||||
}
|
||||
titleEl.textContent = `${baseTitle} - ${statusLabel} Only`;
|
||||
} else {
|
||||
titleEl.textContent = baseTitle;
|
||||
|
||||
@@ -322,7 +322,7 @@
|
||||
/* WIP Status Cards */
|
||||
.wip-status-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
@@ -345,11 +345,16 @@
|
||||
border-color: #F59E0B;
|
||||
}
|
||||
|
||||
.wip-status-card.hold {
|
||||
.wip-status-card.quality-hold {
|
||||
background: #FEF2F2;
|
||||
border-color: #EF4444;
|
||||
}
|
||||
|
||||
.wip-status-card.non-quality-hold {
|
||||
background: #FFF7ED;
|
||||
border-color: #F97316;
|
||||
}
|
||||
|
||||
.wip-status-card .status-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -361,7 +366,8 @@
|
||||
|
||||
.wip-status-card.run .status-header { color: #166534; }
|
||||
.wip-status-card.queue .status-header { color: #92400E; }
|
||||
.wip-status-card.hold .status-header { color: #991B1B; }
|
||||
.wip-status-card.quality-hold .status-header { color: #991B1B; }
|
||||
.wip-status-card.non-quality-hold .status-header { color: #9A3412; }
|
||||
|
||||
.wip-status-card .status-header .dot {
|
||||
width: 8px;
|
||||
@@ -413,11 +419,16 @@
|
||||
box-shadow: 0 6px 25px rgba(245, 158, 11, 0.5);
|
||||
}
|
||||
|
||||
.wip-status-card.hold.active {
|
||||
.wip-status-card.quality-hold.active {
|
||||
background: #FEE2E2;
|
||||
box-shadow: 0 6px 25px rgba(239, 68, 68, 0.5);
|
||||
}
|
||||
|
||||
.wip-status-card.non-quality-hold.active {
|
||||
background: #FFEDD5;
|
||||
box-shadow: 0 6px 25px rgba(249, 115, 22, 0.5);
|
||||
}
|
||||
|
||||
/* Dim non-active cards when filtering */
|
||||
.wip-status-row.filtering .wip-status-card:not(.active) {
|
||||
opacity: 0.5;
|
||||
@@ -570,6 +581,26 @@
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
/* Hold Type Badge */
|
||||
.hold-type-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.hold-type-badge.quality {
|
||||
background: #FEE2E2;
|
||||
color: #991B1B;
|
||||
}
|
||||
|
||||
.hold-type-badge.non-quality {
|
||||
background: #FFEDD5;
|
||||
color: #9A3412;
|
||||
}
|
||||
|
||||
/* Loading */
|
||||
.loading-overlay {
|
||||
position: fixed;
|
||||
@@ -602,6 +633,12 @@
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 1400px) {
|
||||
.wip-status-row {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.content-grid {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -609,9 +646,6 @@
|
||||
.summary-row {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
.wip-status-row {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@@ -697,11 +731,18 @@
|
||||
<span id="queueQty">-</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wip-status-card hold" onclick="toggleStatusFilter('hold')">
|
||||
<div class="status-header"><span class="dot"></span>HOLD</div>
|
||||
<div class="wip-status-card quality-hold" onclick="toggleStatusFilter('quality-hold')">
|
||||
<div class="status-header"><span class="dot"></span>品質異常</div>
|
||||
<div class="status-values">
|
||||
<span id="holdLots">-</span>
|
||||
<span id="holdQty">-</span>
|
||||
<span id="qualityHoldLots">-</span>
|
||||
<span id="qualityHoldQty">-</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wip-status-card non-quality-hold" onclick="toggleStatusFilter('non-quality-hold')">
|
||||
<div class="status-header"><span class="dot"></span>非品質異常</div>
|
||||
<div class="status-values">
|
||||
<span id="nonQualityHoldLots">-</span>
|
||||
<span id="nonQualityHoldQty">-</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -839,7 +880,15 @@
|
||||
const params = buildQueryParams();
|
||||
// Add status filter if active
|
||||
if (activeStatusFilter) {
|
||||
params.status = activeStatusFilter.toUpperCase();
|
||||
if (activeStatusFilter === 'quality-hold') {
|
||||
params.status = 'HOLD';
|
||||
params.hold_type = 'quality';
|
||||
} else if (activeStatusFilter === 'non-quality-hold') {
|
||||
params.status = 'HOLD';
|
||||
params.hold_type = 'non-quality';
|
||||
} else {
|
||||
params.status = activeStatusFilter.toUpperCase();
|
||||
}
|
||||
}
|
||||
const result = await MesApi.get('/api/wip/overview/matrix', {
|
||||
params,
|
||||
@@ -1011,8 +1060,10 @@
|
||||
const runQty = ws.run?.qtyPcs;
|
||||
const queueLots = ws.queue?.lots;
|
||||
const queueQty = ws.queue?.qtyPcs;
|
||||
const holdLots = ws.hold?.lots;
|
||||
const holdQty = ws.hold?.qtyPcs;
|
||||
const qualityHoldLots = ws.qualityHold?.lots;
|
||||
const qualityHoldQty = ws.qualityHold?.qtyPcs;
|
||||
const nonQualityHoldLots = ws.nonQualityHold?.lots;
|
||||
const nonQualityHoldQty = ws.nonQualityHold?.qtyPcs;
|
||||
|
||||
updateElementWithTransition(
|
||||
'runLots',
|
||||
@@ -1031,12 +1082,20 @@
|
||||
queueQty === null || queueQty === undefined ? '-' : formatNumber(queueQty)
|
||||
);
|
||||
updateElementWithTransition(
|
||||
'holdLots',
|
||||
holdLots === null || holdLots === undefined ? '-' : `${formatNumber(holdLots)} lots`
|
||||
'qualityHoldLots',
|
||||
qualityHoldLots === null || qualityHoldLots === undefined ? '-' : `${formatNumber(qualityHoldLots)} lots`
|
||||
);
|
||||
updateElementWithTransition(
|
||||
'holdQty',
|
||||
holdQty === null || holdQty === undefined ? '-' : formatNumber(holdQty)
|
||||
'qualityHoldQty',
|
||||
qualityHoldQty === null || qualityHoldQty === undefined ? '-' : formatNumber(qualityHoldQty)
|
||||
);
|
||||
updateElementWithTransition(
|
||||
'nonQualityHoldLots',
|
||||
nonQualityHoldLots === null || nonQualityHoldLots === undefined ? '-' : `${formatNumber(nonQualityHoldLots)} lots`
|
||||
);
|
||||
updateElementWithTransition(
|
||||
'nonQualityHoldQty',
|
||||
nonQualityHoldQty === null || nonQualityHoldQty === undefined ? '-' : formatNumber(nonQualityHoldQty)
|
||||
);
|
||||
|
||||
if (data.dataUpdateDate) {
|
||||
@@ -1084,7 +1143,14 @@
|
||||
|
||||
const baseTitle = 'Workcenter x Package Matrix (QTY)';
|
||||
if (activeStatusFilter) {
|
||||
const statusLabel = activeStatusFilter.toUpperCase();
|
||||
let statusLabel;
|
||||
if (activeStatusFilter === 'quality-hold') {
|
||||
statusLabel = '品質異常 Hold';
|
||||
} else if (activeStatusFilter === 'non-quality-hold') {
|
||||
statusLabel = '非品質異常 Hold';
|
||||
} else {
|
||||
statusLabel = activeStatusFilter.toUpperCase();
|
||||
}
|
||||
titleEl.textContent = `${baseTitle} - ${statusLabel} Only`;
|
||||
} else {
|
||||
titleEl.textContent = baseTitle;
|
||||
@@ -1177,8 +1243,10 @@
|
||||
html += '</tr></thead><tbody>';
|
||||
|
||||
data.items.forEach(item => {
|
||||
const badgeClass = item.holdType === 'quality' ? 'quality' : 'non-quality';
|
||||
const badgeText = item.holdType === 'quality' ? '品質' : '非品質';
|
||||
html += '<tr>';
|
||||
html += `<td>${item.reason || '-'}</td>`;
|
||||
html += `<td><span class="hold-type-badge ${badgeClass}">${badgeText}</span>${item.reason || '-'}</td>`;
|
||||
html += `<td>${formatNumber(item.lots)}</td>`;
|
||||
html += `<td>${formatNumber(item.qty)}</td>`;
|
||||
html += '</tr>';
|
||||
|
||||
Reference in New Issue
Block a user