feat: WIP Detail 頁面新增 Lot 詳細資訊面板

- 點選 Lot Details 表格中的 LOT ID 可展開詳細資訊面板
- 新增 /api/wip/lot/<lotid> API 端點
- 詳細資訊欄位名稱採用 PowerBI 命名慣例
- 修正 numpy 型別 JSON 序列化問題

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
beabigegg
2026-02-02 14:51:06 +08:00
parent 76d71d69ff
commit 2f50680eaf
3 changed files with 670 additions and 3 deletions

View File

@@ -18,6 +18,7 @@ from mes_dashboard.services.wip_service import (
search_lot_ids, search_lot_ids,
search_packages, search_packages,
search_types, search_types,
get_lot_detail,
) )
# Create Blueprint # Create Blueprint
@@ -213,6 +214,23 @@ def api_detail(workcenter: str):
return jsonify({'success': False, 'error': '查詢失敗'}), 500 return jsonify({'success': False, 'error': '查詢失敗'}), 500
@wip_bp.route('/lot/<lotid>')
def api_lot_detail(lotid: str):
"""API: Get detailed information for a specific lot.
Args:
lotid: LOTID (URL path parameter)
Returns:
JSON with lot details including all fields from DW_MES_LOT_V
"""
result = get_lot_detail(lotid)
if result is not None:
return jsonify({'success': True, 'data': result})
return jsonify({'success': False, 'error': '找不到此批號'}), 404
# ============================================================ # ============================================================
# Meta APIs # Meta APIs
# ============================================================ # ============================================================

View File

