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:
beabigegg
2026-02-06 20:18:04 +08:00
parent e5504dea26
commit 32f3e18e9d
17 changed files with 2003 additions and 28 deletions

View File

@@ -49,6 +49,11 @@
"route": "/query-tool",
"name": "批次追蹤工具",
"status": "released"
},
{
"route": "/tmtt-defect",
"name": "TMTT印字腳型不良分析",
"status": "dev"
}
],
"api_public": true,

View File

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

View File

@@ -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.WORKFLOWNAMEfallback 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 只有 EQUIPMENTNAMETMTT 機台識別以 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` |

View File

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

View File

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

View 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'
}
)

View File

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

View 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

View File

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

View File

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

View File

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

View 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

View File

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

View File

@@ -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 %}

View 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()">&times;</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">&#128202;</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 %}

View 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()

View 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()