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:
60
docs/DW_PJ_LOT_V_POWERBI_SQL.txt
Normal file
60
docs/DW_PJ_LOT_V_POWERBI_SQL.txt
Normal 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""
|
||||
2182
frontend_design/Hold_detail.pen
Normal file
2182
frontend_design/Hold_detail.pen
Normal file
File diff suppressed because it is too large
Load Diff
614
frontend_design/WIP_main.pen
Normal file
614
frontend_design/WIP_main.pen
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
348
openspec/changes/archive/2026-01-27-wip-status-filter/design.md
Normal file
348
openspec/changes/archive/2026-01-27-wip-status-filter/design.md
Normal 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. **自動刷新**
|
||||
- 篩選狀態在刷新後保持
|
||||
@@ -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 秒
|
||||
@@ -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` 參數支援外部取消
|
||||
293
openspec/changes/db-connection-stability/design.md
Normal file
293
openspec/changes/db-connection-stability/design.md
Normal 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 + 監控)→ 視需求加入
|
||||
56
openspec/changes/db-connection-stability/proposal.md
Normal file
56
openspec/changes/db-connection-stability/proposal.md
Normal 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: 低風險,僅增加重試邏輯
|
||||
76
openspec/changes/db-connection-stability/tasks.md
Normal file
76
openspec/changes/db-connection-stability/tasks.md
Normal 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=1800(30 分鐘回收)
|
||||
|
||||
- [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
|
||||
- 確認慢查詢被記錄
|
||||
|
||||
- [ ] **驗證連線池行為**
|
||||
- 併發請求時連線數符合預期
|
||||
- 閒置連線正確回收
|
||||
|
||||
- [ ] **壓力測試**
|
||||
- 模擬高併發請求
|
||||
- 確認無連線洩漏
|
||||
Reference in New Issue
Block a user