fix(query-tool): export all selected CIDs instead of single, fix hold detail float precision

Export was sending only one container_id while the detail table loaded
data for all selected CIDs (including subtree), causing "查無資料" errors.
Now sends container_ids array and uses batch service functions.

Also rounds hold detail age KPI cards to 1 decimal place.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
egg
2026-02-24 18:41:24 +08:00
parent bb6eec6a87
commit 7fffa812a3
4 changed files with 623 additions and 452 deletions

View File

@@ -17,7 +17,8 @@ function formatAge(value) {
if (value === null || value === undefined || value === '-') {
return '-';
}
return `${value}`;
const num = Number(value);
return `${Number.isFinite(num) ? num.toFixed(1) : value}`;
}
</script>

View File

@@ -378,9 +378,9 @@ export function useLotDetail(initial = {}) {
async function exportSubTab(tab) {
const normalized = normalizeSubTab(tab);
const exportType = EXPORT_TYPE_MAP[normalized];
const containerId = selectedContainerId.value;
const cids = getActiveCids();
if (!exportType || !containerId) {
if (!exportType || cids.length === 0) {
return false;
}
@@ -389,7 +389,7 @@ export function useLotDetail(initial = {}) {
try {
const params = {
container_id: containerId,
container_ids: cids,
};
if (normalized === 'jobs') {

View File

@@ -40,6 +40,15 @@ from mes_dashboard.services.query_tool_service import (
validate_equipment_input,
)
def _resolve_export_cids(params: dict) -> list[str]:
"""Extract container_ids from export params, supporting both batch and single."""
cids = params.get('container_ids') or []
if isinstance(cids, list) and cids:
return [c for c in cids if c]
single = params.get('container_id')
return [single] if single else []
# Create Blueprint
query_tool_bp = Blueprint('query_tool', __name__)
@@ -605,11 +614,14 @@ def export_csv():
try:
if export_type == 'lot_history':
container_id = params.get('container_id')
if not container_id:
cids = _resolve_export_cids(params)
if not cids:
return jsonify({'error': '請指定 CONTAINERID'}), 400
result = get_lot_history(container_id)
filename = f'lot_history_{container_id}.csv'
if len(cids) > 1:
result = get_lot_history_batch(cids)
else:
result = get_lot_history(cids[0])
filename = f'lot_history_{cids[0]}.csv'
elif export_type == 'adjacent_lots':
result = get_adjacent_lots(
@@ -620,19 +632,34 @@ 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_raw_materials_{container_id}.csv'
cids = _resolve_export_cids(params)
if not cids:
return jsonify({'error': '請指定 CONTAINERID'}), 400
if len(cids) > 1:
result = get_lot_associations_batch(cids, 'materials')
else:
result = get_lot_materials(cids[0])
filename = f'lot_raw_materials_{cids[0]}.csv'
elif export_type == 'lot_rejects':
container_id = params.get('container_id')
result = get_lot_rejects(container_id)
filename = f'lot_rejects_{container_id}.csv'
cids = _resolve_export_cids(params)
if not cids:
return jsonify({'error': '請指定 CONTAINERID'}), 400
if len(cids) > 1:
result = get_lot_associations_batch(cids, 'rejects')
else:
result = get_lot_rejects(cids[0])
filename = f'lot_rejects_{cids[0]}.csv'
elif export_type == 'lot_holds':
container_id = params.get('container_id')
result = get_lot_holds(container_id)
filename = f'lot_holds_{container_id}.csv'
cids = _resolve_export_cids(params)
if not cids:
return jsonify({'error': '請指定 CONTAINERID'}), 400
if len(cids) > 1:
result = get_lot_associations_batch(cids, 'holds')
else:
result = get_lot_holds(cids[0])
filename = f'lot_holds_{cids[0]}.csv'
elif export_type == 'lot_splits':
container_id = params.get('container_id')

View File

@@ -885,6 +885,149 @@ class TestExportCsvEndpoint:
assert 'GA25010001-A01' in decoded
class TestExportCsvBatchEndpoint:
"""Tests for /api/query-tool/export-csv with batch container_ids."""
@patch('mes_dashboard.routes.query_tool_routes.get_lot_history_batch')
def test_export_lot_history_batch(self, mock_batch, client):
"""Batch container_ids should call get_lot_history_batch."""
mock_batch.return_value = {
'data': [
{'CONTAINERNAME': 'LOT-A', 'SPECNAME': 'S1', 'TRACKINTIMESTAMP': '2026-01-01'},
{'CONTAINERNAME': 'LOT-B', 'SPECNAME': 'S1', 'TRACKINTIMESTAMP': '2026-01-02'},
],
'total': 2,
}
response = client.post(
'/api/query-tool/export-csv',
json={
'export_type': 'lot_history',
'params': {'container_ids': ['aaa1', 'bbb2']},
},
)
assert response.status_code == 200
assert 'text/csv' in response.content_type
mock_batch.assert_called_once_with(['aaa1', 'bbb2'])
@patch('mes_dashboard.routes.query_tool_routes.get_lot_history')
def test_export_lot_history_single_cid_in_array(self, mock_single, client):
"""Single-element container_ids should call get_lot_history (not batch)."""
mock_single.return_value = {
'data': [{'CONTAINERNAME': 'LOT-A', 'SPECNAME': 'S1'}],
'total': 1,
}
response = client.post(
'/api/query-tool/export-csv',
json={
'export_type': 'lot_history',
'params': {'container_ids': ['aaa1']},
},
)
assert response.status_code == 200
mock_single.assert_called_once_with('aaa1')
@patch('mes_dashboard.routes.query_tool_routes.get_lot_associations_batch')
def test_export_lot_materials_batch(self, mock_batch, client):
"""Batch container_ids for materials should call get_lot_associations_batch."""
mock_batch.return_value = {
'data': [
{'CONTAINERNAME': 'LOT-A', 'MATERIALPARTNAME': 'M1', 'QTYCONSUMED': 5},
],
'total': 1,
}
response = client.post(
'/api/query-tool/export-csv',
json={
'export_type': 'lot_materials',
'params': {'container_ids': ['aaa1', 'bbb2']},
},
)
assert response.status_code == 200
assert 'text/csv' in response.content_type
mock_batch.assert_called_once_with(['aaa1', 'bbb2'], 'materials')
@patch('mes_dashboard.routes.query_tool_routes.get_lot_associations_batch')
def test_export_lot_rejects_batch(self, mock_batch, client):
"""Batch container_ids for rejects should call get_lot_associations_batch."""
mock_batch.return_value = {
'data': [
{'CONTAINERNAME': 'LOT-A', 'LOSSREASONNAME': 'IR', 'REJECT_TOTAL_QTY': 10},
],
'total': 1,
}
response = client.post(
'/api/query-tool/export-csv',
json={
'export_type': 'lot_rejects',
'params': {'container_ids': ['aaa1', 'bbb2']},
},
)
assert response.status_code == 200
mock_batch.assert_called_once_with(['aaa1', 'bbb2'], 'rejects')
@patch('mes_dashboard.routes.query_tool_routes.get_lot_associations_batch')
def test_export_lot_holds_batch(self, mock_batch, client):
"""Batch container_ids for holds should call get_lot_associations_batch."""
mock_batch.return_value = {
'data': [
{'CONTAINERNAME': 'LOT-A', 'HOLDREASONNAME': 'Q-Time', 'HOLD_STATUS': 'HOLD'},
],
'total': 1,
}
response = client.post(
'/api/query-tool/export-csv',
json={
'export_type': 'lot_holds',
'params': {'container_ids': ['aaa1', 'bbb2']},
},
)
assert response.status_code == 200
mock_batch.assert_called_once_with(['aaa1', 'bbb2'], 'holds')
@patch('mes_dashboard.routes.query_tool_routes.get_lot_history')
def test_export_backward_compat_container_id(self, mock_single, client):
"""Legacy container_id param should still work."""
mock_single.return_value = {
'data': [{'CONTAINERNAME': 'LOT-A', 'SPECNAME': 'S1'}],
'total': 1,
}
response = client.post(
'/api/query-tool/export-csv',
json={
'export_type': 'lot_history',
'params': {'container_id': '488103800029578b'},
},
)
assert response.status_code == 200
mock_single.assert_called_once_with('488103800029578b')
def test_export_lot_history_no_cids_returns_400(self, client):
"""Missing container_ids and container_id should return 400."""
response = client.post(
'/api/query-tool/export-csv',
json={
'export_type': 'lot_history',
'params': {},
},
)
assert response.status_code == 400
data = response.get_json()
assert 'CONTAINERID' in data.get('error', '')
class TestEquipmentListEndpoint:
"""Tests for /api/query-tool/equipment-list endpoint."""