chore: 新增未追蹤的專案檔案

OpenSpec Changes:
- archive/2026-01-27-wip-status-filter: WIP 狀態篩選功能規格
- db-connection-stability: 資料庫連線穩定性規格(進行中)

Documents:
- DW_PJ_LOT_V_POWERBI_SQL.txt: Power BI SQL 查詢參考

Frontend Design:
- WIP_main.pen: WIP 主頁設計稿
- Hold_detail.pen: Hold 明細頁設計稿

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
beabigegg
2026-01-28 15:07:24 +08:00
parent 460a6f7490
commit e21d736b3e
9 changed files with 3814 additions and 0 deletions

View File

@@ -0,0 +1,60 @@
SELECT L.LOTID AS ""Run Card Lot ID"",
L.Workorder AS ""Work Order ID"",
L.Qty AS ""Lot Qty(pcs)"",
L.Qty2 AS ""Lot Qty(Wafer pcs)"",
L.Status AS ""Run Card Status"",
L.HOLDREASONNAME AS ""Hold Reason"",
L.CurrentHoldCount AS ""Hold Count"",
L.Owner AS ""Work Order Owner"",
L.StartDate AS ""Run Card Start Date"",
L.UTS,
L.Product AS ""Product P/N"",
L.Productlinename AS ""Package"",
L.Package_LEF as ""Package(LF)"",
L.PJ_FUNCTION AS ""Product Function"",
L.Pj_Type AS ""Product Type"",
L.BOP,
L.FirstName AS ""Wafer Lot ID"",
L.WAFERNAME AS ""Wafer P/N"",
L.WaferLot ""Wafer Lot ID(Prefix)"",
L.SpecName AS ""Spec"",
L.SPECSEQUENCE AS ""Spec Sequence"",
L.SPECSEQUENCE || '_' || L.SpecName AS ""Spec(Order)"",
L.Workcentername AS ""Work Center"",
L.WorkCenterSequence AS ""Work Center Sequence"",
L.WorkCenter_Group AS ""Work Center(Group)"",
L.WorkCenter_Short AS ""Work Center(Short)"",
L.WorkCenterSequence_Group AS ""Work Center Sequence(Group)"",
L.WorkCenterSequence_Group || '_' || L.WorkCenter_Group AS ""Work Center Group(Order)"",
L.AgeByDays AS ""Age By Days"",
L.Equipments AS ""Equipment ID"",
L.EquipmentCount AS ""Equipment Count"",
L.Workflowname AS ""Work Flow Name"",
L.Datecode AS ""Product Date Code"",
L.LEADFRAMENAME AS ""LF Material Part"",
L.LEADFRAMEOPTION AS ""LF Option ID"",
L.COMNAME AS ""Compound Material Part"",
L.LOCATIONNAME AS ""Run Card Location"",
L.Eventname AS ""NCR ID"",
L.Occurrencedate AS ""NCR-issued Time"",
L.ReleaseTime AS ""Release Time"",
L.ReleaseEmp AS ""Release Employee"",
L.ReleaseReason AS ""Release Comment"",
L.COMMENT_HOLD AS ""Hold Comment"",
L.CONTAINERCOMMENTS AS ""Comment"",
L.COMMENT_DATE AS ""Run Card Comment"",
L.COMMENT_EMP AS ""Run Card Comment Employee"",
L.COMMENT_FUTURE AS ""Future Hold Comment"",
L.HOLDEMP AS ""Hold Employee"",
L.DEPTNAME AS ""Hold Employee Dept"",
L.PJ_PRODUCEREGION AS ""Produce Region"",
L.Prioritycodename AS ""Work Order Priority"",
L.TMTT_R AS ""TMTT Remaining"",
L.wafer_factor AS ""Die Consumption Qty"",
Case When (L.EquipmentCount>0) Then 'RUN'
When (L.CurrentHoldCount>0) Then 'HOLD'
ELSE 'QUENE' End AS ""WIP Status"",
Case When (L.EquipmentCount>0) Then 1
When (L.CurrentHoldCount>0) Then 3
ELSE 2 End AS ""WIP Status Sequence"",
sys_date AS ""Data Update Date""

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,614 @@
{
"version": "2.6",
"children": [
{
"type": "frame",
"id": "GIoPU",
"x": 950,
"y": 0,
"name": "WIP Overview - Integrated",
"width": 1200,
"fill": "#F5F7FA",
"layout": "vertical",
"gap": 16,
"padding": 20,
"children": [
{
"type": "frame",
"id": "N2qxA",
"name": "header",
"width": "fill_container",
"height": 64,
"fill": {
"type": "gradient",
"gradientType": "linear",
"enabled": true,
"rotation": 135,
"size": {
"height": 1
},
"colors": [
{
"color": "#667eea",
"position": 0
},
{
"color": "#764ba2",
"position": 1
}
]
},
"cornerRadius": 10,
"padding": [
0,
22
],
"justifyContent": "space_between",
"alignItems": "center",
"children": [
{
"type": "text",
"id": "7h8YC",
"name": "headerTitle",
"fill": "#FFFFFF",
"content": "WIP Overview Dashboard",
"fontFamily": "Inter",
"fontSize": 24,
"fontWeight": "600"
},
{
"type": "frame",
"id": "JyskI",
"name": "headerRight",
"gap": 16,
"alignItems": "center",
"children": [
{
"type": "text",
"id": "8rtgc",
"name": "lastUpdate",
"fill": "rgba(255,255,255,0.8)",
"content": "Last Update: 2026-01-27 14:30",
"fontFamily": "Inter",
"fontSize": 13,
"fontWeight": "normal"
},
{
"type": "frame",
"id": "ZH0PW",
"name": "refreshBtn",
"fill": "rgba(255,255,255,0.2)",
"cornerRadius": 8,
"padding": [
9,
20
],
"children": [
{
"type": "text",
"id": "wlrMh",
"name": "refreshText",
"fill": "#FFFFFF",
"content": "重新整理",
"fontFamily": "Inter",
"fontSize": 13,
"fontWeight": "600"
}
]
}
]
}
]
},
{
"type": "frame",
"id": "aYXjP",
"name": "Summary Section",
"width": "fill_container",
"layout": "vertical",
"gap": 12,
"children": [
{
"type": "frame",
"id": "pFof4",
"name": "kpiRow",
"width": "fill_container",
"gap": 14,
"children": [
{
"type": "frame",
"id": "0kWPh",
"name": "kpi1",
"width": "fill_container",
"fill": "#FFFFFF",
"cornerRadius": 10,
"stroke": {
"align": "inside",
"thickness": 1,
"fill": "#E2E6EF"
},
"layout": "vertical",
"gap": 6,
"padding": [
16,
20
],
"alignItems": "center",
"children": [
{
"type": "text",
"id": "DTtUq",
"name": "kpi1Label",
"fill": "#666666",
"content": "Total Lots",
"fontFamily": "Inter",
"fontSize": 12,
"fontWeight": "normal"
},
{
"type": "text",
"id": "vdmq8",
"name": "kpi1Value",
"fill": "#667eea",
"content": "1,234",
"fontFamily": "Inter",
"fontSize": 28,
"fontWeight": "700"
}
]
},
{
"type": "frame",
"id": "wEupl",
"name": "kpi2",
"width": "fill_container",
"fill": "#FFFFFF",
"cornerRadius": 10,
"stroke": {
"align": "inside",
"thickness": 1,
"fill": "#E2E6EF"
},
"layout": "vertical",
"gap": 6,
"padding": [
16,
20
],
"alignItems": "center",
"children": [
{
"type": "text",
"id": "59OHd",
"name": "kpi2Label",
"fill": "#666666",
"content": "Total QTY",
"fontFamily": "Inter",
"fontSize": 12,
"fontWeight": "normal"
},
{
"type": "text",
"id": "YkPVl",
"name": "kpi2Value",
"fill": "#667eea",
"content": "56,789",
"fontFamily": "Inter",
"fontSize": 28,
"fontWeight": "700"
}
]
}
]
},
{
"type": "frame",
"id": "g65nT",
"name": "wipStatusRow",
"width": "fill_container",
"gap": 14,
"children": [
{
"type": "frame",
"id": "sbKdU",
"name": "runCard",
"width": "fill_container",
"fill": "#F0FDF4",
"cornerRadius": 10,
"stroke": {
"align": "inside",
"thickness": 2,
"fill": "#22C55E"
},
"layout": "vertical",
"gap": 8,
"padding": [
16,
20
],
"justifyContent": "space_between",
"alignItems": "center",
"children": [
{
"type": "frame",
"id": "EQzBo",
"name": "runLeft",
"width": "fill_container",
"gap": 10,
"justifyContent": "center",
"alignItems": "center",
"children": [
{
"type": "rectangle",
"cornerRadius": 5,
"id": "m7Prk",
"name": "runDot",
"fill": "#22C55E",
"width": 10,
"height": 10
},
{
"type": "text",
"id": "1DMEu",
"name": "runLabel",
"fill": "#166534",
"content": "RUN",
"fontFamily": "Inter",
"fontSize": 14,
"fontWeight": "600"
}
]
},
{
"type": "frame",
"id": "ZVtRH",
"name": "runRight",
"width": "fill_container",
"gap": 24,
"justifyContent": "center",
"children": [
{
"type": "text",
"id": "OLwma",
"name": "runLots",
"fill": "#0D0D0D",
"content": "500 lots",
"fontFamily": "Inter",
"fontSize": 24,
"fontWeight": "700"
},
{
"type": "text",
"id": "OI5f5",
"name": "runQty",
"fill": "#166534",
"content": "30,000 pcs",
"fontFamily": "Inter",
"fontSize": 24,
"fontWeight": "700"
}
]
}
]
},
{
"type": "frame",
"id": "uibRH",
"name": "queueCard",
"width": "fill_container",
"fill": "#FFFBEB",
"cornerRadius": 10,
"stroke": {
"align": "inside",
"thickness": 2,
"fill": "#F59E0B"
},
"layout": "vertical",
"gap": 8,
"padding": [
16,
20
],
"justifyContent": "space_between",
"alignItems": "center",
"children": [
{
"type": "frame",
"id": "xeGDP",
"name": "queueLeft",
"width": "fill_container",
"gap": 10,
"justifyContent": "center",
"alignItems": "center",
"children": [
{
"type": "rectangle",
"cornerRadius": 5,
"id": "KuAgl",
"name": "queueDot",
"fill": "#F59E0B",
"width": 10,
"height": 10
},
{
"type": "text",
"id": "TsD9B",
"name": "queueLabel",
"fill": "#92400E",
"content": "QUEUE",
"fontFamily": "Inter",
"fontSize": 14,
"fontWeight": "600"
}
]
},
{
"type": "frame",
"id": "41Db3",
"name": "queueRight",
"width": "fill_container",
"gap": 24,
"justifyContent": "center",
"children": [
{
"type": "text",
"id": "dtaqd",
"name": "queueLots",
"fill": "#0D0D0D",
"content": "634 lots",
"fontFamily": "Inter",
"fontSize": 24,
"fontWeight": "700"
},
{
"type": "text",
"id": "BVusD",
"name": "queueQty",
"fill": "#92400E",
"content": "21,789 pcs",
"fontFamily": "Inter",
"fontSize": 24,
"fontWeight": "700"
}
]
}
]
},
{
"type": "frame",
"id": "Y5gLu",
"name": "holdCard",
"width": "fill_container",
"fill": "#FEF2F2",
"cornerRadius": 10,
"stroke": {
"align": "inside",
"thickness": 2,
"fill": "#EF4444"
},
"layout": "vertical",
"gap": 8,
"padding": [
16,
20
],
"justifyContent": "space_between",
"alignItems": "center",
"children": [
{
"type": "frame",
"id": "juHZC",
"name": "holdLeft",
"width": "fill_container",
"gap": 10,
"justifyContent": "center",
"alignItems": "center",
"children": [
{
"type": "rectangle",
"cornerRadius": 5,
"id": "FW9Vv",
"name": "holdDot",
"fill": "#EF4444",
"width": 10,
"height": 10
},
{
"type": "text",
"id": "gEojA",
"name": "holdLabel",
"fill": "#991B1B",
"content": "HOLD",
"fontFamily": "Inter",
"fontSize": 14,
"fontWeight": "600"
}
]
},
{
"type": "frame",
"id": "3imiS",
"name": "holdRight",
"width": "fill_container",
"gap": 24,
"justifyContent": "center",
"children": [
{
"type": "text",
"id": "AlTi3",
"name": "holdLots",
"fill": "#0D0D0D",
"content": "100 lots",
"fontFamily": "Inter",
"fontSize": 24,
"fontWeight": "700"
},
{
"type": "text",
"id": "oKc0i",
"name": "holdQty",
"fill": "#991B1B",
"content": "5,000 pcs",
"fontFamily": "Inter",
"fontSize": 24,
"fontWeight": "700"
}
]
}
]
}
]
}
]
},
{
"type": "frame",
"id": "uRXyA",
"name": "Content Grid",
"width": "fill_container",
"gap": 16,
"children": [
{
"type": "frame",
"id": "7HMip",
"name": "matrixCard",
"width": "fill_container",
"fill": "#FFFFFF",
"cornerRadius": 10,
"stroke": {
"align": "inside",
"thickness": 1,
"fill": "#E2E6EF"
},
"layout": "vertical",
"children": [
{
"type": "frame",
"id": "pxsYm",
"name": "matrixHeader",
"width": "fill_container",
"fill": "#FAFBFC",
"stroke": {
"align": "inside",
"thickness": {
"bottom": 1
},
"fill": "#E2E6EF"
},
"padding": [
14,
20
],
"children": [
{
"type": "text",
"id": "JhSDl",
"name": "matrixTitle",
"fill": "#222222",
"content": "Workcenter x Package Matrix (QTY)",
"fontFamily": "Inter",
"fontSize": 15,
"fontWeight": "600"
}
]
},
{
"type": "frame",
"id": "4hQZP",
"name": "matrixBody",
"width": "fill_container",
"height": 200,
"layout": "vertical",
"padding": 16,
"justifyContent": "center",
"alignItems": "center",
"children": [
{
"type": "text",
"id": "lH6Yr",
"name": "matrixPlaceholder",
"fill": "#999999",
"content": "[ Matrix Table ]",
"fontFamily": "Inter",
"fontSize": 14,
"fontWeight": "normal"
}
]
}
]
},
{
"type": "frame",
"id": "FOIFS",
"name": "holdSummaryCard",
"width": 320,
"fill": "#FFFFFF",
"cornerRadius": 10,
"stroke": {
"align": "inside",
"thickness": 1,
"fill": "#E2E6EF"
},
"layout": "vertical",
"children": [
{
"type": "frame",
"id": "uikVi",
"name": "holdSummaryHeader",
"width": "fill_container",
"fill": "#FAFBFC",
"stroke": {
"align": "inside",
"thickness": {
"bottom": 1
},
"fill": "#E2E6EF"
},
"padding": [
14,
20
],
"children": [
{
"type": "text",
"id": "VBWBv",
"name": "holdSummaryTitle",
"fill": "#222222",
"content": "Hold Summary",
"fontFamily": "Inter",
"fontSize": 15,
"fontWeight": "600"
}
]
},
{
"type": "frame",
"id": "cFEPm",
"name": "holdSummaryBody",
"width": "fill_container",
"height": 200,
"layout": "vertical",
"padding": 16,
"justifyContent": "center",
"alignItems": "center",
"children": [
{
"type": "text",
"id": "s7sa1",
"name": "holdSummaryPlaceholder",
"fill": "#999999",
"content": "[ Hold Table ]",
"fontFamily": "Inter",
"fontSize": 14,
"fontWeight": "normal"
}
]
}
]
}
]
}
]
}
]
}

