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