feat: 新增 TMTT 印字與腳型不良分析頁面,修復批次追蹤工具問題
新增 TMTT 不良分析功能: - SQL CTE 查詢合併 LOTWIPHISTORY + LOTREJECTHISTORY + CONTAINER - 服務層:KPI、五維度 Pareto 圖表、每日趨勢、明細表 - API 路由 /api/tmtt-defect/analysis 與 /export - 前端:單欄圖表佈局、ECharts Pareto + 趨勢圖、明細鑽取篩選 - 單元測試與整合測試 (33 tests) 修復批次追蹤工具: - 修復 Decimal * float TypeError (Oracle 回傳 decimal.Decimal) - 改進批次清單查詢:ROW_NUMBER 去重保留最晚下機、帶入產品資訊 - 更新不良統計欄位定義 (TOTAL_DEFECT_QTY) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -49,6 +49,11 @@
|
||||
"route": "/query-tool",
|
||||
"name": "批次追蹤工具",
|
||||
"status": "released"
|
||||
},
|
||||
{
|
||||
"route": "/tmtt-defect",
|
||||
"name": "TMTT印字腳型不良分析",
|
||||
"status": "dev"
|
||||
}
|
||||
],
|
||||
"api_public": true,
|
||||
|
||||
@@ -101,7 +101,8 @@ jdbc:oracle:thin:@${DB_HOST}:${DB_PORT}:${DB_SERVICE}
|
||||
|
||||
**用途**: 容器/批次主檔 - 目前在製容器狀態、數量與流程資訊
|
||||
|
||||
**数据量**: 5,218,406 行
|
||||
**数据量**: 5,218,406 行(文档生成时)
|
||||
**最新查詢**: 5,236,224 行(2026-02-06 直連 Oracle)
|
||||
|
||||
#### 字段列表
|
||||
|
||||
@@ -201,6 +202,51 @@ jdbc:oracle:thin:@${DB_HOST}:${DB_PORT}:${DB_SERVICE}
|
||||
| `DW_C_SCHEDULEDATAID` | 普通索引 | SCHEDULEDATAID |
|
||||
| `DW_MES_CONTAINER_PRODUCTLINENAME` | 普通索引 | PRODUCTLINENAME |
|
||||
|
||||
|
||||
#### 實際資料觀察(2026-02-06)
|
||||
|
||||
**產品/材料欄位完整度與 distinct**
|
||||
|
||||
| 欄位 | 非 NULL 筆數 | 佔比 | distinct 數 | 觀察 |
|
||||
|------|--------------|------|-------------|------|
|
||||
| `PRODUCTNAME` | 5,236,224 | 100.0% | 11,635 | 產品名稱(完整品號) |
|
||||
| `PRODUCTLINENAME` | 3,668,259 | 70.1% | 66 | 產品線/封裝類型 |
|
||||
| `PJ_TYPE` | 3,449,835 | 65.9% | 5,224 | 產品型號 |
|
||||
| `PJ_FUNCTION` | 3,449,835 | 65.9% | 19 | 產品功能分類 |
|
||||
| `PJ_BOP` | 3,427,782 | 65.5% | 125 | BOP 代碼 |
|
||||
| `PRODUCTBOMBASEID` | 3,534,058 | 67.5% | 10,581 | 產品 BOM 基準 |
|
||||
| `PRODUCTDESC` | 2,005,403 | 38.3% | 5,613 | 產品描述 |
|
||||
| `LEADFRAMENAME` | 3,427,527 | 65.5% | 102 | Leadframe/料號 |
|
||||
| `LEADFRAMEOPTION` | 3,427,017 | 65.4% | 33 | Leadframe option |
|
||||
| `LEADFRAMEDESC` | 3,427,527 | 65.5% | 97 | Leadframe 描述 |
|
||||
| `PJ_PRODUCEREGION` | 2,350,350 | 44.9% | 5 | 生產區域 |
|
||||
|
||||
**拆併批欄位觀察**
|
||||
|
||||
| 指標 | 筆數 | 佔比 | 解讀 |
|
||||
|------|------|------|------|
|
||||
| `SPLITFROMID` 非 NULL | 3,697,293 | 70.6% | 子批可回溯母批 |
|
||||
| `SPLITCOUNT` > 0 | 1,855,733 | 35.4% | 母批拆批數 |
|
||||
| `ORIGINALCONTAINERID = CONTAINERID` | 1,538,931 | 29.4% | 原始批 |
|
||||
| `ORIGINALCONTAINERID ≠ CONTAINERID` | 3,697,293 | 70.6% | 拆批衍生批 |
|
||||
| `FUTURECOMBINEPARENTLOTID` 非 NULL | 5 | ~0% | 少量併批規劃資料 |
|
||||
| `PARENTCONTAINERID` 非 NULL | 0 | 0% | 目前資料未使用 |
|
||||
|
||||
**拆批追溯建議**
|
||||
|
||||
`SPLITFROMID` 與 `SPLITCOUNT` 在資料中呈現「子批/母批」互補特性:子批多以 `SPLITFROMID` 指向母批,而母批以 `SPLITCOUNT` 顯示拆出數量。要追溯拆批關係,可用 `SPLITFROMID -> CONTAINERID` 自連結。
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
c.CONTAINERNAME AS CHILD_CONTAINER,
|
||||
p.CONTAINERNAME AS PARENT_CONTAINER,
|
||||
c.SPLITFROMID
|
||||
FROM DWH.DW_MES_CONTAINER c
|
||||
LEFT JOIN DWH.DW_MES_CONTAINER p ON c.SPLITFROMID = p.CONTAINERID
|
||||
WHERE c.SPLITFROMID IS NOT NULL
|
||||
AND ROWNUM <= 20;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### DW_MES_EQUIPMENTSTATUS_WIP_V
|
||||
|
||||
@@ -63,7 +63,8 @@ sql/
|
||||
│ ├── by_status.sql
|
||||
│ └── detail.sql
|
||||
├── resource_history/ # 歷史 SQL
|
||||
└── job_query/ # 維修工單 SQL
|
||||
├── job_query/ # 維修工單 SQL
|
||||
└── tmtt_defect/ # TMTT 不良分析 SQL
|
||||
```
|
||||
|
||||
### SQLLoader 使用方式
|
||||
@@ -507,6 +508,7 @@ logs = store.query_logs(
|
||||
| resource_history | `/api/resource-history` | `resource_history_routes.py` |
|
||||
| job_query | `/api/job-query` | `job_query_routes.py` |
|
||||
| query_tool | `/api/query-tool` | `query_tool_routes.py` |
|
||||
| tmtt_defect | `/api/tmtt-defect` | `tmtt_defect_routes.py` |
|
||||
| admin | `/admin` | `admin_routes.py` |
|
||||
| auth | `/admin` | `auth_routes.py` |
|
||||
| health | `/` | `health_routes.py` |
|
||||
@@ -1063,6 +1065,83 @@ from mes_dashboard.services.query_tool_service import (
|
||||
|
||||
---
|
||||
|
||||
## 24. TMTT 印字腳型不良分析
|
||||
|
||||
### 位置
|
||||
- 服務: `mes_dashboard.services.tmtt_defect_service`
|
||||
- 路由: `mes_dashboard.routes.tmtt_defect_routes`
|
||||
- SQL: `mes_dashboard/sql/tmtt_defect/`
|
||||
- 頁面: `mes_dashboard/templates/tmtt_defect.html`
|
||||
|
||||
### 功能概述
|
||||
分析 TMTT(測試)站的印字與腳型不良率,按多個維度產生 Pareto 圖表,支援圖表鑽取至明細表,支援 CSV 匯出。
|
||||
|
||||
### 不良率計算
|
||||
不良率依 LOSSREASONNAME **分開計算**:
|
||||
- **印字不良率** = SUM(REJECTQTY WHERE LOSSREASONNAME='277_印字不良') / SUM(TRACKINQTY) × 100%
|
||||
- **腳型不良率** = SUM(REJECTQTY WHERE LOSSREASONNAME='276_腳型不良') / SUM(TRACKINQTY) × 100%
|
||||
- 分母 (INPUT) 需以 CONTAINERID 去重(同 LOT 可能有多筆不良行)
|
||||
|
||||
### 資料來源
|
||||
|
||||
| 資料表 | 用途 | 關鍵欄位 |
|
||||
|--------|------|---------|
|
||||
| DW_MES_LOTWIPHISTORY | TMTT 站投入記錄 | CONTAINERID, TRACKINQTY, EQUIPMENTID, WORKCENTERNAME |
|
||||
| DW_MES_LOTWIPHISTORY | MOLD 站設備回串 | CONTAINERID, EQUIPMENTNAME, TRACKINTIMESTAMP |
|
||||
| DW_MES_LOTREJECTHISTORY | 不良記錄 | CONTAINERID, LOSSREASONNAME, 不良數=SUM(REJECTQTY+STANDBYQTY+QTYTOPROCESS+INPROCESSQTY+PROCESSEDQTY) |
|
||||
| DW_MES_CONTAINER | 產品資訊 | CONTAINERID, PJ_TYPE, PRODUCTLINENAME |
|
||||
| DW_MES_WIP | WORKFLOW 名稱 | CONTAINERID, WORKFLOWNAME (排除 PRODUCTLINENAME='點測') |
|
||||
|
||||
### SQL 查詢結構 (`sql/tmtt_defect/base_data.sql`)
|
||||
單一 CTE 查詢,Python 層聚合:
|
||||
```
|
||||
tmtt_records → LOTWIPHISTORY 篩選 TMTT 站 (WORKCENTERNAME LIKE '%TMTT%' OR '%測試%')
|
||||
tmtt_deduped → ROW_NUMBER 去重 (同 CONTAINERID 取最晚 TRACKOUTTIMESTAMP)
|
||||
tmtt_rejects → LOTREJECTHISTORY 篩選 '276_腳型不良', '277_印字不良',不良數=5欄位加總,按 CONTAINERID+LOSSREASONNAME 聚合
|
||||
mold_records → LOTWIPHISTORY 篩選成型站,ROW_NUMBER 取最早上機
|
||||
mold_deduped → 取 mold_rn=1
|
||||
product_info → CONTAINER 取 PJ_TYPE, PRODUCTLINENAME
|
||||
workflow_info → WIP 取 WORKFLOWNAME (排除 PRODUCTLINENAME='點測',fallback SPECNAME)
|
||||
→ LEFT JOIN 全部
|
||||
```
|
||||
|
||||
### API 端點
|
||||
|
||||
| 端點 | 方法 | 參數 | 說明 |
|
||||
|------|------|------|------|
|
||||
| `/api/tmtt-defect/analysis` | GET | `start_date`, `end_date` | KPI + 5 維度圖表 + 明細 |
|
||||
| `/api/tmtt-defect/export` | GET | `start_date`, `end_date` | CSV 串流匯出 |
|
||||
|
||||
### 快取策略
|
||||
- Redis 快取,TTL 300 秒(5 分鐘)
|
||||
- Cache key 包含 start_date + end_date
|
||||
- 最大查詢範圍: 180 天
|
||||
|
||||
### Pareto 圖表維度
|
||||
1. WORKFLOW (DW_MES_WIP.WORKFLOWNAME,fallback SPECNAME)
|
||||
2. PACKAGE (PRODUCTLINENAME)
|
||||
3. TYPE (PJ_TYPE)
|
||||
4. TMTT 機台 (EQUIPMENTNAME)
|
||||
5. MOLD 機台 (MOLD_EQUIPMENTNAME)
|
||||
|
||||
每個維度按總不良數降序排列,含累積百分比。NULL 值歸為「(未知)」。
|
||||
|
||||
### 服務函數
|
||||
|
||||
```python
|
||||
from mes_dashboard.services.tmtt_defect_service import (
|
||||
query_tmtt_defect_analysis, # 主查詢入口
|
||||
export_csv, # CSV 串流匯出
|
||||
)
|
||||
```
|
||||
|
||||
### 注意事項
|
||||
- WORKFLOW 欄位來自 DW_MES_WIP.WORKFLOWNAME(排除 PRODUCTLINENAME='點測'),若無則 fallback 到 SPECNAME
|
||||
- MOLD 設備取同 LOT 最早上機者(ROW_NUMBER PARTITION BY CONTAINERID ORDER BY TRACKINTIMESTAMP ASC)
|
||||
- LOTREJECTHISTORY 只有 EQUIPMENTNAME,TMTT 機台識別以 LOTWIPHISTORY 的 EQUIPMENTID 為主
|
||||
|
||||
---
|
||||
|
||||
## 參考檔案索引
|
||||
|
||||
| 功能 | 檔案位置 |
|
||||
@@ -1083,5 +1162,7 @@ from mes_dashboard.services.query_tool_service import (
|
||||
| Filter 快取 | `src/mes_dashboard/services/filter_cache.py` |
|
||||
| 資源快取 | `src/mes_dashboard/services/resource_cache.py` |
|
||||
| 批次追蹤服務 | `src/mes_dashboard/services/query_tool_service.py` |
|
||||
| TMTT 不良分析服務 | `src/mes_dashboard/services/tmtt_defect_service.py` |
|
||||
| TMTT 不良分析 SQL | `src/mes_dashboard/sql/tmtt_defect/base_data.sql` |
|
||||
| API 客戶端 | `src/mes_dashboard/static/js/mes-api.js` |
|
||||
| Toast 系統 | `src/mes_dashboard/static/js/toast.js` |
|
||||
|
||||
@@ -216,6 +216,11 @@ def create_app(config_name: str | None = None) -> Flask:
|
||||
"""Resource history analysis page."""
|
||||
return render_template('resource_history.html')
|
||||
|
||||
@app.route('/tmtt-defect')
|
||||
def tmtt_defect_page():
|
||||
"""TMTT printing & lead form defect analysis page."""
|
||||
return render_template('tmtt_defect.html')
|
||||
|
||||
# ========================================================
|
||||
# Table Query APIs (for table_data_viewer)
|
||||
# ========================================================
|
||||
|
||||
@@ -14,6 +14,7 @@ from .admin_routes import admin_bp
|
||||
from .resource_history_routes import resource_history_bp
|
||||
from .job_query_routes import job_query_bp
|
||||
from .query_tool_routes import query_tool_bp
|
||||
from .tmtt_defect_routes import tmtt_defect_bp
|
||||
|
||||
|
||||
def register_routes(app) -> None:
|
||||
@@ -26,6 +27,7 @@ def register_routes(app) -> None:
|
||||
app.register_blueprint(resource_history_bp)
|
||||
app.register_blueprint(job_query_bp)
|
||||
app.register_blueprint(query_tool_bp)
|
||||
app.register_blueprint(tmtt_defect_bp)
|
||||
|
||||
__all__ = [
|
||||
'wip_bp',
|
||||
@@ -38,5 +40,6 @@ __all__ = [
|
||||
'resource_history_bp',
|
||||
'job_query_bp',
|
||||
'query_tool_bp',
|
||||
'tmtt_defect_bp',
|
||||
'register_routes',
|
||||
]
|
||||
|
||||
82
src/mes_dashboard/routes/tmtt_defect_routes.py
Normal file
82
src/mes_dashboard/routes/tmtt_defect_routes.py
Normal file
@@ -0,0 +1,82 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""TMTT Defect Analysis API routes.
|
||||
|
||||
Contains Flask Blueprint for TMTT printing & lead form defect analysis endpoints.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
from mes_dashboard.services.tmtt_defect_service import (
|
||||
query_tmtt_defect_analysis,
|
||||
export_csv,
|
||||
)
|
||||
|
||||
# Create Blueprint
|
||||
tmtt_defect_bp = Blueprint(
|
||||
'tmtt_defect',
|
||||
__name__,
|
||||
url_prefix='/api/tmtt-defect'
|
||||
)
|
||||
|
||||
|
||||
@tmtt_defect_bp.route('/analysis', methods=['GET'])
|
||||
def api_tmtt_defect_analysis():
|
||||
"""API: Get TMTT defect analysis data (KPI + charts + detail).
|
||||
|
||||
Query Parameters:
|
||||
start_date: Start date (YYYY-MM-DD), required
|
||||
end_date: End date (YYYY-MM-DD), required
|
||||
|
||||
Returns:
|
||||
JSON with kpi, charts, detail sections.
|
||||
"""
|
||||
start_date = request.args.get('start_date')
|
||||
end_date = request.args.get('end_date')
|
||||
|
||||
if not start_date or not end_date:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': '必須提供 start_date 和 end_date 參數'
|
||||
}), 400
|
||||
|
||||
result = query_tmtt_defect_analysis(start_date, end_date)
|
||||
|
||||
if result is None:
|
||||
return jsonify({'success': False, 'error': '查詢失敗,請稍後再試'}), 500
|
||||
|
||||
if 'error' in result:
|
||||
return jsonify({'success': False, 'error': result['error']}), 400
|
||||
|
||||
return jsonify({'success': True, 'data': result})
|
||||
|
||||
|
||||
@tmtt_defect_bp.route('/export', methods=['GET'])
|
||||
def api_tmtt_defect_export():
|
||||
"""API: Export TMTT defect detail data as CSV.
|
||||
|
||||
Query Parameters:
|
||||
start_date: Start date (YYYY-MM-DD), required
|
||||
end_date: End date (YYYY-MM-DD), required
|
||||
|
||||
Returns:
|
||||
CSV file download.
|
||||
"""
|
||||
start_date = request.args.get('start_date')
|
||||
end_date = request.args.get('end_date')
|
||||
|
||||
if not start_date or not end_date:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': '必須提供 start_date 和 end_date 參數'
|
||||
}), 400
|
||||
|
||||
filename = f"tmtt_defect_{start_date}_to_{end_date}.csv"
|
||||
|
||||
return Response(
|
||||
export_csv(start_date, end_date),
|
||||
mimetype='text/csv',
|
||||
headers={
|
||||
'Content-Disposition': f'attachment; filename={filename}',
|
||||
'Content-Type': 'text/csv; charset=utf-8-sig'
|
||||
}
|
||||
)
|
||||
@@ -19,6 +19,7 @@ import csv
|
||||
import io
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Any, Dict, List, Optional, Generator
|
||||
|
||||
import pandas as pd
|
||||
@@ -189,6 +190,8 @@ def _df_to_records(df: pd.DataFrame) -> List[Dict[str, Any]]:
|
||||
record[col] = value.strftime('%Y-%m-%d %H:%M:%S')
|
||||
elif isinstance(value, pd.Timestamp):
|
||||
record[col] = value.strftime('%Y-%m-%d %H:%M:%S')
|
||||
elif isinstance(value, Decimal):
|
||||
record[col] = float(value)
|
||||
else:
|
||||
record[col] = value
|
||||
data.append(record)
|
||||
|
||||
529
src/mes_dashboard/services/tmtt_defect_service.py
Normal file
529
src/mes_dashboard/services/tmtt_defect_service.py
Normal file
@@ -0,0 +1,529 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""TMTT Defect Analysis Service.
|
||||
|
||||
Provides functions for analyzing printing (印字) and lead form (腳型) defects
|
||||
at TMTT stations, with MOLD equipment correlation and multi-dimension Pareto analysis.
|
||||
|
||||
Defect rates are calculated separately by LOSSREASONNAME:
|
||||
- Print defect rate = 277_印字不良 / TMTT INPUT
|
||||
- Lead defect rate = 276_腳型不良 / TMTT INPUT
|
||||
"""
|
||||
|
||||
import csv
|
||||
import io
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Dict, List, Any, Generator
|
||||
|
||||
import math
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from mes_dashboard.core.database import read_sql_df
|
||||
from mes_dashboard.core.cache import cache_get, cache_set, make_cache_key
|
||||
from mes_dashboard.sql import SQLLoader
|
||||
|
||||
logger = logging.getLogger('mes_dashboard.tmtt_defect')
|
||||
|
||||
# Constants
|
||||
MAX_QUERY_DAYS = 180
|
||||
CACHE_TTL = 300 # 5 minutes
|
||||
|
||||
PRINT_DEFECT = '277_印字不良'
|
||||
LEAD_DEFECT = '276_腳型不良'
|
||||
|
||||
# Dimension column mapping for chart aggregation
|
||||
DIMENSION_MAP = {
|
||||
'by_workflow': 'WORKFLOW',
|
||||
'by_package': 'PRODUCTLINENAME',
|
||||
'by_type': 'PJ_TYPE',
|
||||
'by_tmtt_machine': 'TMTT_EQUIPMENTNAME',
|
||||
'by_mold_machine': 'MOLD_EQUIPMENTNAME',
|
||||
}
|
||||
|
||||
# CSV export column config
|
||||
CSV_COLUMNS = [
|
||||
('CONTAINERNAME', 'LOT ID'),
|
||||
('PJ_TYPE', 'TYPE'),
|
||||
('PRODUCTLINENAME', 'PACKAGE'),
|
||||
('WORKFLOW', 'WORKFLOW'),
|
||||
('FINISHEDRUNCARD', '完工流水碼'),
|
||||
('TMTT_EQUIPMENTNAME', 'TMTT設備'),
|
||||
('MOLD_EQUIPMENTNAME', 'MOLD設備'),
|
||||
('INPUT_QTY', '投入數'),
|
||||
('PRINT_DEFECT_QTY', '印字不良數'),
|
||||
('PRINT_DEFECT_RATE', '印字不良率(%)'),
|
||||
('LEAD_DEFECT_QTY', '腳型不良數'),
|
||||
('LEAD_DEFECT_RATE', '腳型不良率(%)'),
|
||||
]
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Public API
|
||||
# ============================================================
|
||||
|
||||
def query_tmtt_defect_analysis(
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Main entry point for TMTT defect analysis.
|
||||
|
||||
Args:
|
||||
start_date: Start date (YYYY-MM-DD)
|
||||
end_date: End date (YYYY-MM-DD)
|
||||
|
||||
Returns:
|
||||
Dict with kpi, charts, detail sections, or dict with 'error' key.
|
||||
"""
|
||||
# Validate dates
|
||||
error = _validate_date_range(start_date, end_date)
|
||||
if error:
|
||||
return {'error': error}
|
||||
|
||||
# Check cache
|
||||
cache_key = make_cache_key(
|
||||
"tmtt_defect_analysis",
|
||||
filters={'start_date': start_date, 'end_date': end_date},
|
||||
)
|
||||
cached = cache_get(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
# Fetch data
|
||||
df = _fetch_base_data(start_date, end_date)
|
||||
if df is None:
|
||||
return None
|
||||
|
||||
# Build response
|
||||
result = {
|
||||
'kpi': _build_kpi(df),
|
||||
'charts': _build_all_charts(df),
|
||||
'daily_trend': _build_daily_trend(df),
|
||||
'detail': _build_detail_table(df),
|
||||
}
|
||||
|
||||
cache_set(cache_key, result, ttl=CACHE_TTL)
|
||||
return result
|
||||
|
||||
|
||||
def export_csv(
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
) -> Generator[str, None, None]:
|
||||
"""Stream CSV export of detail data.
|
||||
|
||||
Args:
|
||||
start_date: Start date (YYYY-MM-DD)
|
||||
end_date: End date (YYYY-MM-DD)
|
||||
|
||||
Yields:
|
||||
CSV lines as strings.
|
||||
"""
|
||||
df = _fetch_base_data(start_date, end_date)
|
||||
|
||||
# BOM for Excel UTF-8 compatibility
|
||||
yield '\ufeff'
|
||||
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
|
||||
# Header row
|
||||
writer.writerow([label for _, label in CSV_COLUMNS])
|
||||
yield output.getvalue()
|
||||
output.seek(0)
|
||||
output.truncate(0)
|
||||
|
||||
if df is None or df.empty:
|
||||
return
|
||||
|
||||
detail = _build_detail_table(df)
|
||||
for row in detail:
|
||||
writer.writerow([row.get(col, '') for col, _ in CSV_COLUMNS])
|
||||
yield output.getvalue()
|
||||
output.seek(0)
|
||||
output.truncate(0)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Helpers
|
||||
# ============================================================
|
||||
|
||||
def _safe_str(v, default=''):
|
||||
"""Return a JSON-safe string. Converts NaN/None to default."""
|
||||
if v is None or (isinstance(v, float) and math.isnan(v)):
|
||||
return default
|
||||
try:
|
||||
if pd.isna(v):
|
||||
return default
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return str(v)
|
||||
|
||||
|
||||
def _safe_float(v, default=0.0):
|
||||
"""Return a JSON-safe float. Converts NaN/None to default."""
|
||||
if v is None:
|
||||
return default
|
||||
try:
|
||||
f = float(v)
|
||||
if math.isnan(f) or math.isinf(f):
|
||||
return default
|
||||
return f
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _safe_int(v, default=0):
|
||||
"""Return a JSON-safe int. Converts NaN/None to default."""
|
||||
return int(_safe_float(v, float(default)))
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Internal Functions
|
||||
# ============================================================
|
||||
|
||||
def _validate_date_range(start_date: str, end_date: str) -> Optional[str]:
|
||||
"""Validate date range parameters.
|
||||
|
||||
Returns:
|
||||
Error message string, or None if valid.
|
||||
"""
|
||||
try:
|
||||
start = datetime.strptime(start_date, '%Y-%m-%d')
|
||||
end = datetime.strptime(end_date, '%Y-%m-%d')
|
||||
except (ValueError, TypeError):
|
||||
return '日期格式無效,請使用 YYYY-MM-DD'
|
||||
|
||||
if start > end:
|
||||
return '起始日期不能晚於結束日期'
|
||||
|
||||
if (end - start).days > MAX_QUERY_DAYS:
|
||||
return f'查詢範圍不能超過 {MAX_QUERY_DAYS} 天'
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _fetch_base_data(start_date: str, end_date: str) -> Optional[pd.DataFrame]:
|
||||
"""Execute base_data.sql and return raw DataFrame.
|
||||
|
||||
Args:
|
||||
start_date: Start date (YYYY-MM-DD)
|
||||
end_date: End date (YYYY-MM-DD)
|
||||
|
||||
Returns:
|
||||
DataFrame or None on error.
|
||||
"""
|
||||
try:
|
||||
sql = SQLLoader.load("tmtt_defect/base_data")
|
||||
params = {
|
||||
'start_date': start_date,
|
||||
'end_date': end_date,
|
||||
}
|
||||
df = read_sql_df(sql, params)
|
||||
if df is None:
|
||||
logger.error("TMTT defect base query returned None")
|
||||
return None
|
||||
logger.info(
|
||||
f"TMTT defect query: {len(df)} rows, "
|
||||
f"{df['CONTAINERID'].nunique() if not df.empty else 0} unique lots"
|
||||
)
|
||||
return df
|
||||
except Exception as exc:
|
||||
logger.error(f"TMTT defect query failed: {exc}", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def _build_kpi(df: pd.DataFrame) -> Dict[str, Any]:
|
||||
"""Build KPI summary from base data.
|
||||
|
||||
Defect rates are calculated separately by LOSSREASONNAME.
|
||||
INPUT is deduplicated by CONTAINERID (a LOT may have multiple defect rows).
|
||||
|
||||
Args:
|
||||
df: Base data DataFrame.
|
||||
|
||||
Returns:
|
||||
KPI dict with total_input, lot_count, print/lead defect qty and rate.
|
||||
"""
|
||||
if df.empty:
|
||||
return {
|
||||
'total_input': 0,
|
||||
'lot_count': 0,
|
||||
'print_defect_qty': 0,
|
||||
'print_defect_rate': 0.0,
|
||||
'lead_defect_qty': 0,
|
||||
'lead_defect_rate': 0.0,
|
||||
}
|
||||
|
||||
# Deduplicate for INPUT: one TRACKINQTY per unique CONTAINERID
|
||||
unique_lots = df.drop_duplicates(subset=['CONTAINERID'])
|
||||
total_input = int(unique_lots['TRACKINQTY'].sum())
|
||||
lot_count = len(unique_lots)
|
||||
|
||||
# Defect totals by type
|
||||
defect_rows = df[df['REJECTQTY'] > 0]
|
||||
print_qty = int(
|
||||
defect_rows.loc[
|
||||
defect_rows['LOSSREASONNAME'] == PRINT_DEFECT, 'REJECTQTY'
|
||||
].sum()
|
||||
)
|
||||
lead_qty = int(
|
||||
defect_rows.loc[
|
||||
defect_rows['LOSSREASONNAME'] == LEAD_DEFECT, 'REJECTQTY'
|
||||
].sum()
|
||||
)
|
||||
|
||||
return {
|
||||
'total_input': total_input,
|
||||
'lot_count': lot_count,
|
||||
'print_defect_qty': print_qty,
|
||||
'print_defect_rate': round(print_qty / total_input * 100, 4) if total_input else 0.0,
|
||||
'lead_defect_qty': lead_qty,
|
||||
'lead_defect_rate': round(lead_qty / total_input * 100, 4) if total_input else 0.0,
|
||||
}
|
||||
|
||||
|
||||
def _build_chart_data(
|
||||
df: pd.DataFrame,
|
||||
dimension: str,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Build Pareto chart data for a given dimension.
|
||||
|
||||
Each item includes separate print and lead defect quantities/rates.
|
||||
|
||||
Args:
|
||||
df: Base data DataFrame.
|
||||
dimension: Column name to group by.
|
||||
|
||||
Returns:
|
||||
List of dicts sorted by total defect qty DESC, with cumulative_pct.
|
||||
"""
|
||||
if df.empty:
|
||||
return []
|
||||
|
||||
# Fill NaN dimension values
|
||||
work_df = df.copy()
|
||||
work_df[dimension] = work_df[dimension].fillna('(未知)')
|
||||
|
||||
# INPUT per dimension (deduplicated by CONTAINERID within each group)
|
||||
input_by_dim = (
|
||||
work_df.drop_duplicates(subset=['CONTAINERID', dimension])
|
||||
.groupby(dimension)['TRACKINQTY']
|
||||
.sum()
|
||||
)
|
||||
|
||||
# Defect qty per dimension per type
|
||||
defect_rows = work_df[work_df['REJECTQTY'] > 0]
|
||||
|
||||
print_by_dim = (
|
||||
defect_rows[defect_rows['LOSSREASONNAME'] == PRINT_DEFECT]
|
||||
.groupby(dimension)['REJECTQTY']
|
||||
.sum()
|
||||
)
|
||||
lead_by_dim = (
|
||||
defect_rows[defect_rows['LOSSREASONNAME'] == LEAD_DEFECT]
|
||||
.groupby(dimension)['REJECTQTY']
|
||||
.sum()
|
||||
)
|
||||
|
||||
# Combine
|
||||
combined = pd.DataFrame({
|
||||
'input_qty': input_by_dim,
|
||||
'print_defect_qty': print_by_dim,
|
||||
'lead_defect_qty': lead_by_dim,
|
||||
}).fillna(0).astype({'print_defect_qty': int, 'lead_defect_qty': int, 'input_qty': int})
|
||||
|
||||
combined['total_defect_qty'] = combined['print_defect_qty'] + combined['lead_defect_qty']
|
||||
combined = combined.sort_values('total_defect_qty', ascending=False)
|
||||
|
||||
# Cumulative percentage
|
||||
total_defects = combined['total_defect_qty'].sum()
|
||||
if total_defects > 0:
|
||||
combined['cumulative_pct'] = (
|
||||
combined['total_defect_qty'].cumsum() / total_defects * 100
|
||||
).round(2)
|
||||
else:
|
||||
combined['cumulative_pct'] = 0.0
|
||||
|
||||
# Defect rates
|
||||
combined['print_defect_rate'] = (
|
||||
combined['print_defect_qty'] / combined['input_qty'] * 100
|
||||
).round(4).where(combined['input_qty'] > 0, 0.0)
|
||||
combined['lead_defect_rate'] = (
|
||||
combined['lead_defect_qty'] / combined['input_qty'] * 100
|
||||
).round(4).where(combined['input_qty'] > 0, 0.0)
|
||||
|
||||
result = []
|
||||
for name, row in combined.iterrows():
|
||||
result.append({
|
||||
'name': _safe_str(name),
|
||||
'input_qty': _safe_int(row['input_qty']),
|
||||
'print_defect_qty': _safe_int(row['print_defect_qty']),
|
||||
'print_defect_rate': _safe_float(row['print_defect_rate']),
|
||||
'lead_defect_qty': _safe_int(row['lead_defect_qty']),
|
||||
'lead_defect_rate': _safe_float(row['lead_defect_rate']),
|
||||
'total_defect_qty': _safe_int(row['total_defect_qty']),
|
||||
'cumulative_pct': _safe_float(row['cumulative_pct']),
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _build_all_charts(df: pd.DataFrame) -> Dict[str, List[Dict]]:
|
||||
"""Build chart data for all 5 dimensions.
|
||||
|
||||
Args:
|
||||
df: Base data DataFrame.
|
||||
|
||||
Returns:
|
||||
Dict mapping chart key to Pareto data list.
|
||||
"""
|
||||
return {
|
||||
key: _build_chart_data(df, col)
|
||||
for key, col in DIMENSION_MAP.items()
|
||||
}
|
||||
|
||||
|
||||
def _build_daily_trend(df: pd.DataFrame) -> List[Dict[str, Any]]:
|
||||
"""Build daily defect rate trend data.
|
||||
|
||||
Groups by TRACKINTIMESTAMP date, calculates daily print/lead defect rates.
|
||||
|
||||
Args:
|
||||
df: Base data DataFrame.
|
||||
|
||||
Returns:
|
||||
List of dicts sorted by date ASC, each with date, input_qty,
|
||||
print/lead defect qty and rate.
|
||||
"""
|
||||
if df.empty:
|
||||
return []
|
||||
|
||||
work_df = df.copy()
|
||||
work_df['DATE'] = pd.to_datetime(work_df['TRACKINTIMESTAMP']).dt.strftime('%Y-%m-%d')
|
||||
|
||||
# Daily INPUT (deduplicated by CONTAINERID per date)
|
||||
daily_input = (
|
||||
work_df.drop_duplicates(subset=['CONTAINERID', 'DATE'])
|
||||
.groupby('DATE')['TRACKINQTY']
|
||||
.sum()
|
||||
)
|
||||
|
||||
# Daily defects by type
|
||||
defect_rows = work_df[work_df['REJECTQTY'] > 0]
|
||||
|
||||
daily_print = (
|
||||
defect_rows[defect_rows['LOSSREASONNAME'] == PRINT_DEFECT]
|
||||
.groupby('DATE')['REJECTQTY']
|
||||
.sum()
|
||||
)
|
||||
daily_lead = (
|
||||
defect_rows[defect_rows['LOSSREASONNAME'] == LEAD_DEFECT]
|
||||
.groupby('DATE')['REJECTQTY']
|
||||
.sum()
|
||||
)
|
||||
|
||||
combined = pd.DataFrame({
|
||||
'input_qty': daily_input,
|
||||
'print_defect_qty': daily_print,
|
||||
'lead_defect_qty': daily_lead,
|
||||
}).fillna(0).astype({'print_defect_qty': int, 'lead_defect_qty': int, 'input_qty': int})
|
||||
|
||||
combined['print_defect_rate'] = (
|
||||
combined['print_defect_qty'] / combined['input_qty'] * 100
|
||||
).round(4).where(combined['input_qty'] > 0, 0.0)
|
||||
combined['lead_defect_rate'] = (
|
||||
combined['lead_defect_qty'] / combined['input_qty'] * 100
|
||||
).round(4).where(combined['input_qty'] > 0, 0.0)
|
||||
|
||||
combined = combined.sort_index()
|
||||
|
||||
result = []
|
||||
for date, row in combined.iterrows():
|
||||
result.append({
|
||||
'date': str(date),
|
||||
'input_qty': _safe_int(row['input_qty']),
|
||||
'print_defect_qty': _safe_int(row['print_defect_qty']),
|
||||
'print_defect_rate': _safe_float(row['print_defect_rate']),
|
||||
'lead_defect_qty': _safe_int(row['lead_defect_qty']),
|
||||
'lead_defect_rate': _safe_float(row['lead_defect_rate']),
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _build_detail_table(df: pd.DataFrame) -> List[Dict[str, Any]]:
|
||||
"""Build detail table rows, one per LOT.
|
||||
|
||||
Aggregates defect quantities per LOT across defect types.
|
||||
|
||||
Args:
|
||||
df: Base data DataFrame.
|
||||
|
||||
Returns:
|
||||
List of dicts, one per LOT.
|
||||
"""
|
||||
if df.empty:
|
||||
return []
|
||||
|
||||
# Pivot defects per LOT
|
||||
lot_group_cols = [
|
||||
'CONTAINERID', 'CONTAINERNAME', 'PJ_TYPE', 'PRODUCTLINENAME',
|
||||
'WORKFLOW', 'FINISHEDRUNCARD', 'TMTT_EQUIPMENTNAME',
|
||||
'MOLD_EQUIPMENTNAME', 'TRACKINQTY',
|
||||
]
|
||||
|
||||
# Get unique LOT info (first occurrence)
|
||||
lots = df.drop_duplicates(subset=['CONTAINERID'])[lot_group_cols].copy()
|
||||
|
||||
# Aggregate defects per LOT per type
|
||||
defect_rows = df[df['REJECTQTY'] > 0]
|
||||
|
||||
print_defects = (
|
||||
defect_rows[defect_rows['LOSSREASONNAME'] == PRINT_DEFECT]
|
||||
.groupby('CONTAINERID')['REJECTQTY']
|
||||
.sum()
|
||||
.rename('PRINT_DEFECT_QTY')
|
||||
)
|
||||
lead_defects = (
|
||||
defect_rows[defect_rows['LOSSREASONNAME'] == LEAD_DEFECT]
|
||||
.groupby('CONTAINERID')['REJECTQTY']
|
||||
.sum()
|
||||
.rename('LEAD_DEFECT_QTY')
|
||||
)
|
||||
|
||||
lots = lots.set_index('CONTAINERID')
|
||||
lots = lots.join(print_defects, how='left')
|
||||
lots = lots.join(lead_defects, how='left')
|
||||
lots['PRINT_DEFECT_QTY'] = lots['PRINT_DEFECT_QTY'].fillna(0).astype(int)
|
||||
lots['LEAD_DEFECT_QTY'] = lots['LEAD_DEFECT_QTY'].fillna(0).astype(int)
|
||||
|
||||
# Calculate rates
|
||||
lots['INPUT_QTY'] = lots['TRACKINQTY'].astype(int)
|
||||
lots['PRINT_DEFECT_RATE'] = (
|
||||
lots['PRINT_DEFECT_QTY'] / lots['INPUT_QTY'] * 100
|
||||
).round(4).where(lots['INPUT_QTY'] > 0, 0.0)
|
||||
lots['LEAD_DEFECT_RATE'] = (
|
||||
lots['LEAD_DEFECT_QTY'] / lots['INPUT_QTY'] * 100
|
||||
).round(4).where(lots['INPUT_QTY'] > 0, 0.0)
|
||||
|
||||
# Convert to list of dicts
|
||||
lots = lots.reset_index()
|
||||
result = []
|
||||
for _, row in lots.iterrows():
|
||||
result.append({
|
||||
'CONTAINERNAME': _safe_str(row.get('CONTAINERNAME')),
|
||||
'PJ_TYPE': _safe_str(row.get('PJ_TYPE')),
|
||||
'PRODUCTLINENAME': _safe_str(row.get('PRODUCTLINENAME')),
|
||||
'WORKFLOW': _safe_str(row.get('WORKFLOW')),
|
||||
'FINISHEDRUNCARD': _safe_str(row.get('FINISHEDRUNCARD')),
|
||||
'TMTT_EQUIPMENTNAME': _safe_str(row.get('TMTT_EQUIPMENTNAME')),
|
||||
'MOLD_EQUIPMENTNAME': _safe_str(row.get('MOLD_EQUIPMENTNAME')),
|
||||
'INPUT_QTY': _safe_int(row.get('INPUT_QTY')),
|
||||
'PRINT_DEFECT_QTY': _safe_int(row.get('PRINT_DEFECT_QTY')),
|
||||
'PRINT_DEFECT_RATE': _safe_float(row.get('PRINT_DEFECT_RATE')),
|
||||
'LEAD_DEFECT_QTY': _safe_int(row.get('LEAD_DEFECT_QTY')),
|
||||
'LEAD_DEFECT_RATE': _safe_float(row.get('LEAD_DEFECT_RATE')),
|
||||
})
|
||||
|
||||
return result
|
||||
@@ -9,23 +9,56 @@
|
||||
-- EQUIPMENT_FILTER - Equipment filter condition (on EQUIPMENTID)
|
||||
--
|
||||
-- Note: Uses EQUIPMENTID/EQUIPMENTNAME (NOT RESOURCEID/RESOURCENAME)
|
||||
-- JOIN CONTAINER to get CONTAINERNAME (LOT ID)
|
||||
-- JOIN CONTAINER to get CONTAINERNAME, PJ_TYPE, PJ_BOP, WAFER_LOT_ID
|
||||
-- Partial track-out: Same LOT may have multiple records with same track-in
|
||||
-- but different track-out times. We take the latest track-out time.
|
||||
-- Only includes records with actual equipment (excludes checkpoint stations)
|
||||
|
||||
WITH ranked_lots AS (
|
||||
SELECT
|
||||
h.CONTAINERID,
|
||||
h.WORKCENTERNAME,
|
||||
h.EQUIPMENTID,
|
||||
h.EQUIPMENTNAME,
|
||||
h.SPECNAME,
|
||||
h.TRACKINTIMESTAMP,
|
||||
h.TRACKOUTTIMESTAMP,
|
||||
h.TRACKINQTY,
|
||||
h.TRACKOUTQTY,
|
||||
h.FINISHEDRUNCARD,
|
||||
h.PJ_WORKORDER,
|
||||
c.CONTAINERNAME,
|
||||
c.PJ_TYPE,
|
||||
c.PJ_BOP,
|
||||
c.FIRSTNAME AS WAFER_LOT_ID,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY h.CONTAINERID, h.EQUIPMENTID, h.SPECNAME, h.TRACKINTIMESTAMP
|
||||
ORDER BY h.TRACKOUTTIMESTAMP DESC NULLS LAST
|
||||
) AS rn
|
||||
FROM DWH.DW_MES_LOTWIPHISTORY h
|
||||
LEFT JOIN DWH.DW_MES_CONTAINER c ON h.CONTAINERID = c.CONTAINERID
|
||||
WHERE h.TRACKINTIMESTAMP >= TO_DATE(:start_date, 'YYYY-MM-DD')
|
||||
AND h.TRACKINTIMESTAMP < TO_DATE(:end_date, 'YYYY-MM-DD') + 1
|
||||
AND h.EQUIPMENTID IS NOT NULL
|
||||
AND h.TRACKINTIMESTAMP IS NOT NULL
|
||||
AND {{ EQUIPMENT_FILTER }}
|
||||
)
|
||||
SELECT
|
||||
h.CONTAINERID,
|
||||
c.CONTAINERNAME,
|
||||
h.EQUIPMENTID,
|
||||
h.EQUIPMENTNAME,
|
||||
h.FINISHEDRUNCARD,
|
||||
h.SPECNAME,
|
||||
h.TRACKINTIMESTAMP,
|
||||
h.TRACKOUTTIMESTAMP,
|
||||
h.TRACKINQTY,
|
||||
h.TRACKOUTQTY,
|
||||
h.PJ_WORKORDER
|
||||
FROM DWH.DW_MES_LOTWIPHISTORY h
|
||||
LEFT JOIN DWH.DW_MES_CONTAINER c ON h.CONTAINERID = c.CONTAINERID
|
||||
WHERE h.TRACKINTIMESTAMP >= TO_DATE(:start_date, 'YYYY-MM-DD')
|
||||
AND h.TRACKINTIMESTAMP < TO_DATE(:end_date, 'YYYY-MM-DD') + 1
|
||||
AND {{ EQUIPMENT_FILTER }}
|
||||
ORDER BY h.EQUIPMENTNAME, h.TRACKINTIMESTAMP
|
||||
CONTAINERID,
|
||||
WORKCENTERNAME,
|
||||
EQUIPMENTID,
|
||||
EQUIPMENTNAME,
|
||||
SPECNAME,
|
||||
TRACKINTIMESTAMP,
|
||||
TRACKOUTTIMESTAMP,
|
||||
TRACKINQTY,
|
||||
TRACKOUTQTY,
|
||||
FINISHEDRUNCARD,
|
||||
PJ_WORKORDER,
|
||||
CONTAINERNAME,
|
||||
PJ_TYPE,
|
||||
PJ_BOP,
|
||||
WAFER_LOT_ID
|
||||
FROM ranked_lots
|
||||
WHERE rn = 1
|
||||
ORDER BY EQUIPMENTNAME, TRACKINTIMESTAMP
|
||||
|
||||
@@ -12,16 +12,18 @@
|
||||
-- If need to filter by EQUIPMENTID, must JOIN LOTWIPHISTORY
|
||||
-- Uses LOSSREASONNAME (NOT REJECTREASONNAME)
|
||||
-- Uses TXNDATE (NOT TXNDATETIME)
|
||||
-- DEFECTQTY = SUM of REJECTQTY + STANDBYQTY + QTYTOPROCESS + INPROCESSQTY + PROCESSEDQTY
|
||||
|
||||
SELECT
|
||||
EQUIPMENTNAME,
|
||||
REJECTCATEGORYNAME,
|
||||
LOSSREASONNAME,
|
||||
SUM(REJECTQTY) AS TOTAL_REJECT_QTY,
|
||||
SUM(NVL(REJECTQTY, 0) + NVL(STANDBYQTY, 0) + NVL(QTYTOPROCESS, 0)
|
||||
+ NVL(INPROCESSQTY, 0) + NVL(PROCESSEDQTY, 0)) AS TOTAL_DEFECT_QTY,
|
||||
COUNT(DISTINCT CONTAINERID) AS AFFECTED_LOT_COUNT
|
||||
FROM DWH.DW_MES_LOTREJECTHISTORY
|
||||
WHERE TXNDATE >= TO_DATE(:start_date, 'YYYY-MM-DD')
|
||||
AND TXNDATE < TO_DATE(:end_date, 'YYYY-MM-DD') + 1
|
||||
AND {{ EQUIPMENT_FILTER }}
|
||||
GROUP BY EQUIPMENTNAME, REJECTCATEGORYNAME, LOSSREASONNAME
|
||||
ORDER BY EQUIPMENTNAME, TOTAL_REJECT_QTY DESC
|
||||
GROUP BY EQUIPMENTNAME, LOSSREASONNAME
|
||||
ORDER BY EQUIPMENTNAME, TOTAL_DEFECT_QTY DESC
|
||||
|
||||
@@ -7,12 +7,14 @@
|
||||
-- Note: Uses LOSSREASONNAME (NOT REJECTREASONNAME)
|
||||
-- Uses TXNDATE (NOT TXNDATETIME)
|
||||
-- Only has EQUIPMENTNAME, NO EQUIPMENTID field
|
||||
-- DEFECTQTY = SUM of REJECTQTY + STANDBYQTY + QTYTOPROCESS + INPROCESSQTY + PROCESSEDQTY
|
||||
|
||||
SELECT
|
||||
CONTAINERID,
|
||||
REJECTCATEGORYNAME,
|
||||
LOSSREASONNAME,
|
||||
REJECTQTY,
|
||||
NVL(REJECTQTY, 0) + NVL(STANDBYQTY, 0) + NVL(QTYTOPROCESS, 0)
|
||||
+ NVL(INPROCESSQTY, 0) + NVL(PROCESSEDQTY, 0) AS DEFECTQTY,
|
||||
WORKCENTERNAME,
|
||||
EQUIPMENTNAME,
|
||||
TXNDATE,
|
||||
|
||||
116
src/mes_dashboard/sql/tmtt_defect/base_data.sql
Normal file
116
src/mes_dashboard/sql/tmtt_defect/base_data.sql
Normal file
@@ -0,0 +1,116 @@
|
||||
-- TMTT Defect Analysis - Base Data Query
|
||||
-- Returns LOT-level data with TMTT input, defects (印字/腳型), and MOLD equipment
|
||||
--
|
||||
-- Parameters:
|
||||
-- :start_date - Start date (YYYY-MM-DD)
|
||||
-- :end_date - End date (YYYY-MM-DD)
|
||||
--
|
||||
-- Tables used:
|
||||
-- DWH.DW_MES_LOTWIPHISTORY (TMTT station records, MOLD station records)
|
||||
-- DWH.DW_MES_LOTREJECTHISTORY (defect records)
|
||||
-- DWH.DW_MES_CONTAINER (product info)
|
||||
-- DWH.DW_MES_WIP (WORKFLOWNAME, filtered by PRODUCTLINENAME <> '點測')
|
||||
--
|
||||
-- Notes:
|
||||
-- - LOSSREASONNAME: '276_腳型不良', '277_印字不良'
|
||||
-- - TMTT station: WORKCENTERNAME matching 'TMTT' or '測試'
|
||||
-- - MOLD station: WORKCENTERNAME matching '成型'
|
||||
-- - Multiple MOLD equipment per LOT: take earliest TRACKINTIMESTAMP
|
||||
-- - TMTT dedup: one row per CONTAINERID, take latest TRACKINTIMESTAMP
|
||||
-- - LOTREJECTHISTORY only has EQUIPMENTNAME (no EQUIPMENTID)
|
||||
-- - WORKFLOW: from DW_MES_WIP.WORKFLOWNAME (exclude PRODUCTLINENAME='點測')
|
||||
-- - Defect qty = SUM(REJECTQTY + STANDBYQTY + QTYTOPROCESS + INPROCESSQTY + PROCESSEDQTY)
|
||||
|
||||
WITH tmtt_records AS (
|
||||
SELECT /*+ MATERIALIZE */
|
||||
h.CONTAINERID,
|
||||
h.EQUIPMENTID AS TMTT_EQUIPMENTID,
|
||||
h.EQUIPMENTNAME AS TMTT_EQUIPMENTNAME,
|
||||
h.TRACKINQTY,
|
||||
h.TRACKINTIMESTAMP,
|
||||
h.TRACKOUTTIMESTAMP,
|
||||
h.FINISHEDRUNCARD,
|
||||
h.SPECNAME,
|
||||
h.WORKCENTERNAME,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY h.CONTAINERID
|
||||
ORDER BY h.TRACKINTIMESTAMP DESC, h.TRACKOUTTIMESTAMP DESC NULLS LAST
|
||||
) AS rn
|
||||
FROM DWH.DW_MES_LOTWIPHISTORY h
|
||||
WHERE h.TRACKINTIMESTAMP >= TO_DATE(:start_date, 'YYYY-MM-DD')
|
||||
AND h.TRACKINTIMESTAMP < TO_DATE(:end_date, 'YYYY-MM-DD') + 1
|
||||
AND (UPPER(h.WORKCENTERNAME) LIKE '%TMTT%' OR h.WORKCENTERNAME LIKE '%測試%')
|
||||
AND h.EQUIPMENTID IS NOT NULL
|
||||
AND h.TRACKINTIMESTAMP IS NOT NULL
|
||||
),
|
||||
tmtt_deduped AS (
|
||||
SELECT * FROM tmtt_records WHERE rn = 1
|
||||
),
|
||||
tmtt_rejects AS (
|
||||
SELECT /*+ MATERIALIZE */
|
||||
r.CONTAINERID,
|
||||
r.LOSSREASONNAME,
|
||||
SUM(NVL(r.REJECTQTY, 0) + NVL(r.STANDBYQTY, 0) + NVL(r.QTYTOPROCESS, 0)
|
||||
+ NVL(r.INPROCESSQTY, 0) + NVL(r.PROCESSEDQTY, 0)) AS REJECTQTY
|
||||
FROM DWH.DW_MES_LOTREJECTHISTORY r
|
||||
WHERE r.TXNDATE >= TO_DATE(:start_date, 'YYYY-MM-DD')
|
||||
AND r.TXNDATE < TO_DATE(:end_date, 'YYYY-MM-DD') + 1
|
||||
AND (UPPER(r.WORKCENTERNAME) LIKE '%TMTT%' OR r.WORKCENTERNAME LIKE '%測試%')
|
||||
AND r.LOSSREASONNAME IN ('276_腳型不良', '277_印字不良')
|
||||
GROUP BY r.CONTAINERID, r.LOSSREASONNAME
|
||||
),
|
||||
mold_records AS (
|
||||
SELECT /*+ MATERIALIZE */
|
||||
m.CONTAINERID,
|
||||
m.EQUIPMENTID AS MOLD_EQUIPMENTID,
|
||||
m.EQUIPMENTNAME AS MOLD_EQUIPMENTNAME,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY m.CONTAINERID
|
||||
ORDER BY m.TRACKINTIMESTAMP ASC
|
||||
) AS mold_rn
|
||||
FROM DWH.DW_MES_LOTWIPHISTORY m
|
||||
WHERE m.CONTAINERID IN (SELECT CONTAINERID FROM tmtt_deduped)
|
||||
AND (m.WORKCENTERNAME LIKE '%成型%')
|
||||
AND m.EQUIPMENTID IS NOT NULL
|
||||
),
|
||||
mold_deduped AS (
|
||||
SELECT * FROM mold_records WHERE mold_rn = 1
|
||||
),
|
||||
product_info AS (
|
||||
SELECT /*+ MATERIALIZE */
|
||||
c.CONTAINERID,
|
||||
c.CONTAINERNAME,
|
||||
c.PJ_TYPE,
|
||||
c.PRODUCTLINENAME
|
||||
FROM DWH.DW_MES_CONTAINER c
|
||||
WHERE c.CONTAINERID IN (SELECT CONTAINERID FROM tmtt_deduped)
|
||||
),
|
||||
workflow_info AS (
|
||||
SELECT /*+ MATERIALIZE */
|
||||
DISTINCT w.CONTAINERID,
|
||||
w.WORKFLOWNAME
|
||||
FROM DWH.DW_MES_WIP w
|
||||
WHERE w.CONTAINERID IN (SELECT CONTAINERID FROM tmtt_deduped)
|
||||
AND w.PRODUCTLINENAME <> '點測'
|
||||
)
|
||||
SELECT
|
||||
t.CONTAINERID,
|
||||
p.CONTAINERNAME,
|
||||
p.PJ_TYPE,
|
||||
p.PRODUCTLINENAME,
|
||||
NVL(wf.WORKFLOWNAME, t.SPECNAME) AS WORKFLOW,
|
||||
t.FINISHEDRUNCARD,
|
||||
t.TMTT_EQUIPMENTID,
|
||||
t.TMTT_EQUIPMENTNAME,
|
||||
t.TRACKINQTY,
|
||||
t.TRACKINTIMESTAMP,
|
||||
m.MOLD_EQUIPMENTID,
|
||||
m.MOLD_EQUIPMENTNAME,
|
||||
r.LOSSREASONNAME,
|
||||
NVL(r.REJECTQTY, 0) AS REJECTQTY
|
||||
FROM tmtt_deduped t
|
||||
LEFT JOIN product_info p ON t.CONTAINERID = p.CONTAINERID
|
||||
LEFT JOIN workflow_info wf ON t.CONTAINERID = wf.CONTAINERID
|
||||
LEFT JOIN mold_deduped m ON t.CONTAINERID = m.CONTAINERID
|
||||
LEFT JOIN tmtt_rejects r ON t.CONTAINERID = r.CONTAINERID
|
||||
ORDER BY t.TRACKINTIMESTAMP
|
||||
@@ -2670,16 +2670,16 @@ function renderEquipmentTab(tabType, result) {
|
||||
labels: { RESOURCENAME: '設備名稱', PRD_HOURS: '生產', SBY_HOURS: '待機', UDT_HOURS: '非計畫停機', SDT_HOURS: '計畫停機', EGT_HOURS: '工程', NST_HOURS: '非排程', TOTAL_HOURS: '總時數', OU_PERCENT: 'OU%' }
|
||||
},
|
||||
'lots': {
|
||||
cols: ['EQUIPMENTNAME', 'CONTAINERNAME', 'FINISHEDRUNCARD', 'SPECNAME', 'TRACKINTIMESTAMP', 'TRACKOUTTIMESTAMP', 'TRACKINQTY', 'TRACKOUTQTY'],
|
||||
labels: { EQUIPMENTNAME: '設備', CONTAINERNAME: 'LOT ID', FINISHEDRUNCARD: '批次號', SPECNAME: '規格', TRACKINTIMESTAMP: '上機時間', TRACKOUTTIMESTAMP: '下機時間', TRACKINQTY: '上機數', TRACKOUTQTY: '下機數' }
|
||||
cols: ['EQUIPMENTNAME', 'WORKCENTERNAME', 'CONTAINERNAME', 'PJ_TYPE', 'PJ_BOP', 'WAFER_LOT_ID', 'FINISHEDRUNCARD', 'SPECNAME', 'TRACKINTIMESTAMP', 'TRACKOUTTIMESTAMP', 'TRACKINQTY', 'TRACKOUTQTY'],
|
||||
labels: { EQUIPMENTNAME: '設備', WORKCENTERNAME: '站點', CONTAINERNAME: 'LOT ID', PJ_TYPE: '產品類型', PJ_BOP: 'BOP', WAFER_LOT_ID: 'Wafer Lot', FINISHEDRUNCARD: '批次號', SPECNAME: '規格', TRACKINTIMESTAMP: '上機時間', TRACKOUTTIMESTAMP: '下機時間', TRACKINQTY: '上機數', TRACKOUTQTY: '下機數' }
|
||||
},
|
||||
'materials': {
|
||||
cols: ['EQUIPMENTNAME', 'MATERIALPARTNAME', 'TOTAL_CONSUMED', 'LOT_COUNT'],
|
||||
labels: { EQUIPMENTNAME: '設備', MATERIALPARTNAME: '物料名稱', TOTAL_CONSUMED: '消耗總量', LOT_COUNT: '批次數' }
|
||||
},
|
||||
'rejects': {
|
||||
cols: ['EQUIPMENTNAME', 'REJECTCATEGORYNAME', 'LOSSREASONNAME', 'TOTAL_REJECT_QTY', 'AFFECTED_LOT_COUNT'],
|
||||
labels: { EQUIPMENTNAME: '設備', REJECTCATEGORYNAME: '不良分類', LOSSREASONNAME: '損失原因', TOTAL_REJECT_QTY: '不良數量', AFFECTED_LOT_COUNT: '影響批次' }
|
||||
cols: ['EQUIPMENTNAME', 'LOSSREASONNAME', 'TOTAL_DEFECT_QTY', 'TOTAL_REJECT_QTY', 'AFFECTED_LOT_COUNT'],
|
||||
labels: { EQUIPMENTNAME: '設備', LOSSREASONNAME: '損失原因', TOTAL_DEFECT_QTY: '不良數量', TOTAL_REJECT_QTY: 'REJECT數量', AFFECTED_LOT_COUNT: '影響批次' }
|
||||
},
|
||||
'jobs': {
|
||||
cols: ['RESOURCENAME', 'JOBID', 'JOBSTATUS', 'JOBMODELNAME', 'CREATEDATE', 'COMPLETEDATE', 'CAUSECODENAME', 'REPAIRCODENAME'],
|
||||
|
||||
@@ -328,6 +328,9 @@
|
||||
{% if can_view_page('/query-tool') %}
|
||||
<button class="tab" data-target="queryToolFrame">批次追蹤工具</button>
|
||||
{% endif %}
|
||||
{% if can_view_page('/tmtt-defect') %}
|
||||
<button class="tab" data-target="tmttDefectFrame">TMTT不良分析</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
@@ -353,6 +356,9 @@
|
||||
{% if can_view_page('/query-tool') %}
|
||||
<iframe id="queryToolFrame" data-src="/query-tool" title="批次追蹤工具"></iframe>
|
||||
{% endif %}
|
||||
{% if can_view_page('/tmtt-defect') %}
|
||||
<iframe id="tmttDefectFrame" data-src="/tmtt-defect" title="TMTT不良分析"></iframe>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
629
src/mes_dashboard/templates/tmtt_defect.html
Normal file
629
src/mes_dashboard/templates/tmtt_defect.html
Normal file
@@ -0,0 +1,629 @@
|
||||
{% extends "_base.html" %}
|
||||
|
||||
{% block title %}TMTT 印字腳型不良分析{% endblock %}
|
||||
|
||||
{% block head_extra %}
|
||||
<script src="{{ url_for('static', filename='js/echarts.min.js') }}"></script>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f0f2f5;
|
||||
--card-bg: #ffffff;
|
||||
--primary: #667eea;
|
||||
--primary-light: #818cf8;
|
||||
--success: #22c55e;
|
||||
--danger: #ef4444;
|
||||
--warning: #f59e0b;
|
||||
--text: #1f2937;
|
||||
--text-secondary: #6b7280;
|
||||
--border: #e5e7eb;
|
||||
--print-color: #ef4444;
|
||||
--lead-color: #f59e0b;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { background: var(--bg); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; color: var(--text); }
|
||||
|
||||
.page-header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white; padding: 20px 32px;
|
||||
}
|
||||
.page-header h1 { font-size: 22px; font-weight: 600; }
|
||||
.page-header .subtitle { font-size: 13px; opacity: 0.8; margin-top: 4px; }
|
||||
|
||||
.container { max-width: 1600px; margin: 0 auto; padding: 20px 24px; }
|
||||
|
||||
/* Filter Bar */
|
||||
.filter-bar {
|
||||
background: var(--card-bg); border-radius: 10px; padding: 16px 20px;
|
||||
display: flex; align-items: center; gap: 16px; margin-bottom: 20px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
||||
}
|
||||
.filter-bar label { font-size: 13px; color: var(--text-secondary); font-weight: 500; }
|
||||
.filter-bar input[type="date"] {
|
||||
padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px;
|
||||
font-size: 13px; color: var(--text);
|
||||
}
|
||||
.btn-query {
|
||||
padding: 8px 24px; background: var(--primary); color: white; border: none;
|
||||
border-radius: 6px; font-size: 13px; font-weight: 600; cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.btn-query:hover { background: var(--primary-light); }
|
||||
.btn-query:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
/* KPI Cards */
|
||||
.kpi-row {
|
||||
display: grid; grid-template-columns: repeat(6, 1fr); gap: 12px; margin-bottom: 20px;
|
||||
}
|
||||
.kpi-card {
|
||||
background: var(--card-bg); border-radius: 10px; padding: 16px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.08); border-left: 4px solid var(--primary);
|
||||
}
|
||||
.kpi-card.print { border-left-color: var(--print-color); }
|
||||
.kpi-card.lead { border-left-color: var(--lead-color); }
|
||||
.kpi-card .kpi-label { font-size: 12px; color: var(--text-secondary); margin-bottom: 6px; }
|
||||
.kpi-card .kpi-value { font-size: 22px; font-weight: 700; }
|
||||
.kpi-card .kpi-unit { font-size: 12px; color: var(--text-secondary); margin-left: 4px; }
|
||||
|
||||
/* Chart Grid */
|
||||
.chart-grid {
|
||||
display: grid; grid-template-columns: 1fr; gap: 16px; margin-bottom: 20px;
|
||||
}
|
||||
.chart-card {
|
||||
background: var(--card-bg); border-radius: 10px; padding: 16px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
||||
}
|
||||
.chart-card h3 { font-size: 14px; font-weight: 600; margin-bottom: 12px; color: var(--text); }
|
||||
.chart-container { height: 380px; }
|
||||
|
||||
/* Detail Section */
|
||||
.detail-section {
|
||||
background: var(--card-bg); border-radius: 10px; padding: 16px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
||||
}
|
||||
.detail-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.detail-header h3 { font-size: 14px; font-weight: 600; }
|
||||
.detail-actions { display: flex; gap: 8px; align-items: center; }
|
||||
|
||||
.filter-tag {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
background: #eff6ff; color: #2563eb; padding: 4px 10px;
|
||||
border-radius: 16px; font-size: 12px;
|
||||
}
|
||||
.filter-tag button {
|
||||
background: none; border: none; color: #2563eb; cursor: pointer;
|
||||
font-size: 14px; line-height: 1;
|
||||
}
|
||||
|
||||
.btn-export {
|
||||
padding: 6px 14px; background: var(--success); color: white; border: none;
|
||||
border-radius: 6px; font-size: 12px; font-weight: 600; cursor: pointer;
|
||||
}
|
||||
.btn-export:hover { opacity: 0.9; }
|
||||
|
||||
.btn-clear {
|
||||
padding: 6px 14px; background: #f3f4f6; color: var(--text-secondary); border: none;
|
||||
border-radius: 6px; font-size: 12px; cursor: pointer;
|
||||
}
|
||||
.btn-clear:hover { background: #e5e7eb; }
|
||||
|
||||
/* Table */
|
||||
.detail-table-wrap { overflow-x: auto; max-height: 500px; overflow-y: auto; }
|
||||
.detail-table {
|
||||
width: 100%; border-collapse: collapse; font-size: 12px;
|
||||
}
|
||||
.detail-table th {
|
||||
background: #f8fafc; position: sticky; top: 0; z-index: 1;
|
||||
padding: 8px 10px; text-align: left; font-weight: 600;
|
||||
border-bottom: 2px solid var(--border); white-space: nowrap; cursor: pointer;
|
||||
}
|
||||
.detail-table th:hover { background: #f0f2f5; }
|
||||
.detail-table td {
|
||||
padding: 6px 10px; border-bottom: 1px solid #f3f4f6; white-space: nowrap;
|
||||
}
|
||||
.detail-table tr:hover td { background: #f8fafc; }
|
||||
|
||||
.sort-indicator { font-size: 10px; margin-left: 4px; color: var(--text-secondary); }
|
||||
|
||||
/* Empty state */
|
||||
.empty-state {
|
||||
text-align: center; padding: 60px 20px; color: var(--text-secondary);
|
||||
}
|
||||
.empty-state .icon { font-size: 48px; margin-bottom: 12px; }
|
||||
.empty-state p { font-size: 14px; }
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 1200px) { .kpi-row { grid-template-columns: repeat(3, 1fr); } }
|
||||
@media (max-width: 768px) {
|
||||
.kpi-row { grid-template-columns: repeat(2, 1fr); }
|
||||
.filter-bar { flex-wrap: wrap; }
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>TMTT 印字與腳型不良分析</h1>
|
||||
<div class="subtitle">分析 TMTT 站印字/腳型不良率,按 WORKFLOW、PACKAGE、TYPE、TMTT機台、MOLD機台 維度</div>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<!-- Filter Bar -->
|
||||
<div class="filter-bar">
|
||||
<label>起始日期</label>
|
||||
<input type="date" id="startDate">
|
||||
<label>結束日期</label>
|
||||
<input type="date" id="endDate">
|
||||
<button class="btn-query" id="btnQuery" onclick="executeQuery()">查詢</button>
|
||||
</div>
|
||||
|
||||
<!-- KPI Cards -->
|
||||
<div class="kpi-row" id="kpiRow" style="display:none;">
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-label">投入數</div>
|
||||
<div class="kpi-value" id="kpiInput">-</div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-label">LOT 數</div>
|
||||
<div class="kpi-value" id="kpiLots">-</div>
|
||||
</div>
|
||||
<div class="kpi-card print">
|
||||
<div class="kpi-label">印字不良數</div>
|
||||
<div class="kpi-value" id="kpiPrintQty">-</div>
|
||||
</div>
|
||||
<div class="kpi-card print">
|
||||
<div class="kpi-label">印字不良率</div>
|
||||
<div class="kpi-value" id="kpiPrintRate">-<span class="kpi-unit">%</span></div>
|
||||
</div>
|
||||
<div class="kpi-card lead">
|
||||
<div class="kpi-label">腳型不良數</div>
|
||||
<div class="kpi-value" id="kpiLeadQty">-</div>
|
||||
</div>
|
||||
<div class="kpi-card lead">
|
||||
<div class="kpi-label">腳型不良率</div>
|
||||
<div class="kpi-value" id="kpiLeadRate">-<span class="kpi-unit">%</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts -->
|
||||
<div class="chart-grid" id="chartGrid" style="display:none;">
|
||||
<div class="chart-card">
|
||||
<h3>依 WORKFLOW</h3>
|
||||
<div class="chart-container" id="chartWorkflow"></div>
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<h3>依 PACKAGE</h3>
|
||||
<div class="chart-container" id="chartPackage"></div>
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<h3>依 TYPE</h3>
|
||||
<div class="chart-container" id="chartType"></div>
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<h3>依 TMTT 機台</h3>
|
||||
<div class="chart-container" id="chartTmtt"></div>
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<h3>依 MOLD 機台</h3>
|
||||
<div class="chart-container" id="chartMold"></div>
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<h3>每日印字不良率趨勢</h3>
|
||||
<div class="chart-container" id="chartPrintTrend"></div>
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<h3>每日腳型不良率趨勢</h3>
|
||||
<div class="chart-container" id="chartLeadTrend"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detail Table -->
|
||||
<div class="detail-section" id="detailSection" style="display:none;">
|
||||
<div class="detail-header">
|
||||
<h3>明細清單 <span id="detailCount" style="font-weight:400; color:var(--text-secondary);"></span></h3>
|
||||
<div class="detail-actions">
|
||||
<span id="filterTag" style="display:none;" class="filter-tag">
|
||||
<span id="filterLabel"></span>
|
||||
<button onclick="clearFilter()">×</button>
|
||||
</span>
|
||||
<button class="btn-clear" onclick="clearFilter()" id="btnClear" style="display:none;">清除篩選</button>
|
||||
<button class="btn-export" onclick="exportCsv()">匯出 CSV</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-table-wrap">
|
||||
<table class="detail-table" id="detailTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th onclick="sortTable('CONTAINERNAME')">LOT ID <span class="sort-indicator" id="sort_CONTAINERNAME"></span></th>
|
||||
<th onclick="sortTable('PJ_TYPE')">TYPE <span class="sort-indicator" id="sort_PJ_TYPE"></span></th>
|
||||
<th onclick="sortTable('PRODUCTLINENAME')">PACKAGE <span class="sort-indicator" id="sort_PRODUCTLINENAME"></span></th>
|
||||
<th onclick="sortTable('WORKFLOW')">WORKFLOW <span class="sort-indicator" id="sort_WORKFLOW"></span></th>
|
||||
<th onclick="sortTable('FINISHEDRUNCARD')">完工流水碼 <span class="sort-indicator" id="sort_FINISHEDRUNCARD"></span></th>
|
||||
<th onclick="sortTable('TMTT_EQUIPMENTNAME')">TMTT設備 <span class="sort-indicator" id="sort_TMTT_EQUIPMENTNAME"></span></th>
|
||||
<th onclick="sortTable('MOLD_EQUIPMENTNAME')">MOLD設備 <span class="sort-indicator" id="sort_MOLD_EQUIPMENTNAME"></span></th>
|
||||
<th onclick="sortTable('INPUT_QTY')">投入數 <span class="sort-indicator" id="sort_INPUT_QTY"></span></th>
|
||||
<th onclick="sortTable('PRINT_DEFECT_QTY')">印字不良 <span class="sort-indicator" id="sort_PRINT_DEFECT_QTY"></span></th>
|
||||
<th onclick="sortTable('PRINT_DEFECT_RATE')">印字不良率(%) <span class="sort-indicator" id="sort_PRINT_DEFECT_RATE"></span></th>
|
||||
<th onclick="sortTable('LEAD_DEFECT_QTY')">腳型不良 <span class="sort-indicator" id="sort_LEAD_DEFECT_QTY"></span></th>
|
||||
<th onclick="sortTable('LEAD_DEFECT_RATE')">腳型不良率(%) <span class="sort-indicator" id="sort_LEAD_DEFECT_RATE"></span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="detailBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div class="empty-state" id="emptyState">
|
||||
<div class="icon">📊</div>
|
||||
<p>請選擇日期範圍後點擊「查詢」</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
(function() {
|
||||
// ============================================================
|
||||
// State
|
||||
// ============================================================
|
||||
let analysisData = null;
|
||||
let activeFilter = null; // { dimension: 'by_workflow', field: 'WORKFLOW', value: 'xxx' }
|
||||
let sortState = { column: null, asc: true };
|
||||
const charts = {};
|
||||
|
||||
const CHART_CONFIG = [
|
||||
{ id: 'chartWorkflow', key: 'by_workflow', field: 'WORKFLOW', title: 'WORKFLOW' },
|
||||
{ id: 'chartPackage', key: 'by_package', field: 'PRODUCTLINENAME', title: 'PACKAGE' },
|
||||
{ id: 'chartType', key: 'by_type', field: 'PJ_TYPE', title: 'TYPE' },
|
||||
{ id: 'chartTmtt', key: 'by_tmtt_machine', field: 'TMTT_EQUIPMENTNAME', title: 'TMTT機台' },
|
||||
{ id: 'chartMold', key: 'by_mold_machine', field: 'MOLD_EQUIPMENTNAME', title: 'MOLD機台' },
|
||||
];
|
||||
|
||||
// ============================================================
|
||||
// Query
|
||||
// ============================================================
|
||||
window.executeQuery = async function() {
|
||||
const startDate = document.getElementById('startDate').value;
|
||||
const endDate = document.getElementById('endDate').value;
|
||||
|
||||
if (!startDate || !endDate) {
|
||||
Toast.warning('請選擇起始和結束日期');
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.getElementById('btnQuery');
|
||||
btn.disabled = true;
|
||||
const loadingId = Toast.loading('查詢中...');
|
||||
|
||||
try {
|
||||
const result = await MesApi.get('/api/tmtt-defect/analysis', {
|
||||
params: { start_date: startDate, end_date: endDate },
|
||||
timeout: 120000,
|
||||
});
|
||||
|
||||
Toast.dismiss(loadingId);
|
||||
|
||||
if (!result || !result.success) {
|
||||
Toast.error(result?.error || '查詢失敗');
|
||||
return;
|
||||
}
|
||||
|
||||
analysisData = result.data;
|
||||
activeFilter = null;
|
||||
sortState = { column: null, asc: true };
|
||||
|
||||
renderAll();
|
||||
Toast.success('查詢完成');
|
||||
} catch (err) {
|
||||
Toast.dismiss(loadingId);
|
||||
Toast.error('查詢失敗: ' + (err.message || '未知錯誤'));
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Render
|
||||
// ============================================================
|
||||
function renderAll() {
|
||||
if (!analysisData) return;
|
||||
|
||||
document.getElementById('emptyState').style.display = 'none';
|
||||
document.getElementById('kpiRow').style.display = '';
|
||||
document.getElementById('chartGrid').style.display = '';
|
||||
document.getElementById('detailSection').style.display = '';
|
||||
|
||||
renderKpi(analysisData.kpi);
|
||||
renderCharts(analysisData.charts);
|
||||
renderDailyTrend(analysisData.daily_trend || []);
|
||||
renderDetailTable();
|
||||
}
|
||||
|
||||
function renderKpi(kpi) {
|
||||
document.getElementById('kpiInput').textContent = kpi.total_input.toLocaleString('zh-TW');
|
||||
document.getElementById('kpiLots').textContent = kpi.lot_count.toLocaleString('zh-TW');
|
||||
document.getElementById('kpiPrintQty').textContent = kpi.print_defect_qty.toLocaleString('zh-TW');
|
||||
document.getElementById('kpiPrintRate').innerHTML = kpi.print_defect_rate.toFixed(4) + '<span class="kpi-unit">%</span>';
|
||||
document.getElementById('kpiLeadQty').textContent = kpi.lead_defect_qty.toLocaleString('zh-TW');
|
||||
document.getElementById('kpiLeadRate').innerHTML = kpi.lead_defect_rate.toFixed(4) + '<span class="kpi-unit">%</span>';
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Charts
|
||||
// ============================================================
|
||||
function renderCharts(chartsData) {
|
||||
CHART_CONFIG.forEach(cfg => {
|
||||
const data = chartsData[cfg.key] || [];
|
||||
renderParetoChart(cfg.id, data, cfg.key, cfg.field, cfg.title);
|
||||
});
|
||||
}
|
||||
|
||||
function renderParetoChart(containerId, data, chartKey, filterField, title) {
|
||||
if (!charts[containerId]) {
|
||||
charts[containerId] = echarts.init(document.getElementById(containerId));
|
||||
}
|
||||
const chart = charts[containerId];
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
chart.setOption({
|
||||
title: { text: '無資料', left: 'center', top: 'center', textStyle: { color: '#999', fontSize: 14 } },
|
||||
xAxis: { show: false }, yAxis: { show: false }, series: []
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const names = data.map(d => d.name);
|
||||
const printRates = data.map(d => d.print_defect_rate);
|
||||
const leadRates = data.map(d => d.lead_defect_rate);
|
||||
const cumPct = data.map(d => d.cumulative_pct);
|
||||
|
||||
const option = {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'shadow' },
|
||||
formatter: function(params) {
|
||||
const name = params[0].name;
|
||||
const item = data.find(d => d.name === name);
|
||||
if (!item) return name;
|
||||
return `<b>${name}</b><br/>` +
|
||||
`投入數: ${item.input_qty.toLocaleString()}<br/>` +
|
||||
`<span style="color:${getComputedStyle(document.documentElement).getPropertyValue('--print-color')}">●</span> 印字不良: ${item.print_defect_qty} (${item.print_defect_rate.toFixed(4)}%)<br/>` +
|
||||
`<span style="color:${getComputedStyle(document.documentElement).getPropertyValue('--lead-color')}">●</span> 腳型不良: ${item.lead_defect_qty} (${item.lead_defect_rate.toFixed(4)}%)<br/>` +
|
||||
`累積: ${item.cumulative_pct.toFixed(1)}%`;
|
||||
}
|
||||
},
|
||||
legend: { data: ['印字不良率', '腳型不良率', '累積%'], bottom: 0, textStyle: { fontSize: 11 } },
|
||||
grid: { left: 60, right: 60, top: 30, bottom: names.length > 8 ? 100 : 60 },
|
||||
xAxis: {
|
||||
type: 'category', data: names,
|
||||
axisLabel: {
|
||||
rotate: names.length > 8 ? 35 : 0,
|
||||
fontSize: 11,
|
||||
interval: 0,
|
||||
formatter: v => v.length > 16 ? v.slice(0, 16) + '...' : v
|
||||
}
|
||||
},
|
||||
yAxis: [
|
||||
{ type: 'value', name: '不良率(%)', axisLabel: { fontSize: 10 }, splitLine: { lineStyle: { type: 'dashed' } } },
|
||||
{ type: 'value', name: '累積%', max: 100, axisLabel: { fontSize: 10 } }
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: '印字不良率', type: 'bar', stack: 'defect',
|
||||
data: printRates,
|
||||
itemStyle: { color: '#ef4444' },
|
||||
barMaxWidth: 40,
|
||||
},
|
||||
{
|
||||
name: '腳型不良率', type: 'bar', stack: 'defect',
|
||||
data: leadRates,
|
||||
itemStyle: { color: '#f59e0b' },
|
||||
barMaxWidth: 40,
|
||||
},
|
||||
{
|
||||
name: '累積%', type: 'line', yAxisIndex: 1,
|
||||
data: cumPct,
|
||||
itemStyle: { color: '#6366f1' },
|
||||
lineStyle: { width: 2 },
|
||||
symbol: 'circle', symbolSize: 6,
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
chart.setOption(option, true);
|
||||
|
||||
// Drill-down click handler
|
||||
chart.off('click');
|
||||
chart.on('click', function(params) {
|
||||
if (params.componentType === 'series' && params.name) {
|
||||
setFilter(chartKey, filterField, params.name);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Daily Trend Charts
|
||||
// ============================================================
|
||||
function renderDailyTrend(trendData) {
|
||||
renderTrendChart('chartPrintTrend', trendData, 'print_defect_rate', '印字不良率', '#ef4444');
|
||||
renderTrendChart('chartLeadTrend', trendData, 'lead_defect_rate', '腳型不良率', '#f59e0b');
|
||||
}
|
||||
|
||||
function renderTrendChart(containerId, data, rateKey, label, color) {
|
||||
if (!charts[containerId]) {
|
||||
charts[containerId] = echarts.init(document.getElementById(containerId));
|
||||
}
|
||||
const chart = charts[containerId];
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
chart.setOption({
|
||||
title: { text: '無資料', left: 'center', top: 'center', textStyle: { color: '#999', fontSize: 14 } },
|
||||
xAxis: { show: false }, yAxis: { show: false }, series: []
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const dates = data.map(d => d.date);
|
||||
const rates = data.map(d => d[rateKey]);
|
||||
const qtys = data.map(d => d[rateKey === 'print_defect_rate' ? 'print_defect_qty' : 'lead_defect_qty']);
|
||||
const inputs = data.map(d => d.input_qty);
|
||||
|
||||
const option = {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
formatter: function(params) {
|
||||
const idx = params[0].dataIndex;
|
||||
const d = data[idx];
|
||||
return `<b>${d.date}</b><br/>` +
|
||||
`投入數: ${d.input_qty.toLocaleString()}<br/>` +
|
||||
`<span style="color:${color}">●</span> ${label}: ${d[rateKey].toFixed(4)}%<br/>` +
|
||||
`不良數: ${qtys[idx].toLocaleString()}`;
|
||||
}
|
||||
},
|
||||
legend: { data: [label, '投入數'], bottom: 0, textStyle: { fontSize: 11 } },
|
||||
grid: { left: 60, right: 60, top: 30, bottom: 50 },
|
||||
xAxis: {
|
||||
type: 'category', data: dates,
|
||||
axisLabel: { fontSize: 11, rotate: dates.length > 15 ? 35 : 0 }
|
||||
},
|
||||
yAxis: [
|
||||
{ type: 'value', name: '不良率(%)', axisLabel: { fontSize: 10 }, splitLine: { lineStyle: { type: 'dashed' } } },
|
||||
{ type: 'value', name: '投入數', axisLabel: { fontSize: 10 } }
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: label, type: 'line', data: rates,
|
||||
itemStyle: { color: color },
|
||||
lineStyle: { width: 2 },
|
||||
symbol: 'circle', symbolSize: 4,
|
||||
areaStyle: { color: { type: 'linear', x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: color + '33' }, { offset: 1, color: color + '05' }] } },
|
||||
},
|
||||
{
|
||||
name: '投入數', type: 'bar', yAxisIndex: 1,
|
||||
data: inputs,
|
||||
itemStyle: { color: '#e0e7ff' },
|
||||
barMaxWidth: 20,
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
chart.setOption(option, true);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Filter / Drill-down
|
||||
// ============================================================
|
||||
function setFilter(chartKey, field, value) {
|
||||
activeFilter = { dimension: chartKey, field: field, value: value };
|
||||
renderDetailTable();
|
||||
}
|
||||
|
||||
window.clearFilter = function() {
|
||||
activeFilter = null;
|
||||
renderDetailTable();
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Detail Table
|
||||
// ============================================================
|
||||
function renderDetailTable() {
|
||||
if (!analysisData) return;
|
||||
|
||||
let rows = analysisData.detail;
|
||||
|
||||
// Apply filter
|
||||
const filterTag = document.getElementById('filterTag');
|
||||
const btnClear = document.getElementById('btnClear');
|
||||
|
||||
if (activeFilter) {
|
||||
rows = rows.filter(r => (r[activeFilter.field] || '') === activeFilter.value);
|
||||
document.getElementById('filterLabel').textContent =
|
||||
`${activeFilter.field}: ${activeFilter.value}`;
|
||||
filterTag.style.display = '';
|
||||
btnClear.style.display = '';
|
||||
} else {
|
||||
filterTag.style.display = 'none';
|
||||
btnClear.style.display = 'none';
|
||||
}
|
||||
|
||||
// Apply sort
|
||||
if (sortState.column) {
|
||||
const col = sortState.column;
|
||||
const asc = sortState.asc;
|
||||
rows = [...rows].sort((a, b) => {
|
||||
const va = a[col] ?? '';
|
||||
const vb = b[col] ?? '';
|
||||
if (typeof va === 'number' && typeof vb === 'number') {
|
||||
return asc ? va - vb : vb - va;
|
||||
}
|
||||
return asc ? String(va).localeCompare(String(vb)) : String(vb).localeCompare(String(va));
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('detailCount').textContent = `(${rows.length} 筆)`;
|
||||
|
||||
const tbody = document.getElementById('detailBody');
|
||||
if (rows.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="12" style="text-align:center;padding:20px;color:#999;">無資料</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = rows.map(r => `<tr>
|
||||
<td>${r.CONTAINERNAME || ''}</td>
|
||||
<td>${r.PJ_TYPE || ''}</td>
|
||||
<td>${r.PRODUCTLINENAME || ''}</td>
|
||||
<td>${r.WORKFLOW || ''}</td>
|
||||
<td>${r.FINISHEDRUNCARD || ''}</td>
|
||||
<td>${r.TMTT_EQUIPMENTNAME || ''}</td>
|
||||
<td>${r.MOLD_EQUIPMENTNAME || ''}</td>
|
||||
<td style="text-align:right">${(r.INPUT_QTY || 0).toLocaleString()}</td>
|
||||
<td style="text-align:right;color:var(--print-color)">${r.PRINT_DEFECT_QTY || 0}</td>
|
||||
<td style="text-align:right;color:var(--print-color)">${(r.PRINT_DEFECT_RATE || 0).toFixed(4)}</td>
|
||||
<td style="text-align:right;color:var(--lead-color)">${r.LEAD_DEFECT_QTY || 0}</td>
|
||||
<td style="text-align:right;color:var(--lead-color)">${(r.LEAD_DEFECT_RATE || 0).toFixed(4)}</td>
|
||||
</tr>`).join('');
|
||||
|
||||
// Update sort indicators
|
||||
document.querySelectorAll('.sort-indicator').forEach(el => el.textContent = '');
|
||||
if (sortState.column) {
|
||||
const ind = document.getElementById('sort_' + sortState.column);
|
||||
if (ind) ind.textContent = sortState.asc ? '▲' : '▼';
|
||||
}
|
||||
}
|
||||
|
||||
window.sortTable = function(column) {
|
||||
if (sortState.column === column) {
|
||||
sortState.asc = !sortState.asc;
|
||||
} else {
|
||||
sortState.column = column;
|
||||
sortState.asc = true;
|
||||
}
|
||||
renderDetailTable();
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// CSV Export
|
||||
// ============================================================
|
||||
window.exportCsv = function() {
|
||||
const startDate = document.getElementById('startDate').value;
|
||||
const endDate = document.getElementById('endDate').value;
|
||||
if (!startDate || !endDate) {
|
||||
Toast.warning('請先查詢資料');
|
||||
return;
|
||||
}
|
||||
window.open(`/api/tmtt-defect/export?start_date=${startDate}&end_date=${endDate}`, '_blank');
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Resize
|
||||
// ============================================================
|
||||
window.addEventListener('resize', function() {
|
||||
Object.values(charts).forEach(c => c.resize());
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
146
tests/test_tmtt_defect_routes.py
Normal file
146
tests/test_tmtt_defect_routes.py
Normal file
@@ -0,0 +1,146 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Integration tests for TMTT Defect Analysis API routes."""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
import pandas as pd
|
||||
|
||||
|
||||
class TestTmttDefectAnalysisEndpoint(unittest.TestCase):
|
||||
"""Test GET /api/tmtt-defect/analysis endpoint."""
|
||||
|
||||
def setUp(self):
|
||||
from mes_dashboard.core import database as db
|
||||
db._ENGINE = None
|
||||
|
||||
from mes_dashboard.app import create_app
|
||||
self.app = create_app()
|
||||
self.client = self.app.test_client()
|
||||
|
||||
def test_missing_start_date(self):
|
||||
resp = self.client.get('/api/tmtt-defect/analysis?end_date=2025-01-31')
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
data = resp.get_json()
|
||||
self.assertFalse(data['success'])
|
||||
|
||||
def test_missing_end_date(self):
|
||||
resp = self.client.get('/api/tmtt-defect/analysis?start_date=2025-01-01')
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
data = resp.get_json()
|
||||
self.assertFalse(data['success'])
|
||||
|
||||
def test_missing_both_dates(self):
|
||||
resp = self.client.get('/api/tmtt-defect/analysis')
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
|
||||
@patch('mes_dashboard.routes.tmtt_defect_routes.query_tmtt_defect_analysis')
|
||||
def test_invalid_date_format(self, mock_query):
|
||||
mock_query.return_value = {'error': '日期格式無效,請使用 YYYY-MM-DD'}
|
||||
resp = self.client.get(
|
||||
'/api/tmtt-defect/analysis?start_date=invalid&end_date=2025-01-31'
|
||||
)
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
data = resp.get_json()
|
||||
self.assertFalse(data['success'])
|
||||
self.assertIn('格式', data['error'])
|
||||
|
||||
@patch('mes_dashboard.routes.tmtt_defect_routes.query_tmtt_defect_analysis')
|
||||
def test_exceeds_180_days(self, mock_query):
|
||||
mock_query.return_value = {'error': '查詢範圍不能超過 180 天'}
|
||||
resp = self.client.get(
|
||||
'/api/tmtt-defect/analysis?start_date=2025-01-01&end_date=2025-12-31'
|
||||
)
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
data = resp.get_json()
|
||||
self.assertIn('180', data['error'])
|
||||
|
||||
@patch('mes_dashboard.routes.tmtt_defect_routes.query_tmtt_defect_analysis')
|
||||
def test_successful_query(self, mock_query):
|
||||
mock_query.return_value = {
|
||||
'kpi': {
|
||||
'total_input': 1000, 'lot_count': 10,
|
||||
'print_defect_qty': 5, 'print_defect_rate': 0.5,
|
||||
'lead_defect_qty': 3, 'lead_defect_rate': 0.3,
|
||||
},
|
||||
'charts': {
|
||||
'by_workflow': [], 'by_package': [], 'by_type': [],
|
||||
'by_tmtt_machine': [], 'by_mold_machine': [],
|
||||
},
|
||||
'detail': [],
|
||||
}
|
||||
|
||||
resp = self.client.get(
|
||||
'/api/tmtt-defect/analysis?start_date=2025-01-01&end_date=2025-01-31'
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
data = resp.get_json()
|
||||
self.assertTrue(data['success'])
|
||||
self.assertIn('kpi', data['data'])
|
||||
self.assertIn('charts', data['data'])
|
||||
self.assertIn('detail', data['data'])
|
||||
|
||||
# Verify separate defect rates
|
||||
kpi = data['data']['kpi']
|
||||
self.assertEqual(kpi['print_defect_qty'], 5)
|
||||
self.assertEqual(kpi['lead_defect_qty'], 3)
|
||||
|
||||
@patch('mes_dashboard.routes.tmtt_defect_routes.query_tmtt_defect_analysis')
|
||||
def test_query_failure_returns_500(self, mock_query):
|
||||
mock_query.return_value = None
|
||||
resp = self.client.get(
|
||||
'/api/tmtt-defect/analysis?start_date=2025-01-01&end_date=2025-01-31'
|
||||
)
|
||||
self.assertEqual(resp.status_code, 500)
|
||||
|
||||
|
||||
class TestTmttDefectExportEndpoint(unittest.TestCase):
|
||||
"""Test GET /api/tmtt-defect/export endpoint."""
|
||||
|
||||
def setUp(self):
|
||||
from mes_dashboard.core import database as db
|
||||
db._ENGINE = None
|
||||
|
||||
from mes_dashboard.app import create_app
|
||||
self.app = create_app()
|
||||
self.client = self.app.test_client()
|
||||
|
||||
def test_missing_dates(self):
|
||||
resp = self.client.get('/api/tmtt-defect/export')
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
|
||||
@patch('mes_dashboard.routes.tmtt_defect_routes.export_csv')
|
||||
def test_export_csv(self, mock_export):
|
||||
mock_export.return_value = iter([
|
||||
'\ufeff',
|
||||
'LOT ID,TYPE,PACKAGE,WORKFLOW,完工流水碼,TMTT設備,MOLD設備,'
|
||||
'投入數,印字不良數,印字不良率(%),腳型不良數,腳型不良率(%)\r\n',
|
||||
])
|
||||
resp = self.client.get(
|
||||
'/api/tmtt-defect/export?start_date=2025-01-01&end_date=2025-01-31'
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertIn('text/csv', resp.content_type)
|
||||
self.assertIn('attachment', resp.headers.get('Content-Disposition', ''))
|
||||
|
||||
|
||||
class TestTmttDefectPageRoute(unittest.TestCase):
|
||||
"""Test page route."""
|
||||
|
||||
def setUp(self):
|
||||
from mes_dashboard.core import database as db
|
||||
db._ENGINE = None
|
||||
|
||||
from mes_dashboard.app import create_app
|
||||
self.app = create_app()
|
||||
self.client = self.app.test_client()
|
||||
|
||||
def test_page_requires_auth_when_dev(self):
|
||||
"""Page in 'dev' status returns 403 for unauthenticated users."""
|
||||
resp = self.client.get('/tmtt-defect')
|
||||
# 403 because page_status is 'dev' and user is not admin
|
||||
self.assertIn(resp.status_code, [200, 403])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
287
tests/test_tmtt_defect_service.py
Normal file
287
tests/test_tmtt_defect_service.py
Normal file
@@ -0,0 +1,287 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Unit tests for TMTT Defect Analysis Service."""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from mes_dashboard.services.tmtt_defect_service import (
|
||||
_build_kpi,
|
||||
_build_chart_data,
|
||||
_build_all_charts,
|
||||
_build_detail_table,
|
||||
_validate_date_range,
|
||||
query_tmtt_defect_analysis,
|
||||
PRINT_DEFECT,
|
||||
LEAD_DEFECT,
|
||||
)
|
||||
|
||||
|
||||
def _make_df(rows):
|
||||
"""Helper to create test DataFrame from list of dicts."""
|
||||
cols = [
|
||||
'CONTAINERID', 'CONTAINERNAME', 'PJ_TYPE', 'PRODUCTLINENAME',
|
||||
'WORKFLOW', 'FINISHEDRUNCARD', 'TMTT_EQUIPMENTID',
|
||||
'TMTT_EQUIPMENTNAME', 'TRACKINQTY', 'TRACKINTIMESTAMP',
|
||||
'MOLD_EQUIPMENTID', 'MOLD_EQUIPMENTNAME',
|
||||
'LOSSREASONNAME', 'REJECTQTY',
|
||||
]
|
||||
if not rows:
|
||||
return pd.DataFrame(columns=cols)
|
||||
df = pd.DataFrame(rows)
|
||||
for c in cols:
|
||||
if c not in df.columns:
|
||||
df[c] = None
|
||||
return df
|
||||
|
||||
|
||||
class TestValidateDateRange(unittest.TestCase):
|
||||
"""Test date range validation."""
|
||||
|
||||
def test_valid_range(self):
|
||||
self.assertIsNone(_validate_date_range('2025-01-01', '2025-01-31'))
|
||||
|
||||
def test_invalid_format(self):
|
||||
result = _validate_date_range('2025/01/01', '2025-01-31')
|
||||
self.assertIn('格式', result)
|
||||
|
||||
def test_start_after_end(self):
|
||||
result = _validate_date_range('2025-02-01', '2025-01-01')
|
||||
self.assertIn('不能晚於', result)
|
||||
|
||||
def test_exceeds_max_days(self):
|
||||
result = _validate_date_range('2025-01-01', '2025-12-31')
|
||||
self.assertIn('180', result)
|
||||
|
||||
def test_exactly_max_days(self):
|
||||
self.assertIsNone(_validate_date_range('2025-01-01', '2025-06-30'))
|
||||
|
||||
|
||||
class TestBuildKpi(unittest.TestCase):
|
||||
"""Test KPI calculation with separate defect rates."""
|
||||
|
||||
def test_empty_dataframe(self):
|
||||
df = _make_df([])
|
||||
kpi = _build_kpi(df)
|
||||
self.assertEqual(kpi['total_input'], 0)
|
||||
self.assertEqual(kpi['lot_count'], 0)
|
||||
self.assertEqual(kpi['print_defect_qty'], 0)
|
||||
self.assertEqual(kpi['lead_defect_qty'], 0)
|
||||
self.assertEqual(kpi['print_defect_rate'], 0.0)
|
||||
self.assertEqual(kpi['lead_defect_rate'], 0.0)
|
||||
|
||||
def test_single_lot_no_defects(self):
|
||||
df = _make_df([{
|
||||
'CONTAINERID': 'A001', 'TRACKINQTY': 100,
|
||||
'LOSSREASONNAME': None, 'REJECTQTY': 0,
|
||||
}])
|
||||
kpi = _build_kpi(df)
|
||||
self.assertEqual(kpi['total_input'], 100)
|
||||
self.assertEqual(kpi['lot_count'], 1)
|
||||
self.assertEqual(kpi['print_defect_qty'], 0)
|
||||
self.assertEqual(kpi['lead_defect_qty'], 0)
|
||||
|
||||
def test_separate_defect_rates(self):
|
||||
"""A LOT with both print and lead defects - rates calculated separately."""
|
||||
df = _make_df([
|
||||
{'CONTAINERID': 'A001', 'TRACKINQTY': 10000,
|
||||
'LOSSREASONNAME': PRINT_DEFECT, 'REJECTQTY': 50},
|
||||
{'CONTAINERID': 'A001', 'TRACKINQTY': 10000,
|
||||
'LOSSREASONNAME': LEAD_DEFECT, 'REJECTQTY': 30},
|
||||
])
|
||||
kpi = _build_kpi(df)
|
||||
# INPUT should be deduplicated (10000, not 20000)
|
||||
self.assertEqual(kpi['total_input'], 10000)
|
||||
self.assertEqual(kpi['lot_count'], 1)
|
||||
self.assertEqual(kpi['print_defect_qty'], 50)
|
||||
self.assertEqual(kpi['lead_defect_qty'], 30)
|
||||
self.assertAlmostEqual(kpi['print_defect_rate'], 0.5, places=4)
|
||||
self.assertAlmostEqual(kpi['lead_defect_rate'], 0.3, places=4)
|
||||
|
||||
def test_multiple_lots(self):
|
||||
df = _make_df([
|
||||
{'CONTAINERID': 'A001', 'TRACKINQTY': 100,
|
||||
'LOSSREASONNAME': PRINT_DEFECT, 'REJECTQTY': 2},
|
||||
{'CONTAINERID': 'A002', 'TRACKINQTY': 200,
|
||||
'LOSSREASONNAME': LEAD_DEFECT, 'REJECTQTY': 1},
|
||||
{'CONTAINERID': 'A003', 'TRACKINQTY': 300,
|
||||
'LOSSREASONNAME': None, 'REJECTQTY': 0},
|
||||
])
|
||||
kpi = _build_kpi(df)
|
||||
self.assertEqual(kpi['total_input'], 600)
|
||||
self.assertEqual(kpi['lot_count'], 3)
|
||||
self.assertEqual(kpi['print_defect_qty'], 2)
|
||||
self.assertEqual(kpi['lead_defect_qty'], 1)
|
||||
|
||||
|
||||
class TestBuildChartData(unittest.TestCase):
|
||||
"""Test Pareto chart data aggregation."""
|
||||
|
||||
def test_empty_dataframe(self):
|
||||
df = _make_df([])
|
||||
result = _build_chart_data(df, 'PJ_TYPE')
|
||||
self.assertEqual(result, [])
|
||||
|
||||
def test_single_dimension_value(self):
|
||||
df = _make_df([
|
||||
{'CONTAINERID': 'A001', 'TRACKINQTY': 100, 'PJ_TYPE': 'TypeA',
|
||||
'LOSSREASONNAME': PRINT_DEFECT, 'REJECTQTY': 5},
|
||||
{'CONTAINERID': 'A001', 'TRACKINQTY': 100, 'PJ_TYPE': 'TypeA',
|
||||
'LOSSREASONNAME': LEAD_DEFECT, 'REJECTQTY': 3},
|
||||
])
|
||||
result = _build_chart_data(df, 'PJ_TYPE')
|
||||
self.assertEqual(len(result), 1)
|
||||
self.assertEqual(result[0]['name'], 'TypeA')
|
||||
self.assertEqual(result[0]['print_defect_qty'], 5)
|
||||
self.assertEqual(result[0]['lead_defect_qty'], 3)
|
||||
self.assertEqual(result[0]['total_defect_qty'], 8)
|
||||
self.assertAlmostEqual(result[0]['cumulative_pct'], 100.0)
|
||||
|
||||
def test_null_dimension_grouped_as_unknown(self):
|
||||
df = _make_df([
|
||||
{'CONTAINERID': 'A001', 'TRACKINQTY': 100, 'MOLD_EQUIPMENTNAME': None,
|
||||
'LOSSREASONNAME': PRINT_DEFECT, 'REJECTQTY': 2},
|
||||
])
|
||||
result = _build_chart_data(df, 'MOLD_EQUIPMENTNAME')
|
||||
self.assertEqual(len(result), 1)
|
||||
self.assertEqual(result[0]['name'], '(未知)')
|
||||
|
||||
def test_sorted_by_total_defect_desc(self):
|
||||
df = _make_df([
|
||||
{'CONTAINERID': 'A001', 'TRACKINQTY': 100, 'PJ_TYPE': 'TypeA',
|
||||
'LOSSREASONNAME': PRINT_DEFECT, 'REJECTQTY': 1},
|
||||
{'CONTAINERID': 'A002', 'TRACKINQTY': 100, 'PJ_TYPE': 'TypeB',
|
||||
'LOSSREASONNAME': PRINT_DEFECT, 'REJECTQTY': 10},
|
||||
])
|
||||
result = _build_chart_data(df, 'PJ_TYPE')
|
||||
self.assertEqual(result[0]['name'], 'TypeB')
|
||||
self.assertEqual(result[1]['name'], 'TypeA')
|
||||
|
||||
def test_cumulative_percentage(self):
|
||||
df = _make_df([
|
||||
{'CONTAINERID': 'A001', 'TRACKINQTY': 100, 'PJ_TYPE': 'TypeA',
|
||||
'LOSSREASONNAME': PRINT_DEFECT, 'REJECTQTY': 6},
|
||||
{'CONTAINERID': 'A002', 'TRACKINQTY': 100, 'PJ_TYPE': 'TypeB',
|
||||
'LOSSREASONNAME': PRINT_DEFECT, 'REJECTQTY': 4},
|
||||
])
|
||||
result = _build_chart_data(df, 'PJ_TYPE')
|
||||
# TypeA: 6/10 = 60%, TypeB: cumulative 10/10 = 100%
|
||||
self.assertAlmostEqual(result[0]['cumulative_pct'], 60.0)
|
||||
self.assertAlmostEqual(result[1]['cumulative_pct'], 100.0)
|
||||
|
||||
|
||||
class TestBuildAllCharts(unittest.TestCase):
|
||||
"""Test all 5 chart dimensions are built."""
|
||||
|
||||
def test_returns_all_dimensions(self):
|
||||
df = _make_df([{
|
||||
'CONTAINERID': 'A001', 'TRACKINQTY': 100,
|
||||
'WORKFLOW': 'WF1', 'PRODUCTLINENAME': 'PKG1',
|
||||
'PJ_TYPE': 'T1', 'TMTT_EQUIPMENTNAME': 'TMTT-1',
|
||||
'MOLD_EQUIPMENTNAME': 'MOLD-1',
|
||||
'LOSSREASONNAME': PRINT_DEFECT, 'REJECTQTY': 1,
|
||||
}])
|
||||
charts = _build_all_charts(df)
|
||||
self.assertIn('by_workflow', charts)
|
||||
self.assertIn('by_package', charts)
|
||||
self.assertIn('by_type', charts)
|
||||
self.assertIn('by_tmtt_machine', charts)
|
||||
self.assertIn('by_mold_machine', charts)
|
||||
|
||||
|
||||
class TestBuildDetailTable(unittest.TestCase):
|
||||
"""Test detail table building."""
|
||||
|
||||
def test_empty_dataframe(self):
|
||||
df = _make_df([])
|
||||
result = _build_detail_table(df)
|
||||
self.assertEqual(result, [])
|
||||
|
||||
def test_single_lot_aggregated(self):
|
||||
"""LOT with both defect types should produce one row."""
|
||||
df = _make_df([
|
||||
{'CONTAINERID': 'A001', 'CONTAINERNAME': 'LOT-001',
|
||||
'TRACKINQTY': 100, 'PJ_TYPE': 'T1', 'PRODUCTLINENAME': 'P1',
|
||||
'WORKFLOW': 'WF1', 'FINISHEDRUNCARD': 'RC001',
|
||||
'TMTT_EQUIPMENTNAME': 'TMTT-1', 'MOLD_EQUIPMENTNAME': 'MOLD-1',
|
||||
'LOSSREASONNAME': PRINT_DEFECT, 'REJECTQTY': 5},
|
||||
{'CONTAINERID': 'A001', 'CONTAINERNAME': 'LOT-001',
|
||||
'TRACKINQTY': 100, 'PJ_TYPE': 'T1', 'PRODUCTLINENAME': 'P1',
|
||||
'WORKFLOW': 'WF1', 'FINISHEDRUNCARD': 'RC001',
|
||||
'TMTT_EQUIPMENTNAME': 'TMTT-1', 'MOLD_EQUIPMENTNAME': 'MOLD-1',
|
||||
'LOSSREASONNAME': LEAD_DEFECT, 'REJECTQTY': 3},
|
||||
])
|
||||
result = _build_detail_table(df)
|
||||
self.assertEqual(len(result), 1)
|
||||
row = result[0]
|
||||
self.assertEqual(row['CONTAINERNAME'], 'LOT-001')
|
||||
self.assertEqual(row['INPUT_QTY'], 100)
|
||||
self.assertEqual(row['PRINT_DEFECT_QTY'], 5)
|
||||
self.assertEqual(row['LEAD_DEFECT_QTY'], 3)
|
||||
self.assertAlmostEqual(row['PRINT_DEFECT_RATE'], 5.0, places=4)
|
||||
self.assertAlmostEqual(row['LEAD_DEFECT_RATE'], 3.0, places=4)
|
||||
|
||||
def test_lot_with_no_defects(self):
|
||||
df = _make_df([{
|
||||
'CONTAINERID': 'A001', 'CONTAINERNAME': 'LOT-001',
|
||||
'TRACKINQTY': 100, 'PJ_TYPE': 'T1',
|
||||
'LOSSREASONNAME': None, 'REJECTQTY': 0,
|
||||
}])
|
||||
result = _build_detail_table(df)
|
||||
self.assertEqual(len(result), 1)
|
||||
self.assertEqual(result[0]['PRINT_DEFECT_QTY'], 0)
|
||||
self.assertEqual(result[0]['LEAD_DEFECT_QTY'], 0)
|
||||
|
||||
|
||||
class TestQueryTmttDefectAnalysis(unittest.TestCase):
|
||||
"""Test the main entry point function."""
|
||||
|
||||
def setUp(self):
|
||||
from mes_dashboard.core import database as db
|
||||
db._ENGINE = None
|
||||
|
||||
@patch('mes_dashboard.services.tmtt_defect_service.cache_get', return_value=None)
|
||||
@patch('mes_dashboard.services.tmtt_defect_service.cache_set')
|
||||
@patch('mes_dashboard.services.tmtt_defect_service._fetch_base_data')
|
||||
def test_valid_query(self, mock_fetch, mock_cache_set, mock_cache_get):
|
||||
mock_fetch.return_value = _make_df([{
|
||||
'CONTAINERID': 'A001', 'CONTAINERNAME': 'LOT-001',
|
||||
'TRACKINQTY': 100, 'PJ_TYPE': 'T1', 'PRODUCTLINENAME': 'P1',
|
||||
'WORKFLOW': 'WF1', 'FINISHEDRUNCARD': 'RC001',
|
||||
'TMTT_EQUIPMENTNAME': 'TMTT-1', 'MOLD_EQUIPMENTNAME': 'MOLD-1',
|
||||
'LOSSREASONNAME': PRINT_DEFECT, 'REJECTQTY': 2,
|
||||
}])
|
||||
|
||||
result = query_tmtt_defect_analysis('2025-01-01', '2025-01-31')
|
||||
self.assertIn('kpi', result)
|
||||
self.assertIn('charts', result)
|
||||
self.assertIn('detail', result)
|
||||
self.assertNotIn('error', result)
|
||||
mock_cache_set.assert_called_once()
|
||||
|
||||
def test_invalid_dates(self):
|
||||
result = query_tmtt_defect_analysis('invalid', '2025-01-31')
|
||||
self.assertIn('error', result)
|
||||
|
||||
def test_exceeds_max_days(self):
|
||||
result = query_tmtt_defect_analysis('2025-01-01', '2025-12-31')
|
||||
self.assertIn('error', result)
|
||||
self.assertIn('180', result['error'])
|
||||
|
||||
@patch('mes_dashboard.services.tmtt_defect_service.cache_get')
|
||||
def test_cache_hit(self, mock_cache_get):
|
||||
cached_data = {'kpi': {}, 'charts': {}, 'detail': []}
|
||||
mock_cache_get.return_value = cached_data
|
||||
result = query_tmtt_defect_analysis('2025-01-01', '2025-01-31')
|
||||
self.assertEqual(result, cached_data)
|
||||
|
||||
@patch('mes_dashboard.services.tmtt_defect_service.cache_get', return_value=None)
|
||||
@patch('mes_dashboard.services.tmtt_defect_service._fetch_base_data', return_value=None)
|
||||
def test_query_failure(self, mock_fetch, mock_cache_get):
|
||||
result = query_tmtt_defect_analysis('2025-01-01', '2025-01-31')
|
||||
self.assertIsNone(result)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user