View File

@@ -0,0 +1,348 @@
# WIP Status Filter - 技術設計
## 架構概覽
```
┌─────────────────────────────────────────────────────────────┐
│ wip_overview.html (前端) │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ RUN │ │ QUEUE │ │ HOLD │ ← 可點擊卡片 │
│ └────┬────┘ └────┬────┘ └────┬────┘ │
│ │ │ │ │
│ └───────────┼───────────┘ │
│ ▼ │
│ activeStatusFilter (state) │
│ │ │
│ ▼ │
│ GET /api/wip/overview/matrix?status={RUN|QUEUE|HOLD} │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ wip_routes.py │
│ api_overview_matrix() ← 新增 status 參數解析 │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ wip_service.py │
│ get_wip_matrix(status=...) ← 新增 status 篩選邏輯 │
└─────────────────────────────────────────────────────────────┘
```
---
## 後端變更
### 1. wip_service.py - get_wip_matrix()
**新增參數**`status: Optional[str] = None`
**SQL 條件**
```python
# 在 _build_base_conditions 之後加入
if status:
status_upper = status.upper()
if status_upper == 'RUN':
conditions.append("EQUIPMENTCOUNT > 0")
elif status_upper == 'HOLD':
conditions.append("EQUIPMENTCOUNT = 0 AND CURRENTHOLDCOUNT > 0")
elif status_upper == 'QUEUE':
conditions.append("EQUIPMENTCOUNT = 0 AND CURRENTHOLDCOUNT = 0")
```
**函數簽名變更**
```python
def get_wip_matrix(
include_dummy: bool = False,
workorder: Optional[str] = None,
lotid: Optional[str] = None,
status: Optional[str] = None # 新增
) -> Optional[Dict[str, Any]]:
```
### 2. wip_routes.py - api_overview_matrix()
**新增參數解析**
```python
status = request.args.get('status', '').strip().upper() or None
# 驗證 status 值
if status and status not in ('RUN', 'QUEUE', 'HOLD'):
return jsonify({
'success': False,
'error': 'Invalid status. Use RUN, QUEUE, or HOLD'
}), 400
result = get_wip_matrix(
include_dummy=include_dummy,
workorder=workorder,
lotid=lotid,
status=status # 新增
)
```
---
## 前端變更
### 1. 新增狀態變數
```javascript
// 在 state 物件旁邊新增
let activeStatusFilter = null; // null | 'run' | 'queue' | 'hold'
```
### 2. 新增 CSS 樣式
```css
/* 可點擊的卡片 */
.wip-status-card {
cursor: pointer;
transition: all 0.2s ease;
}
.wip-status-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.15);
}
/* 選中狀態 */
.wip-status-card.active {
border-width: 3px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
}
.wip-status-card.run.active {
box-shadow: 0 4px 20px rgba(34, 197, 94, 0.4);
}
.wip-status-card.queue.active {
box-shadow: 0 4px 20px rgba(245, 158, 11, 0.4);
}
.wip-status-card.hold.active {
box-shadow: 0 4px 20px rgba(239, 68, 68, 0.4);
}
/* 篩選提示標籤 */
.filter-badge {
display: none;
font-size: 10px;
padding: 2px 6px;
border-radius: 4px;
margin-left: 8px;
background: rgba(0, 0, 0, 0.1);
}
.wip-status-card.active .filter-badge {
display: inline-block;
}
```
### 3. HTML 變更
為卡片添加 `onclick` 和篩選標籤:
```html
<div class="wip-status-card run" onclick="toggleStatusFilter('run')">
<div class="status-header">
<span class="dot"></span>RUN
<span class="filter-badge">FILTERED</span>
</div>
<div class="status-values">
<span id="runLots">-</span>
<span id="runQty">-</span>
</div>
</div>
```
### 4. JavaScript 邏輯
```javascript
function toggleStatusFilter(status) {
if (activeStatusFilter === status) {
// 解除篩選
activeStatusFilter = null;
} else {
// 套用新篩選
activeStatusFilter = status;
}
// 更新卡片樣式
updateCardStyles();
// 重新載入 Matrix帶篩選參數
loadMatrix();
}
function updateCardStyles() {
document.querySelectorAll('.wip-status-card').forEach(card => {
card.classList.remove('active');
});
if (activeStatusFilter) {
const activeCard = document.querySelector(`.wip-status-card.${activeStatusFilter}`);
if (activeCard) {
activeCard.classList.add('active');
}
}
}
function loadMatrix() {
const params = new URLSearchParams();
// 現有篩選條件
const workorder = document.getElementById('workorder').value.trim();
const lotid = document.getElementById('lotid').value.trim();
if (workorder) params.append('workorder', workorder);
if (lotid) params.append('lotid', lotid);
// 狀態篩選
if (activeStatusFilter) {
params.append('status', activeStatusFilter.toUpperCase());
}
const url = '/api/wip/overview/matrix' + (params.toString() ? '?' + params : '');
// 顯示載入狀態
document.getElementById('matrixContainer').innerHTML =
'<div class="placeholder">Loading...</div>';
fetch(url)
.then(response => response.json())
.then(result => {
if (result.success) {
renderMatrix(result.data);
} else {
document.getElementById('matrixContainer').innerHTML =
'<div class="placeholder">Error loading data</div>';
}
})
.catch(error => {
console.error('Matrix load error:', error);
document.getElementById('matrixContainer').innerHTML =
'<div class="placeholder">Error loading data</div>';
});
}
```
### 5. 整合 loadAllData()
修改 `loadAllData()` 確保 Matrix 載入時考慮 `activeStatusFilter`
```javascript
async function loadAllData(showLoading = false) {
// ... 現有邏輯 ...
// Matrix 改為獨立載入(支援狀態篩選)
loadMatrix();
// Summary 和 Hold 維持不變
// ...
}
```
### 6. Auto-refresh 整合
確保自動刷新時保留篩選狀態:
```javascript
function startAutoRefresh() {
refreshInterval = setInterval(() => {
loadAllData(false); // loadMatrix() 內部會讀取 activeStatusFilter
}, 600000);
}
```
---
## Matrix 表格標題顯示篩選狀態
當有篩選時,更新 Matrix 標題:
```javascript
function updateMatrixTitle() {
const titleEl = document.querySelector('.card-title');
const baseTitle = 'Workcenter x Package Matrix (QTY)';
if (activeStatusFilter) {
const statusLabel = activeStatusFilter.toUpperCase();
titleEl.textContent = `${baseTitle} - ${statusLabel} Only`;
} else {
titleEl.textContent = baseTitle;
}
}
```
---
## 資料流程
### 篩選開啟流程
```
1. 使用者點擊 RUN 卡片
2. toggleStatusFilter('run')
3. activeStatusFilter = 'run'
4. updateCardStyles() → RUN 卡片加上 .active
5. loadMatrix() → GET /api/wip/overview/matrix?status=RUN
6. 後端過濾 EQUIPMENTCOUNT > 0 的資料
7. 前端 renderMatrix() 顯示篩選結果
```
### 篩選關閉流程
```
1. 使用者再次點擊 RUN 卡片
2. toggleStatusFilter('run')
3. activeStatusFilter = null因為已是 'run'
4. updateCardStyles() → 移除所有 .active
5. loadMatrix() → GET /api/wip/overview/matrix無 status 參數)
6. 後端返回全部資料
7. 前端 renderMatrix() 顯示完整 Matrix
```
---
## 邊界情況
| 情況 | 處理方式 |
|------|----------|
| 篩選結果為空 | 顯示 "No data available" |
| 與 workorder/lotid 組合篩選 | 條件疊加AND |
| 無效的 status 參數 | API 返回 400 錯誤 |
| 自動刷新時 | 保留 activeStatusFilter 狀態 |
| 手動刷新時 | 保留 activeStatusFilter 狀態 |
---
## 測試要點
1. **基本功能**
- 點擊 RUN → Matrix 只顯示 RUN 狀態數量
- 點擊 QUEUE → Matrix 只顯示 QUEUE 狀態數量
- 點擊 HOLD → Matrix 只顯示 HOLD 狀態數量
- 再次點擊 → 恢復全部
2. **視覺回饋**
- 選中卡片有明顯樣式變化
- 載入時顯示 Loading 提示
3. **組合篩選**
- 同時輸入 workorder + 點選 RUN → 兩個條件都生效
4. **自動刷新**
- 篩選狀態在刷新後保持