@@ -20,9 +20,12 @@ logger = logging.getLogger('mes_dashboard.wip_service')
def _safe_value(val): def _safe_value(val):
"""Convert pandas NaN/NaT to None for JSON serialization.""" """Convert pandas NaN/NaT to None and numpy types to native Python types for JSON serialization."""
if pd.isna(val): if pd.isna(val):
return None return None
# Convert numpy types to native Python types for JSON serialization
if hasattr(val, 'item'): # numpy scalar types have .item() method
return val.item()
return val return val
@@ -2145,3 +2148,276 @@ def _get_hold_detail_lots_from_oracle(
import traceback import traceback
traceback.print_exc() traceback.print_exc()
return None return None
# ============================================================
# Lot Detail API Functions
# ============================================================
# Field labels mapping for lot detail display (PowerBI naming convention)
LOT_DETAIL_FIELD_LABELS = {
'lotId': 'Run Card Lot ID',
'workorder': 'Work Order ID',
'qty': 'Lot Qty(pcs)',
'qty2': 'Lot Qty(Wafer pcs)',
'status': 'Run Card Status',
'holdReason': 'Hold Reason',
'holdCount': 'Hold Count',
'owner': 'Work Order Owner',
'startDate': 'Run Card Start Date',
'uts': 'UTS',
'product': 'Product P/N',
'productLine': 'Package',
'packageLef': 'Package(LF)',
'pjFunction': 'Product Function',
'pjType': 'Product Type',
'bop': 'BOP',
'waferLotId': 'Wafer Lot ID',
'waferPn': 'Wafer P/N',
'waferLotPrefix': 'Wafer Lot ID(Prefix)',
'spec': 'Spec',
'specSequence': 'Spec Sequence',
'workcenter': 'Work Center',
'workcenterSequence': 'Work Center Sequence',
'workcenterGroup': 'Work Center(Group)',
'workcenterShort': 'Work Center(Short)',
'ageByDays': 'Age By Days',
'equipment': 'Equipment ID',
'equipmentCount': 'Equipment Count',
'workflow': 'Work Flow Name',
'dateCode': 'Product Date Code',
'leadframeName': 'LF Material Part',
'leadframeOption': 'LF Option ID',
'compoundName': 'Compound Material Part',
'location': 'Run Card Location',
'ncrId': 'NCR ID',
'ncrDate': 'NCR-issued Time',
'releaseTime': 'Release Time',
'releaseEmp': 'Release Employee',
'releaseComment': 'Release Comment',
'holdComment': 'Hold Comment',
'comment': 'Comment',
'commentDate': 'Run Card Comment',
'commentEmp': 'Run Card Comment Employee',
'futureHoldComment': 'Future Hold Comment',
'holdEmp': 'Hold Employee',
'holdDept': 'Hold Employee Dept',
'produceRegion': 'Produce Region',
'priority': 'Work Order Priority',
'tmttRemaining': 'TMTT Remaining',
'dieConsumption': 'Die Consumption Qty',
'wipStatus': 'WIP Status',
'dataUpdateDate': 'Data Update Date'
}
def get_lot_detail(lotid: str) -> Optional[Dict[str, Any]]:
"""Get detailed information for a specific lot.
Uses Redis cache when available, falls back to Oracle direct query.
Args:
lotid: The LOTID to retrieve
Returns:
Dict with lot details or None if not found
"""
# Try cache first
cached_df = _get_wip_dataframe()
if cached_df is not None:
try:
df = cached_df[cached_df['LOTID'] == lotid]
if df.empty:
return None
row = df.iloc[0]
return _build_lot_detail_response(row)
except Exception as exc:
logger.warning(f"Cache-based lot detail failed, falling back to Oracle: {exc}")
# Fallback to Oracle direct query
return _get_lot_detail_from_oracle(lotid)
def _get_lot_detail_from_oracle(lotid: str) -> Optional[Dict[str, Any]]:
"""Get lot detail directly from Oracle (fallback)."""
try:
sql = f"""
SELECT
LOTID,
WORKORDER,
QTY,
QTY2,
STATUS,
HOLDREASONNAME,
CURRENTHOLDCOUNT,
OWNER,
STARTDATE,
UTS,
PRODUCT,
PRODUCTLINENAME,
PACKAGE_LEF,
PJ_FUNCTION,
PJ_TYPE,
BOP,
FIRSTNAME,
WAFERNAME,
WAFERLOT,
SPECNAME,
SPECSEQUENCE,
WORKCENTERNAME,
WORKCENTERSEQUENCE,
WORKCENTER_GROUP,
WORKCENTER_SHORT,
AGEBYDAYS,
EQUIPMENTS,
EQUIPMENTCOUNT,
WORKFLOWNAME,
DATECODE,
LEADFRAMENAME,
LEADFRAMEOPTION,
COMNAME,
LOCATIONNAME,
EVENTNAME,
OCCURRENCEDATE,
RELEASETIME,
RELEASEEMP,
RELEASEREASON,
COMMENT_HOLD,
CONTAINERCOMMENTS,
COMMENT_DATE,
COMMENT_EMP,
COMMENT_FUTURE,
HOLDEMP,
DEPTNAME,
PJ_PRODUCEREGION,
PRIORITYCODENAME,
TMTT_R,
WAFER_FACTOR,
SYS_DATE
FROM {WIP_VIEW}
WHERE LOTID = '{_escape_sql(lotid)}'
"""
df = read_sql_df(sql)
if df is None or df.empty:
return None
row = df.iloc[0]
return _build_lot_detail_response(row)
except Exception as exc:
logger.error(f"Lot detail query failed: {exc}")
import traceback
traceback.print_exc()
return None
def _build_lot_detail_response(row) -> Dict[str, Any]:
"""Build lot detail response from DataFrame row."""
# Helper to safely get value from row (handles NaN and missing columns)
def safe_get(col, default=None):
try:
val = row.get(col)
if pd.isna(val):
return default
return val
except Exception:
return default
# Helper to safely get int value
def safe_int(col, default=0):
val = safe_get(col)
if val is None:
return default
try:
return int(val)
except (ValueError, TypeError):
return default
# Helper to safely get float value
def safe_float(col, default=0.0):
val = safe_get(col)
if val is None:
return default
try:
return float(val)
except (ValueError, TypeError):
return default
# Helper to format date value
def format_date(col):
val = safe_get(col)
if val is None:
return None
try:
return str(val)
except Exception:
return None
# Compute WIP status
equipment_count = safe_int('EQUIPMENTCOUNT')
hold_count = safe_int('CURRENTHOLDCOUNT')
if equipment_count > 0:
wip_status = 'RUN'
elif hold_count > 0:
wip_status = 'HOLD'
else:
wip_status = 'QUEUE'
return {
'lotId': _safe_value(safe_get('LOTID')),
'workorder': _safe_value(safe_get('WORKORDER')),
'qty': safe_int('QTY'),
'qty2': safe_int('QTY2') if safe_get('QTY2') is not None else None,
'status': _safe_value(safe_get('STATUS')),
'holdReason': _safe_value(safe_get('HOLDREASONNAME')),
'holdCount': hold_count,
'owner': _safe_value(safe_get('OWNER')),
'startDate': format_date('STARTDATE'),
'uts': _safe_value(safe_get('UTS')),
'product': _safe_value(safe_get('PRODUCT')),
'productLine': _safe_value(safe_get('PRODUCTLINENAME')),
'packageLef': _safe_value(safe_get('PACKAGE_LEF')),
'pjFunction': _safe_value(safe_get('PJ_FUNCTION')),
'pjType': _safe_value(safe_get('PJ_TYPE')),
'bop': _safe_value(safe_get('BOP')),
'waferLotId': _safe_value(safe_get('FIRSTNAME')),
'waferPn': _safe_value(safe_get('WAFERNAME')),
'waferLotPrefix': _safe_value(safe_get('WAFERLOT')),
'spec': _safe_value(safe_get('SPECNAME')),
'specSequence': safe_int('SPECSEQUENCE') if safe_get('SPECSEQUENCE') is not None else None,
'workcenter': _safe_value(safe_get('WORKCENTERNAME')),
'workcenterSequence': safe_int('WORKCENTERSEQUENCE') if safe_get('WORKCENTERSEQUENCE') is not None else None,
'workcenterGroup': _safe_value(safe_get('WORKCENTER_GROUP')),
'workcenterShort': _safe_value(safe_get('WORKCENTER_SHORT')),
'ageByDays': round(safe_float('AGEBYDAYS'), 2),
'equipment': _safe_value(safe_get('EQUIPMENTS')),
'equipmentCount': equipment_count,
'workflow': _safe_value(safe_get('WORKFLOWNAME')),
'dateCode': _safe_value(safe_get('DATECODE')),
'leadframeName': _safe_value(safe_get('LEADFRAMENAME')),
'leadframeOption': _safe_value(safe_get('LEADFRAMEOPTION')),
'compoundName': _safe_value(safe_get('COMNAME')),
'location': _safe_value(safe_get('LOCATIONNAME')),
'ncrId': _safe_value(safe_get('EVENTNAME')),
'ncrDate': format_date('OCCURRENCEDATE'),
'releaseTime': format_date('RELEASETIME'),
'releaseEmp': _safe_value(safe_get('RELEASEEMP')),
'releaseComment': _safe_value(safe_get('RELEASEREASON')),
'holdComment': _safe_value(safe_get('COMMENT_HOLD')),
'comment': _safe_value(safe_get('CONTAINERCOMMENTS')),
'commentDate': _safe_value(safe_get('COMMENT_DATE')),
'commentEmp': _safe_value(safe_get('COMMENT_EMP')),
'futureHoldComment': _safe_value(safe_get('COMMENT_FUTURE')),
'holdEmp': _safe_value(safe_get('HOLDEMP')),
'holdDept': _safe_value(safe_get('DEPTNAME')),
'produceRegion': _safe_value(safe_get('PJ_PRODUCEREGION')),
'priority': _safe_value(safe_get('PRIORITYCODENAME')),
'tmttRemaining': _safe_value(safe_get('TMTT_R')),
'dieConsumption': safe_int('WAFER_FACTOR') if safe_get('WAFER_FACTOR') is not None else None,
'wipStatus': wip_status,
'dataUpdateDate': format_date('SYS_DATE'),
'fieldLabels': LOT_DETAIL_FIELD_LABELS
}

View File

@@ -614,11 +614,163 @@
color: var(--muted); color: var(--muted);
} }
/* Lot Detail Panel */
.lot-detail-panel {
background: var(--card-bg);
border-radius: 10px;
box-shadow: var(--shadow);
margin-top: 16px;
overflow: hidden;
display: none;
}
.lot-detail-panel.show {
display: block;
}
.lot-detail-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 20px;
border-bottom: 1px solid var(--border);
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.lot-detail-title {
font-size: 16px;
font-weight: 600;
color: #fff;
display: flex;
align-items: center;
gap: 10px;
}
.lot-detail-title .lot-id {
background: rgba(255,255,255,0.2);
padding: 4px 12px;
border-radius: 6px;
font-family: monospace;
}
.lot-detail-close {
background: rgba(255,255,255,0.2);
border: none;
color: white;
padding: 6px 12px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
transition: background 0.2s;
}
.lot-detail-close:hover {
background: rgba(255,255,255,0.3);
}
.lot-detail-content {
padding: 20px;
}
.lot-detail-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
}
.lot-detail-section {
background: #f8f9fa;
border-radius: 8px;
padding: 14px;
}
.lot-detail-section-title {
font-size: 13px;
font-weight: 600;
color: var(--primary);
margin-bottom: 12px;
padding-bottom: 6px;
border-bottom: 2px solid var(--primary);
}
.lot-detail-field {
display: flex;
flex-direction: column;
margin-bottom: 10px;
}
.lot-detail-field:last-child {
margin-bottom: 0;
}
.lot-detail-label {
font-size: 11px;
color: var(--muted);
margin-bottom: 2px;
}
.lot-detail-value {
font-size: 13px;
color: var(--text);
word-break: break-word;
}
.lot-detail-value.empty {
color: #ccc;
}
.lot-detail-value.status-run {
color: #166534;
font-weight: 600;
}
.lot-detail-value.status-queue {
color: #92400E;
font-weight: 600;
}
.lot-detail-value.status-hold {
color: #991B1B;
font-weight: 600;
}
.lot-detail-loading {
text-align: center;
padding: 40px;
color: var(--muted);
}
.lot-detail-loading .loading-spinner {
margin-right: 8px;
}
/* Clickable LOT ID in table */
.lot-id-link {
color: var(--primary);
cursor: pointer;
text-decoration: none;
transition: color 0.2s;
}
.lot-id-link:hover {
color: var(--primary-dark);
text-decoration: underline;
}
.lot-id-link.active {
background: rgba(102, 126, 234, 0.15);
padding: 2px 6px;
border-radius: 4px;
}
/* Responsive */ /* Responsive */
@media (max-width: 1400px) { @media (max-width: 1400px) {
.summary-row { .summary-row {
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
} }
.lot-detail-grid {
grid-template-columns: repeat(2, 1fr);
}
} }
@media (max-width: 1000px) { @media (max-width: 1000px) {
@@ -638,6 +790,9 @@
.summary-row { .summary-row {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.lot-detail-grid {
grid-template-columns: 1fr;
}
} }
</style> </style>
{% endblock %} {% endblock %}
@@ -734,6 +889,21 @@
<button id="btnNext" onclick="nextPage()">Next</button> <button id="btnNext" onclick="nextPage()">Next</button>
</div> </div>
</div> </div>
<!-- Lot Detail Panel -->
<div class="lot-detail-panel" id="lotDetailPanel">
<div class="lot-detail-header">
<div class="lot-detail-title">
Lot Detail - <span class="lot-id" id="lotDetailLotId"></span>
</div>
<button class="lot-detail-close" onclick="closeLotDetail()">Close</button>
</div>
<div class="lot-detail-content" id="lotDetailContent">
<div class="lot-detail-loading">
<span class="loading-spinner"></span>Loading...
</div>
</div>
</div>
</div> </div>
<!-- Loading Overlay --> <!-- Loading Overlay -->
@@ -956,8 +1126,11 @@
data.lots.forEach(lot => { data.lots.forEach(lot => {
html += '<tr>'; html += '<tr>';
// Fixed columns // Fixed columns - LOT ID is clickable
html += `<td class="fixed-col">${lot.lotId || '-'}</td>`; const lotIdDisplay = lot.lotId
? `<span class="lot-id-link" onclick="showLotDetail('${lot.lotId}')">${lot.lotId}</span>`
: '-';
html += `<td class="fixed-col">${lotIdDisplay}</td>`;
html += `<td class="fixed-col">${lot.equipment || '<span style="color: var(--muted);">-</span>'}</td>`; html += `<td class="fixed-col">${lot.equipment || '<span style="color: var(--muted);">-</span>'}</td>`;
// WIP Status with color and hold reason // WIP Status with color and hold reason
@@ -1329,6 +1502,206 @@
loadAllData(false); loadAllData(false);
} }
// ============================================================
// Lot Detail Functions
// ============================================================
let selectedLotId = null;
async function fetchLotDetail(lotId) {
const result = await MesApi.get(`/api/wip/lot/${encodeURIComponent(lotId)}`, {
timeout: API_TIMEOUT
});
if (result.success) {
return result.data;
}
throw new Error(result.error || 'Failed to fetch lot detail');
}
async function showLotDetail(lotId) {
// Update selected state
selectedLotId = lotId;
// Highlight the selected row
document.querySelectorAll('.lot-id-link').forEach(el => {
el.classList.toggle('active', el.textContent === lotId);
});
// Show panel
const panel = document.getElementById('lotDetailPanel');
panel.classList.add('show');
// Update title
document.getElementById('lotDetailLotId').textContent = lotId;
// Show loading
document.getElementById('lotDetailContent').innerHTML = `
<div class="lot-detail-loading">
<span class="loading-spinner"></span>Loading...
</div>
`;
// Scroll to panel
panel.scrollIntoView({ behavior: 'smooth', block: 'start' });
try {
const data = await fetchLotDetail(lotId);
renderLotDetail(data);
} catch (error) {
console.error('Failed to load lot detail:', error);
document.getElementById('lotDetailContent').innerHTML = `
<div class="lot-detail-loading" style="color: var(--danger);">
載入失敗:${error.message || '未知錯誤'}
</div>
`;
}
}
function renderLotDetail(data) {
const labels = data.fieldLabels || {};
// Helper to format value
const formatValue = (value) => {
if (value === null || value === undefined || value === '') {
return '<span class="empty">-</span>';
}
if (typeof value === 'number') {
return formatNumber(value);
}
return value;
};
// Helper to create field HTML
const field = (key, customLabel = null) => {
const label = customLabel || labels[key] || key;
const value = data[key];
let valueClass = '';
// Special styling for WIP Status
if (key === 'wipStatus') {
valueClass = `status-${(value || '').toLowerCase()}`;
}
return `
<div class="lot-detail-field">
<span class="lot-detail-label">${label}</span>
<span class="lot-detail-value ${valueClass}">${formatValue(value)}</span>
</div>
`;
};
const html = `
<div class="lot-detail-grid">
<!-- Basic Info -->
<div class="lot-detail-section">
<div class="lot-detail-section-title">基本資訊</div>
${field('lotId')}
${field('workorder')}
${field('wipStatus')}
${field('status')}
${field('qty')}
${field('qty2')}
${field('ageByDays')}
${field('priority')}
</div>
<!-- Product Info -->
<div class="lot-detail-section">
<div class="lot-detail-section-title">產品資訊</div>
${field('product')}
${field('productLine')}
${field('packageLef')}
${field('pjType')}
${field('pjFunction')}
${field('bop')}
${field('dateCode')}
${field('produceRegion')}
</div>
<!-- Process Info -->
<div class="lot-detail-section">
<div class="lot-detail-section-title">製程資訊</div>
${field('workcenterGroup')}
${field('workcenter')}
${field('spec')}
${field('specSequence')}
${field('workflow')}
${field('equipment')}
${field('equipmentCount')}
${field('location')}
</div>
<!-- Material Info -->
<div class="lot-detail-section">
<div class="lot-detail-section-title">物料資訊</div>
${field('waferLotId')}
${field('waferPn')}
${field('waferLotPrefix')}
${field('leadframeName')}
${field('leadframeOption')}
${field('compoundName')}
${field('dieConsumption')}
${field('uts')}
</div>
<!-- Hold Info (if HOLD status) -->
${data.wipStatus === 'HOLD' || data.holdCount > 0 ? `
<div class="lot-detail-section">
<div class="lot-detail-section-title">Hold 資訊</div>
${field('holdReason')}
${field('holdCount')}
${field('holdEmp')}
${field('holdDept')}
${field('holdComment')}
${field('releaseTime')}
${field('releaseEmp')}
${field('releaseComment')}
</div>
` : ''}
<!-- NCR Info (if exists) -->
${data.ncrId ? `
<div class="lot-detail-section">
<div class="lot-detail-section-title">NCR 資訊</div>
${field('ncrId')}
${field('ncrDate')}
</div>
` : ''}
<!-- Comments -->
<div class="lot-detail-section">
<div class="lot-detail-section-title">備註資訊</div>
${field('comment')}
${field('commentDate')}
${field('commentEmp')}
${field('futureHoldComment')}
</div>
<!-- Other Info -->
<div class="lot-detail-section">
<div class="lot-detail-section-title">其他資訊</div>
${field('owner')}
${field('startDate')}
${field('tmttRemaining')}
${field('dataUpdateDate')}
</div>
</div>
`;
document.getElementById('lotDetailContent').innerHTML = html;
}
function closeLotDetail() {
const panel = document.getElementById('lotDetailPanel');
panel.classList.remove('show');
// Remove highlight from selected row
document.querySelectorAll('.lot-id-link').forEach(el => {
el.classList.remove('active');
});
selectedLotId = null;
}
// ============================================================ // ============================================================
// Initialize // Initialize
// ============================================================ // ============================================================