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:
beabigegg
2026-01-28 10:21:04 +08:00
parent b00750436e
commit fcfa942762
7 changed files with 840 additions and 45 deletions

View File

@@ -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**: 標籤顯示正確

View File

@@ -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 參數保持不變,新增參數為可選

View File

@@ -0,0 +1,109 @@
# Implementation Tasks
## Phase 1: Backend - Hold 分類邏輯
### wip_service.py
- [x] 新增 `NON_QUALITY_HOLD_REASONS` 常數 Set11 個非品質異常 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 breakpoints1400px → 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] 響應式測試:不同螢幕寬度下卡片正確排列

View File

@@ -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,

View File

@@ -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)

View File

@@ -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;

View File

@@ -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>';