View File

@@ -0,0 +1,86 @@
# WIP Status Filter
## 問題描述
WIP 即時概況頁面目前顯示三個狀態卡片RUN / QUEUE / HOLD和 Workcenter × Package Matrix 表格,但兩者之間沒有互動關係。
使用者想快速查看「目前有多少 WIP 正在 RUN」或「HOLD 的量分布在哪些工站」時,需要另外到 Detail 頁面篩選,操作不便。
## 目標
在 WIP Overview 頁面新增卡片篩選功能:
- 點選 RUN / QUEUE / HOLD 卡片時Matrix 表格只顯示該狀態的數量
- 再點一次同一卡片則解除篩選toggle 行為)
- 提供清晰的視覺回饋,讓使用者知道目前的篩選狀態
## 目標用戶
- 生產主管:快速了解各狀態的分布
- 值班人員:追蹤 HOLD 量的工站分布
## 預期行為
1. **初始狀態**Matrix 顯示全部數量(現行行為)
2. **點選卡片**
- 卡片顯示「選中」樣式(例如外框加粗、背景變深)
- Matrix 重新載入,只顯示該狀態的數量
- 其他兩張卡片恢復「未選中」樣式
3. **再次點選同一卡片**
- 卡片恢復「未選中」樣式
- Matrix 恢復顯示全部數量
4. **篩選期間**
- Summary 區域的 Total Lots / Total QTY 不變(保持全局統計)
- Hold Summary 區塊不受影響
## 範圍界定
### 包含
- 前端卡片點擊事件、選中樣式、Matrix 重載邏輯
- 後端:`/api/wip/overview/matrix` 新增 `status` 參數(可選,值為 RUN/QUEUE/HOLD
- 後端:`get_wip_matrix()` 函數新增 `status` 篩選條件
### 不包含
- Summary 卡片的數值變動(維持全局統計)
- Hold Summary 區塊的篩選
- URL 參數同步(不影響分享連結)
- 鍵盤快捷鍵
## 技術考量
### WIP 狀態判斷邏輯(與 IT 定義一致)
```sql
CASE
WHEN EQUIPMENTCOUNT > 0 THEN 'RUN'
WHEN CURRENTHOLDCOUNT > 0 THEN 'HOLD'
ELSE 'QUEUE'
END AS WIP_STATUS
```
### API 變更
```
GET /api/wip/overview/matrix?status=RUN
GET /api/wip/overview/matrix?status=QUEUE
GET /api/wip/overview/matrix?status=HOLD
GET /api/wip/overview/matrix (不帶參數 = 全部)
```
### 前端狀態
```javascript
let activeStatusFilter = null; // null | 'run' | 'queue' | 'hold'
```
## 風險評估
| 風險 | 影響 | 緩解措施 |
|------|------|----------|
| API 查詢變慢 | 低 | 狀態條件可利用現有索引 |
| 使用者混淆篩選狀態 | 中 | 明確的視覺回饋 + 載入提示 |
## 成功標準
- 點擊卡片後 Matrix 正確顯示篩選結果
- 視覺回饋清晰,使用者能直觀理解當前狀態
- 篩選操作響應時間 < 1

View File

@@ -0,0 +1,99 @@
## Tasks
### Phase 1: 後端 API 支援
- [x] **修改 wip_service.py - get_wip_matrix()**
- 新增 `status` 參數Optional[str]
- 加入 WIP 狀態篩選條件
- RUN: `EQUIPMENTCOUNT > 0`
- HOLD: `EQUIPMENTCOUNT = 0 AND CURRENTHOLDCOUNT > 0`
- QUEUE: `EQUIPMENTCOUNT = 0 AND CURRENTHOLDCOUNT = 0`
- [x] **修改 wip_routes.py - api_overview_matrix()**
- 解析 `status` query parameter
- 驗證 status 值RUN/QUEUE/HOLD 或空)
- 傳遞給 get_wip_matrix()
### Phase 2: 前端互動
- [x] **新增 CSS 樣式**
- `.wip-status-card` 加入 cursor: pointer 和 hover 效果
- `.wip-status-card.active` 選中狀態樣式
- 各狀態的選中陰影顏色
- [x] **修改 HTML 結構**
- 三張卡片加入 `onclick="toggleStatusFilter('xxx')"`
- [x] **實作 JavaScript 邏輯**
- 新增 `activeStatusFilter` 狀態變數
- 實作 `toggleStatusFilter(status)` 函數
- 實作 `updateCardStyles()` 函數
- 實作 `loadMatrixOnly()` 獨立載入 Matrix
- 修改 `fetchMatrix()` 支援 status 參數
### Phase 3: 整合與優化
- [x] **整合 loadAllData()**
- fetchMatrix() 內部已讀取 activeStatusFilter
- Summary 和 Hold Summary 不受影響
- [x] **Matrix 標題顯示篩選狀態**
- 實作 `updateMatrixTitle()` 函數
- 有篩選時顯示 "- RUN Only" 等後綴
### Phase 4: WIP Detail 頁面整合
- [x] **增強視覺效果wip_overview.html**
- 更強的 active 狀態scale 1.03, border 4px
- 更深的背景色(#DCFCE7, #FEF3C7, #FEE2E2
- 非選中卡片變暗opacity: 0.5
- [x] **套用至 WIP Detail 頁面**
- 新增可點擊的 CSS 樣式到 summary cards
- 移除 Status 下拉選單(原本是 ACTIVE/HOLD
- Summary cards 加入 onclick handlers
- 新增 `activeStatusFilter` 狀態變數
- 實作 `toggleStatusFilter()`, `updateCardStyles()`, `updateTableTitle()`
- 修改 `fetchDetail()` 支援 status 參數
- 新增 `loadTableOnly()` 獨立載入函數(避免 isLoading 阻擋切換)
- [x] **後端 API 更新**
- 修改 `get_wip_detail()` 支援 RUN/QUEUE/HOLD 篩選
- 修改 `api_detail()` 驗證 status 參數
### 驗證測試
- [x] **WIP Overview 功能測試**
- 點擊 RUN → Matrix 只顯示 RUN 數量
- 點擊 QUEUE → Matrix 只顯示 QUEUE 數量
- 點擊 HOLD → Matrix 只顯示 HOLD 數量
- 再次點擊 → 恢復全部
- [x] **WIP Detail 功能測試**
- 點擊 RUN → Table 只顯示 RUN lots
- 點擊 QUEUE → Table 只顯示 QUEUE lots
- 點擊 HOLD → Table 只顯示 HOLD lots
- 再次點擊 → 恢復全部
- [x] **視覺測試**
- 選中卡片樣式正確(兩頁面)
- 非選中卡片變暗
- 載入時有提示
- [x] **組合篩選測試**
- workorder + status 同時篩選
- lotid + status 同時篩選
- package + status 同時篩選Detail
### Bug 修復
- [x] **WIP Detail 無法直接切換篩選狀態**
- 問題:`loadAllData()``isLoading` 保護,載入中點擊其他卡片會被忽略
- 解法:新增 `loadTableOnly()` 獨立載入函數,與 WIP Overview 的 `loadMatrixOnly()` 行為一致
- [x] **快速切換篩選導致連線堆積 Timeout**
- 問題:連續快速點擊不同狀態卡片,多個請求同時發出,耗盡連線池
- 解法:使用 `AbortController` 取消前一個進行中的請求
- WIP Overview: `matrixAbortController` + `loadMatrixOnly()` 取消邏輯
- WIP Detail: `tableAbortController` + `loadTableOnly()` 取消邏輯
- `fetchWithTimeout()` 新增 `externalSignal` 參數支援外部取消

View File

@@ -0,0 +1,293 @@
# Technical Design
## Decision: 連線池策略
**選擇**: SQLAlchemy QueuePool而非 oracledb SessionPool
**原因**:
- 現有程式碼已使用 SQLAlchemy
- QueuePool 提供內建的連線健康檢查pool_pre_ping
- 更容易與現有 `read_sql_df()` 整合
**設定**:
```python
create_engine(
CONNECTION_STRING,
pool_size=5, # 基本連線數
max_overflow=10, # 額外連線數(尖峰時)
pool_timeout=30, # 等待連線的超時
pool_recycle=1800, # 30分鐘回收連線
pool_pre_ping=True, # 使用前檢查連線健康
)
```
---
## Decision: Logging 策略
**選擇**: 使用 Python logging 模組 + 結構化格式
**Logger 配置**:
```python
import logging
logger = logging.getLogger('mes_dashboard.database')
logger.setLevel(logging.INFO)
# Format: [時間] [層級] [模組] 訊息
formatter = logging.Formatter(
'%(asctime)s [%(levelname)s] %(name)s: %(message)s'
)
```
**記錄內容**:
- 連線成功/失敗(含 ORA 錯誤碼)
- 查詢時間(標記 >1s 為慢查詢)
- 重試次數
---
## Decision: 密碼 URL 編碼
**選擇**: 使用 `urllib.parse.quote_plus()` 編碼密碼
**實作**:
```python
from urllib.parse import quote_plus
CONNECTION_STRING = (
f"oracle+oracledb://{DB_USER}:{quote_plus(DB_PASSWORD)}"
f"@{DB_HOST}:{DB_PORT}/?service_name={DB_SERVICE}"
)
```
---
## Implementation Approach
### 1. config/database.py 修改
```python
from urllib.parse import quote_plus
# 安全的連線字串(密碼已編碼)
CONNECTION_STRING = (
f"oracle+oracledb://{DB_USER}:{quote_plus(DB_PASSWORD)}"
f"@{DB_HOST}:{DB_PORT}/?service_name={DB_SERVICE}"
)
```
### 2. core/database.py 修改
```python
import logging
import time
from functools import wraps
logger = logging.getLogger('mes_dashboard.database')
def get_engine():
global _ENGINE
if _ENGINE is None:
_ENGINE = create_engine(
CONNECTION_STRING,
pool_size=5,
max_overflow=10,
pool_timeout=30,
pool_recycle=1800,
pool_pre_ping=True,
connect_args={
"tcp_connect_timeout": 15,
"retry_count": 2,
"retry_delay": 1,
}
)
logger.info("Database engine created with QueuePool")
return _ENGINE
def read_sql_df(sql: str, params=None) -> pd.DataFrame:
"""Execute SQL with timing and error logging."""
start_time = time.time()
engine = get_engine()
try:
with engine.connect() as conn:
df = pd.read_sql(text(sql), conn, params=params)
df.columns = [str(c).upper() for c in df.columns]
elapsed = time.time() - start_time
if elapsed > 1.0:
logger.warning(f"Slow query ({elapsed:.2f}s): {sql[:100]}...")
else:
logger.debug(f"Query completed in {elapsed:.3f}s")
return df
except Exception as exc:
elapsed = time.time() - start_time
# 擷取 ORA 錯誤碼
ora_code = _extract_ora_code(exc)
logger.error(
f"Query failed after {elapsed:.2f}s - "
f"ORA-{ora_code}: {exc}"
)
raise
def _extract_ora_code(exc: Exception) -> str:
"""從例外中擷取 ORA 錯誤碼."""
import re
match = re.search(r'ORA-(\d+)', str(exc))
return match.group(1) if match else 'UNKNOWN'
```
---
## Data Flow
```
┌──────────────────────────────────────────────────────────────┐
│ 改進後的連線流程 │
├──────────────────────────────────────────────────────────────┤
│ │
│ API Request │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ read_sql_df() │ │
│ │ - 記錄開始時間 │ │
│ │ - 從 QueuePool 取得連線(或等待/新建) │ │
│ └─────────────────────────────────────────────────────┘ │
│ │ │
│ │ pool_pre_ping (健康檢查) │
│ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 執行查詢 │ │
│ │ - 成功: 記錄查詢時間,標記慢查詢 │ │
│ │ - 失敗: 記錄 ORA 錯誤碼,考慮重試 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │ │
│ │ 連線歸還 Pool不關閉
│ ▼ │
│ API Response │
│ │
└──────────────────────────────────────────────────────────────┘
```
---
## Testing Strategy
### 1. 單元測試
- Mock engine 測試 logging 輸出
- 測試 ORA 錯誤碼擷取
### 2. 整合測試
- 驗證連線池行為pool_size, max_overflow
- 模擬連線失敗,驗證重試邏輯
### 3. 監控驗證
- 確認 error.log 記錄 DB 錯誤
- 確認慢查詢被標記
---
## 額外考量
### 連線池大小與 Gunicorn 配置協調
```
Gunicorn: 2 workers × 4 threads = 8 併發
Pool: pool_size=5 + max_overflow=10 = 最多 15 連線/worker
實際最大連線數: 2 workers × 15 = 30 連線
```
**注意**: 確認 Oracle 端 `SESSIONS` 參數足夠(通常 >100 沒問題)
### 應用啟動時驗證連線
```python
def init_db(app):
app.teardown_appcontext(close_db)
# 啟動時驗證連線
try:
with get_engine().connect() as conn:
conn.execute(text("SELECT 1 FROM DUAL"))
logger.info("Database connection verified on startup")
except Exception as e:
logger.error(f"Database connection failed on startup: {e}")
# 不中斷啟動,讓應用可以在 DB 恢復後自動運作
```
### Gunicorn preload_app 考量
```python
# gunicorn.conf.py
preload_app = False # 建議保持 False讓每個 worker 建立自己的 pool
```
若啟用 `preload_app = True`,需在 `post_fork` hook 重新初始化 engine。
### 連線池監控(可觀測性)
```python
from sqlalchemy import event
@event.listens_for(engine, "checkout")
def log_checkout(dbapi_conn, connection_record, connection_proxy):
logger.debug("Connection checked out from pool")
@event.listens_for(engine, "checkin")
def log_checkin(dbapi_conn, connection_record):
logger.debug("Connection returned to pool")
```
### Circuit Breaker 模式Phase 3 可選)
```python
class CircuitBreaker:
def __init__(self, failure_threshold=5, reset_timeout=60):
self.failures = 0
self.last_failure = None
self.threshold = failure_threshold
self.reset_timeout = reset_timeout
def is_open(self):
if self.failures >= self.threshold:
if datetime.now() - self.last_failure < timedelta(seconds=self.reset_timeout):
return True # 熔斷中,直接拒絕
self.failures = 0 # 重置嘗試
return False
def record_failure(self):
self.failures += 1
self.last_failure = datetime.now()
def record_success(self):
self.failures = 0
```
---
## 風險矩陣
| 項目 | 風險 | 優先級 |
|------|------|--------|
| 密碼 URL 編碼 | 低 | 高 |
| print → logging | 低 | 高 |
| 查詢時間統計 | 低 | 高 |
| NullPool → QueuePool | 中 | 高 |
| 啟動時驗證連線 | 低 | 中 |
| Pool 監控事件 | 低 | 低 |
| Circuit Breaker | 中 | 低 |
---
## 實作順序建議
1. **Phase 1**logging + URL 編碼)→ 先上線觀察
2. **Phase 2**(連線池化 + 啟動驗證)→ 根據 Phase 1 的 log 調整參數
3. **Phase 3**Circuit Breaker + 監控)→ 視需求加入

