fix(query-tool): finalize raw-material tab/export and resolve ORA-00918
This commit is contained in:
@@ -16,9 +16,52 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: '無資料',
|
||||
},
|
||||
hiddenColumns: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
columnLabels: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
columnOrder: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const columns = computed(() => Object.keys(props.rows[0] || {}));
|
||||
const columns = computed(() => {
|
||||
const baseColumns = Object.keys(props.rows[0] || {});
|
||||
if (baseColumns.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const hidden = new Set((props.hiddenColumns || []).map((value) => String(value)));
|
||||
const visible = baseColumns.filter((column) => !hidden.has(column));
|
||||
|
||||
if (!Array.isArray(props.columnOrder) || props.columnOrder.length === 0) {
|
||||
return visible;
|
||||
}
|
||||
|
||||
const ordered = [];
|
||||
const seen = new Set();
|
||||
props.columnOrder.forEach((column) => {
|
||||
if (visible.includes(column) && !seen.has(column)) {
|
||||
ordered.push(column);
|
||||
seen.add(column);
|
||||
}
|
||||
});
|
||||
visible.forEach((column) => {
|
||||
if (!seen.has(column)) {
|
||||
ordered.push(column);
|
||||
}
|
||||
});
|
||||
return ordered;
|
||||
});
|
||||
|
||||
function resolveColumnLabel(column) {
|
||||
return props.columnLabels?.[column] || column;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -40,7 +83,7 @@ const columns = computed(() => Object.keys(props.rows[0] || {}));
|
||||
:key="column"
|
||||
class="whitespace-nowrap border-b border-stroke-soft px-2 py-1.5 text-left font-semibold"
|
||||
>
|
||||
{{ column }}
|
||||
{{ resolveColumnLabel(column) }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -69,7 +69,7 @@ const emit = defineEmits(['change-sub-tab', 'update-workcenter-groups', 'export-
|
||||
|
||||
const tabMeta = Object.freeze({
|
||||
history: { label: '歷程', emptyText: '無歷程資料' },
|
||||
materials: { label: '物料', emptyText: '無物料資料' },
|
||||
materials: { label: '原物料', emptyText: '無原物料資料' },
|
||||
rejects: { label: '報廢', emptyText: '無報廢資料' },
|
||||
holds: { label: 'Hold', emptyText: '無 Hold 資料' },
|
||||
jobs: { label: 'Job', emptyText: '無 Job 資料' },
|
||||
@@ -104,6 +104,39 @@ const activeEmptyText = computed(() => {
|
||||
return tabMeta[props.activeSubTab]?.emptyText || '無資料';
|
||||
});
|
||||
|
||||
const activeColumnLabels = computed(() => {
|
||||
if (props.activeSubTab !== 'materials') {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
CONTAINERNAME: 'LOT ID',
|
||||
};
|
||||
});
|
||||
|
||||
const activeHiddenColumns = computed(() => {
|
||||
if (props.activeSubTab !== 'materials') {
|
||||
return [];
|
||||
}
|
||||
return ['CONTAINERID'];
|
||||
});
|
||||
|
||||
const activeColumnOrder = computed(() => {
|
||||
if (props.activeSubTab !== 'materials') {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
'CONTAINERNAME',
|
||||
'MATERIALPARTNAME',
|
||||
'MATERIALLOTNAME',
|
||||
'QTYCONSUMED',
|
||||
'WORKCENTERNAME',
|
||||
'SPECNAME',
|
||||
'EQUIPMENTNAME',
|
||||
'TXNDATE',
|
||||
'WORKCENTER_GROUP',
|
||||
];
|
||||
});
|
||||
|
||||
const canExport = computed(() => {
|
||||
return !activeLoading.value && activeRows.value.length > 0;
|
||||
});
|
||||
@@ -194,6 +227,9 @@ const detailCountLabel = computed(() => {
|
||||
:rows="activeRows"
|
||||
:loading="activeLoading"
|
||||
:empty-text="activeLoaded ? activeEmptyText : '尚未查詢此分頁資料'"
|
||||
:column-labels="activeColumnLabels"
|
||||
:hidden-columns="activeHiddenColumns"
|
||||
:column-order="activeColumnOrder"
|
||||
/>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
@@ -348,7 +348,7 @@ def export_csv():
|
||||
|
||||
return Response(
|
||||
csv_content,
|
||||
mimetype='text/csv; charset=utf-8',
|
||||
mimetype='text/csv; charset=utf-8-sig',
|
||||
headers={
|
||||
'Content-Disposition': 'attachment; filename=query_result.csv'
|
||||
}
|
||||
|
||||
@@ -190,10 +190,10 @@ def export_jobs():
|
||||
return jsonify({'error': validation_error}), 400
|
||||
|
||||
# Stream CSV response
|
||||
return Response(
|
||||
export_jobs_with_history(resource_ids, start_date, end_date),
|
||||
mimetype='text/csv; charset=utf-8',
|
||||
headers={
|
||||
'Content-Disposition': 'attachment; filename=job_history_export.csv'
|
||||
}
|
||||
)
|
||||
return Response(
|
||||
export_jobs_with_history(resource_ids, start_date, end_date),
|
||||
mimetype='text/csv; charset=utf-8-sig',
|
||||
headers={
|
||||
'Content-Disposition': 'attachment; filename=job_history_export.csv'
|
||||
}
|
||||
)
|
||||
|
||||
@@ -40,7 +40,7 @@ from mes_dashboard.services.query_tool_service import (
|
||||
)
|
||||
|
||||
# Create Blueprint
|
||||
query_tool_bp = Blueprint('query_tool', __name__)
|
||||
query_tool_bp = Blueprint('query_tool', __name__)
|
||||
|
||||
_QUERY_TOOL_RESOLVE_RATE_LIMIT = configured_rate_limit(
|
||||
bucket="query-tool-resolve",
|
||||
@@ -77,13 +77,31 @@ _QUERY_TOOL_EQUIPMENT_RATE_LIMIT = configured_rate_limit(
|
||||
default_max_attempts=5,
|
||||
default_window_seconds=60,
|
||||
)
|
||||
_QUERY_TOOL_EXPORT_RATE_LIMIT = configured_rate_limit(
|
||||
_QUERY_TOOL_EXPORT_RATE_LIMIT = configured_rate_limit(
|
||||
bucket="query-tool-export",
|
||||
max_attempts_env="QT_EXPORT_RATE_MAX_REQUESTS",
|
||||
window_seconds_env="QT_EXPORT_RATE_WINDOW_SECONDS",
|
||||
default_max_attempts=3,
|
||||
default_window_seconds=60,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _format_lot_materials_export_rows(rows):
|
||||
"""Normalize LOT material export columns for UI/CSV consistency."""
|
||||
normalized_rows = []
|
||||
for row in rows or []:
|
||||
lot_id = row.get('CONTAINERNAME') or row.get('CONTAINERID') or ''
|
||||
normalized_rows.append({
|
||||
'LOT ID': lot_id,
|
||||
'MATERIALPARTNAME': row.get('MATERIALPARTNAME', ''),
|
||||
'MATERIALLOTNAME': row.get('MATERIALLOTNAME', ''),
|
||||
'QTYCONSUMED': row.get('QTYCONSUMED', ''),
|
||||
'WORKCENTERNAME': row.get('WORKCENTERNAME', ''),
|
||||
'SPECNAME': row.get('SPECNAME', ''),
|
||||
'EQUIPMENTNAME': row.get('EQUIPMENTNAME', ''),
|
||||
'TXNDATE': row.get('TXNDATE', ''),
|
||||
})
|
||||
return normalized_rows
|
||||
|
||||
|
||||
# ============================================================
|
||||
@@ -498,10 +516,10 @@ def export_csv():
|
||||
)
|
||||
filename = 'adjacent_lots.csv'
|
||||
|
||||
elif export_type == 'lot_materials':
|
||||
container_id = params.get('container_id')
|
||||
result = get_lot_materials(container_id)
|
||||
filename = f'lot_materials_{container_id}.csv'
|
||||
elif export_type == 'lot_materials':
|
||||
container_id = params.get('container_id')
|
||||
result = get_lot_materials(container_id)
|
||||
filename = f'lot_raw_materials_{container_id}.csv'
|
||||
|
||||
elif export_type == 'lot_rejects':
|
||||
container_id = params.get('container_id')
|
||||
@@ -589,18 +607,21 @@ def export_csv():
|
||||
error_msg = result.get('error', '查詢失敗') if result else '查詢失敗'
|
||||
return jsonify({'error': error_msg}), 400
|
||||
|
||||
export_data = result.get('data', [])
|
||||
if not export_data:
|
||||
return jsonify({'error': '查無資料'}), 404
|
||||
|
||||
# Stream CSV response
|
||||
return Response(
|
||||
generate_csv_stream(export_data),
|
||||
mimetype='text/csv; charset=utf-8',
|
||||
headers={
|
||||
'Content-Disposition': f'attachment; filename={filename}'
|
||||
}
|
||||
)
|
||||
export_data = result.get('data', [])
|
||||
if not export_data:
|
||||
return jsonify({'error': '查無資料'}), 404
|
||||
|
||||
if export_type == 'lot_materials':
|
||||
export_data = _format_lot_materials_export_rows(export_data)
|
||||
|
||||
# Stream CSV response
|
||||
return Response(
|
||||
generate_csv_stream(export_data),
|
||||
mimetype='text/csv; charset=utf-8-sig',
|
||||
headers={
|
||||
'Content-Disposition': f'attachment; filename={filename}'
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
return jsonify({'error': f'匯出失敗: {str(exc)}'}), 500
|
||||
|
||||
@@ -6,6 +6,7 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from typing import Any, Dict, List
|
||||
|
||||
@@ -28,7 +29,7 @@ _DOMAIN_SPECS: Dict[str, Dict[str, Any]] = {
|
||||
"default_window": 60,
|
||||
},
|
||||
"materials": {
|
||||
"filter_column": "CONTAINERID",
|
||||
"filter_column": "m.CONTAINERID",
|
||||
"cache_ttl": 300,
|
||||
"bucket": "event-materials",
|
||||
"max_env": "EVT_MATERIALS_RATE_MAX_REQUESTS",
|
||||
@@ -106,12 +107,31 @@ def _normalize_ids(container_ids: List[str]) -> List[str]:
|
||||
class EventFetcher:
|
||||
"""Fetches container-scoped event records with cache and batching."""
|
||||
|
||||
_CONTAINER_EQ_PARAM_PATTERN = re.compile(
|
||||
r"(?:[A-Za-z_][A-Za-z0-9_]*\.)?CONTAINERID\s*=\s*:container_id",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _cache_key(domain: str, container_ids: List[str]) -> str:
|
||||
normalized = sorted(_normalize_ids(container_ids))
|
||||
digest = hashlib.md5("|".join(normalized).encode("utf-8")).hexdigest()[:12]
|
||||
return f"evt:{domain}:{digest}"
|
||||
|
||||
@staticmethod
|
||||
def _replace_container_filter(sql: str, condition_sql: str) -> str:
|
||||
"""Replace single-CID predicate with batched predicate in domain SQL."""
|
||||
replaced_sql, replacements = EventFetcher._CONTAINER_EQ_PARAM_PATTERN.subn(
|
||||
condition_sql,
|
||||
sql,
|
||||
count=1,
|
||||
)
|
||||
if replacements == 0:
|
||||
logger.warning(
|
||||
"EventFetcher container filter replacement missed target predicate"
|
||||
)
|
||||
return replaced_sql
|
||||
|
||||
@staticmethod
|
||||
def _get_rate_limit_config(domain: str) -> Dict[str, int | str]:
|
||||
spec = _DOMAIN_SPECS.get(domain)
|
||||
@@ -133,20 +153,20 @@ class EventFetcher:
|
||||
|
||||
if domain == "history":
|
||||
sql = SQLLoader.load("query_tool/lot_history")
|
||||
sql = sql.replace("h.CONTAINERID = :container_id", condition_sql)
|
||||
sql = EventFetcher._replace_container_filter(sql, condition_sql)
|
||||
return sql.replace("{{ WORKCENTER_FILTER }}", "")
|
||||
|
||||
if domain == "materials":
|
||||
sql = SQLLoader.load("query_tool/lot_materials")
|
||||
return sql.replace("CONTAINERID = :container_id", condition_sql)
|
||||
return EventFetcher._replace_container_filter(sql, condition_sql)
|
||||
|
||||
if domain == "rejects":
|
||||
sql = SQLLoader.load("query_tool/lot_rejects")
|
||||
return sql.replace("CONTAINERID = :container_id", condition_sql)
|
||||
return EventFetcher._replace_container_filter(sql, condition_sql)
|
||||
|
||||
if domain == "holds":
|
||||
sql = SQLLoader.load("query_tool/lot_holds")
|
||||
return sql.replace("CONTAINERID = :container_id", condition_sql)
|
||||
return EventFetcher._replace_container_filter(sql, condition_sql)
|
||||
|
||||
if domain == "jobs":
|
||||
return f"""
|
||||
|
||||
@@ -526,6 +526,7 @@ def export_csv(
|
||||
|
||||
# Write CSV header
|
||||
output = io.StringIO()
|
||||
output.write('\ufeff') # UTF-8 BOM for Excel compatibility
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(headers)
|
||||
yield output.getvalue()
|
||||
|
||||
@@ -9,14 +9,17 @@
|
||||
-- Uses TXNDATE (NOT TXNDATETIME)
|
||||
|
||||
SELECT
|
||||
CONTAINERID,
|
||||
MATERIALPARTNAME,
|
||||
MATERIALLOTNAME,
|
||||
QTYCONSUMED,
|
||||
WORKCENTERNAME,
|
||||
SPECNAME,
|
||||
EQUIPMENTNAME,
|
||||
TXNDATE
|
||||
FROM DWH.DW_MES_LOTMATERIALSHISTORY
|
||||
WHERE CONTAINERID = :container_id
|
||||
ORDER BY TXNDATE
|
||||
m.CONTAINERID,
|
||||
c.CONTAINERNAME,
|
||||
m.MATERIALPARTNAME,
|
||||
m.MATERIALLOTNAME,
|
||||
m.QTYCONSUMED,
|
||||
m.WORKCENTERNAME,
|
||||
m.SPECNAME,
|
||||
m.EQUIPMENTNAME,
|
||||
m.TXNDATE
|
||||
FROM DWH.DW_MES_LOTMATERIALSHISTORY m
|
||||
LEFT JOIN DWH.DW_MES_CONTAINER c
|
||||
ON c.CONTAINERID = m.CONTAINERID
|
||||
WHERE m.CONTAINERID = :container_id
|
||||
ORDER BY m.TXNDATE
|
||||
|
||||
@@ -89,3 +89,26 @@ def test_fetch_events_history_branch_replaces_container_filter(
|
||||
assert "h.CONTAINERID = :container_id" not in sql
|
||||
assert "{{ WORKCENTER_FILTER }}" not in sql
|
||||
assert params == {"p0": "CID-1"}
|
||||
|
||||
|
||||
@patch("mes_dashboard.services.event_fetcher.cache_set")
|
||||
@patch("mes_dashboard.services.event_fetcher.cache_get", return_value=None)
|
||||
@patch("mes_dashboard.services.event_fetcher.read_sql_df")
|
||||
@patch("mes_dashboard.services.event_fetcher.SQLLoader.load")
|
||||
def test_fetch_events_materials_branch_replaces_aliased_container_filter(
|
||||
mock_sql_load,
|
||||
mock_read_sql_df,
|
||||
_mock_cache_get,
|
||||
_mock_cache_set,
|
||||
):
|
||||
mock_sql_load.return_value = (
|
||||
"SELECT * FROM t m WHERE m.CONTAINERID = :container_id ORDER BY TXNDATE"
|
||||
)
|
||||
mock_read_sql_df.return_value = pd.DataFrame([])
|
||||
|
||||
EventFetcher.fetch_events(["CID-1", "CID-2"], "materials")
|
||||
|
||||
sql, params = mock_read_sql_df.call_args.args
|
||||
assert "m.CONTAINERID = :container_id" not in sql
|
||||
assert "IN" in sql.upper()
|
||||
assert params == {"p0": "CID-1", "p1": "CID-2"}
|
||||
|
||||
@@ -123,5 +123,5 @@ def test_resource_history_export_uses_contract_headers(
|
||||
chunks = list(export_resource_history_csv('2024-01-01', '2024-01-10'))
|
||||
|
||||
assert chunks
|
||||
header_row = next(csv.reader(io.StringIO(chunks[0])))
|
||||
header_row = next(csv.reader(io.StringIO(chunks[0].lstrip('\ufeff'))))
|
||||
assert header_row == export_headers
|
||||
|
||||
@@ -690,10 +690,10 @@ class TestExportCsvEndpoint:
|
||||
assert '不支援' in data['error'] or 'type' in data['error'].lower()
|
||||
|
||||
@patch('mes_dashboard.routes.query_tool_routes.get_lot_history')
|
||||
def test_export_lot_history_success(self, mock_get_history, client):
|
||||
"""Should return CSV for lot history."""
|
||||
mock_get_history.return_value = {
|
||||
'data': [
|
||||
def test_export_lot_history_success(self, mock_get_history, client):
|
||||
"""Should return CSV for lot history."""
|
||||
mock_get_history.return_value = {
|
||||
'data': [
|
||||
{
|
||||
'EQUIPMENTNAME': 'ASSY-01',
|
||||
'SPECNAME': 'SPEC-001',
|
||||
@@ -709,9 +709,46 @@ class TestExportCsvEndpoint:
|
||||
'export_type': 'lot_history',
|
||||
'params': {'container_id': '488103800029578b'}
|
||||
}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert 'text/csv' in response.content_type
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert 'text/csv' in response.content_type
|
||||
|
||||
@patch('mes_dashboard.routes.query_tool_routes.get_lot_materials')
|
||||
def test_export_lot_materials_uses_container_name_as_lot_id(
|
||||
self,
|
||||
mock_get_materials,
|
||||
client,
|
||||
):
|
||||
mock_get_materials.return_value = {
|
||||
'data': [
|
||||
{
|
||||
'CONTAINERID': '488103800029578b',
|
||||
'CONTAINERNAME': 'GA25010001-A01',
|
||||
'MATERIALPARTNAME': 'M-001',
|
||||
'MATERIALLOTNAME': 'LOT-MAT-01',
|
||||
'QTYCONSUMED': 10,
|
||||
'WORKCENTERNAME': 'DB',
|
||||
'SPECNAME': 'SPEC-DB',
|
||||
'EQUIPMENTNAME': 'EQ-01',
|
||||
'TXNDATE': '2026-02-22 10:00:00',
|
||||
}
|
||||
],
|
||||
'total': 1,
|
||||
}
|
||||
|
||||
response = client.post(
|
||||
'/api/query-tool/export-csv',
|
||||
json={
|
||||
'export_type': 'lot_materials',
|
||||
'params': {'container_id': '488103800029578b'}
|
||||
}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert 'lot_raw_materials_488103800029578b.csv' in response.headers.get('Content-Disposition', '')
|
||||
decoded = response.data.decode('utf-8-sig')
|
||||
assert 'LOT ID' in decoded
|
||||
assert 'GA25010001-A01' in decoded
|
||||
|
||||
|
||||
class TestEquipmentListEndpoint:
|
||||
|
||||
Reference in New Issue
Block a user