View File

@@ -0,0 +1,56 @@
# DB Connection Stability 提案
## 問題描述
目前資料庫連線架構存在以下問題,導致「看起來像連線不穩」的現象:
### 1. 無連線池化NullPool
- 每次查詢都建立新連線
- 高併發時造成連線風暴
- 容易碰到 Oracle listener/SESSION 限制
### 2. 併發放大效應
- Gunicorn 2 workers × 4 threads
- 前端同頁多 API 並行請求 + 定時刷新
- 短時間大量新連線
### 3. 超時設定問題
- Gunicorn worker timeout 60s
- 前端 fetch 超時 60s
- 查詢稍慢就會被中止,呈現 500/請求中斷
### 4. 密碼 URL 編碼問題
- CONNECTION_STRING 未對密碼做 URL encode
- 若密碼含 `@:/?#` 等特殊字元會被誤解析
### 5. 錯誤紀錄不足
- 連線失敗用 `print()` 而非 logging
- 錯誤不一定進入 error.log
## 解決方案
### Phase 1: 結構化錯誤紀錄(低風險)
- 將所有 `print()` 改為 `logging`
- 記錄 ORA error code
- 加入查詢時間統計
### Phase 2: 連線池化(中風險)
- 改用 SQLAlchemy QueuePool
- 設定合理的 pool_size 和 max_overflow
- 加入 pool_pre_ping 健康檢查
### Phase 3: 重試策略優化(低風險)
- 增加 query-level retry
- 針對特定 ORA 錯誤碼進行重試
## 影響範圍
- `src/mes_dashboard/config/database.py` - 密碼 URL 編碼
- `src/mes_dashboard/core/database.py` - 連線池、logging、重試
- 所有使用 `read_sql_df()` 的 service
## 風險評估
- Phase 1: 低風險,僅增加 logging
- Phase 2: 中風險,需測試連線池行為
- Phase 3: 低風險,僅增加重試邏輯

View File

@@ -0,0 +1,76 @@
## Tasks
### Phase 1: 結構化錯誤紀錄
- [x] **修改 config/database.py - 密碼 URL 編碼**
- 使用 `urllib.parse.quote_plus()` 編碼密碼
- 防止特殊字元造成連線字串解析錯誤
- [x] **修改 core/database.py - 加入 logging**
- 將所有 `print()` 改為 `logging.logger`
- 設定 logger: `mes_dashboard.database`
- 記錄連線成功/失敗事件
- [x] **加入查詢時間統計**
-`read_sql_df()` 加入計時
- 標記 >1s 查詢為 WARNING慢查詢
- 正常查詢記錄為 DEBUG
- [x] **加入 ORA 錯誤碼擷取**
- 從例外訊息擷取 ORA-XXXXX
- 錯誤訊息包含錯誤碼便於追蹤
- [x] **配置 logging handler**
- 在 app.py 加入 `_configure_logging()` 函數
- 設定 `mes_dashboard` logger 輸出至 stderr
- Gunicorn `--capture-output` 會將 stderr 導向 error.log
### Phase 2: 連線池化
- [x] **將 NullPool 改為 QueuePool**
- pool_size=5基本連線數
- max_overflow=10尖峰額外連線
- pool_timeout=30等待連線超時
- pool_recycle=180030 分鐘回收)
- [x] **加入 pool_pre_ping**
- 使用連線前先做健康檢查
- 避免使用已斷線的連線
- [x] **更新 Keep-Alive 機制**
- 改為定期 ping每 5 分鐘)保持連線活躍
- 防止防火牆/NAT 斷開閒置連線
### Phase 2.5: 啟動驗證與監控
- [x] **加入連線池監控事件**
- SQLAlchemy event: checkout / checkin / invalidate / connect
- 記錄 pool 使用狀況至 DEBUG log
- [ ] **應用啟動時驗證連線**
- 在 init_db() 加入 SELECT 1 FROM DUAL 測試
- 失敗時記錄錯誤但不中斷啟動
### Phase 3: 重試策略與熔斷(可選)
- [ ] **加入 query-level retry decorator**
- 針對可重試的 ORA 錯誤碼(如 ORA-03113, ORA-03114
- 最多重試 2 次,間隔 1 秒
- [ ] **Circuit Breaker 模式**
- 連續失敗 5 次後熔斷 60 秒
- 熔斷期間直接返回錯誤,不嘗試連線
### 測試驗證
- [ ] **驗證 logging 輸出**
- 確認錯誤進入 error.log
- 確認慢查詢被記錄
- [ ] **驗證連線池行為**
- 併發請求時連線數符合預期
- 閒置連線正確回收
- [ ] **壓力測試**
- 模擬高併發請求
- 確認無連線洩漏