Agregar carga masiva de lecturas y corregir manejo de respuestas paginadas
- Implementar carga masiva de lecturas via Excel (backend y frontend) - Corregir cliente API para manejar respuestas con paginación - Eliminar referencias a device_id (columna inexistente) - Cambiar areaName por meterLocation en lecturas - Actualizar fetchProjects y fetchConcentrators para paginación - Agregar documentación del estado actual y cambios Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,11 @@
|
||||
import { Request, Response } from 'express';
|
||||
import multer from 'multer';
|
||||
import { bulkUploadMeters, generateMeterTemplate } from '../services/bulk-upload.service';
|
||||
import {
|
||||
bulkUploadMeters,
|
||||
generateMeterTemplate,
|
||||
bulkUploadReadings,
|
||||
generateReadingTemplate,
|
||||
} from '../services/bulk-upload.service';
|
||||
|
||||
// Configure multer for memory storage
|
||||
const storage = multer.memoryStorage();
|
||||
@@ -11,13 +16,17 @@ export const upload = multer({
|
||||
fileSize: 10 * 1024 * 1024, // 10MB max
|
||||
},
|
||||
fileFilter: (_req, file, cb) => {
|
||||
// Accept Excel files only
|
||||
// Accept Excel files only - check both MIME type and extension
|
||||
const allowedMimes = [
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', // .xlsx
|
||||
'application/vnd.ms-excel', // .xls
|
||||
'application/octet-stream', // Generic binary (some systems send this)
|
||||
];
|
||||
|
||||
if (allowedMimes.includes(file.mimetype)) {
|
||||
const allowedExtensions = ['.xlsx', '.xls'];
|
||||
const fileExtension = file.originalname.toLowerCase().slice(file.originalname.lastIndexOf('.'));
|
||||
|
||||
if (allowedMimes.includes(file.mimetype) || allowedExtensions.includes(fileExtension)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Solo se permiten archivos Excel (.xlsx, .xls)'));
|
||||
@@ -80,3 +89,59 @@ export async function downloadMeterTemplate(_req: Request, res: Response): Promi
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/bulk-upload/readings
|
||||
* Upload Excel file with readings data
|
||||
*/
|
||||
export async function uploadReadings(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
if (!req.file) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'No se proporcionó ningún archivo',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await bulkUploadReadings(req.file.buffer);
|
||||
|
||||
res.status(result.success ? 200 : 207).json({
|
||||
success: result.success,
|
||||
data: {
|
||||
totalRows: result.totalRows,
|
||||
inserted: result.inserted,
|
||||
failed: result.errors.length,
|
||||
errors: result.errors.slice(0, 50), // Limit errors in response
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
console.error('Error in readings bulk upload:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message || 'Error procesando la carga masiva de lecturas',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/bulk-upload/readings/template
|
||||
* Download Excel template for readings
|
||||
*/
|
||||
export async function downloadReadingTemplate(_req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const buffer = generateReadingTemplate();
|
||||
|
||||
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
res.setHeader('Content-Disposition', 'attachment; filename=plantilla_lecturas.xlsx');
|
||||
res.send(buffer);
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
console.error('Error generating readings template:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Error generando la plantilla de lecturas',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import * as readingService from '../services/reading.service';
|
||||
export async function getAll(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const page = parseInt(req.query.page as string, 10) || 1;
|
||||
const pageSize = Math.min(parseInt(req.query.pageSize as string, 10) || 10, 100);
|
||||
const pageSize = Math.min(parseInt(req.query.pageSize as string, 10) || 50, 1000);
|
||||
|
||||
const filters: meterService.MeterFilters = {};
|
||||
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { Router } from 'express';
|
||||
import { uploadMeters, downloadMeterTemplate, upload } from '../controllers/bulk-upload.controller';
|
||||
import {
|
||||
uploadMeters,
|
||||
downloadMeterTemplate,
|
||||
uploadReadings,
|
||||
downloadReadingTemplate,
|
||||
upload,
|
||||
} from '../controllers/bulk-upload.controller';
|
||||
import { authenticateToken } from '../middleware/auth.middleware';
|
||||
|
||||
const router = Router();
|
||||
@@ -19,4 +25,16 @@ router.post('/meters', upload.single('file'), uploadMeters);
|
||||
*/
|
||||
router.get('/meters/template', downloadMeterTemplate);
|
||||
|
||||
/**
|
||||
* POST /api/bulk-upload/readings
|
||||
* Upload Excel file with readings data
|
||||
*/
|
||||
router.post('/readings', upload.single('file'), uploadReadings);
|
||||
|
||||
/**
|
||||
* GET /api/bulk-upload/readings/template
|
||||
* Download Excel template for readings
|
||||
*/
|
||||
router.get('/readings/template', downloadReadingTemplate);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -315,3 +315,294 @@ export function generateMeterTemplate(): Buffer {
|
||||
|
||||
return Buffer.from(XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Expected columns in the Excel file for readings
|
||||
*/
|
||||
interface ReadingRow {
|
||||
meter_serial: string;
|
||||
reading_value: number;
|
||||
reading_type?: string;
|
||||
received_at?: string;
|
||||
battery_level?: number;
|
||||
signal_strength?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize column name for readings
|
||||
*/
|
||||
function normalizeReadingColumnName(name: string): string {
|
||||
const normalized = name
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/\s+/g, '_')
|
||||
.replace(/[áàäâ]/g, 'a')
|
||||
.replace(/[éèëê]/g, 'e')
|
||||
.replace(/[íìïî]/g, 'i')
|
||||
.replace(/[óòöô]/g, 'o')
|
||||
.replace(/[úùüû]/g, 'u')
|
||||
.replace(/ñ/g, 'n');
|
||||
|
||||
const mappings: Record<string, string> = {
|
||||
// Meter serial
|
||||
'serial': 'meter_serial',
|
||||
'serial_number': 'meter_serial',
|
||||
'meter_serial': 'meter_serial',
|
||||
'numero_de_serie': 'meter_serial',
|
||||
'serial_medidor': 'meter_serial',
|
||||
'medidor': 'meter_serial',
|
||||
// Reading value
|
||||
'valor': 'reading_value',
|
||||
'value': 'reading_value',
|
||||
'reading_value': 'reading_value',
|
||||
'lectura': 'reading_value',
|
||||
'consumo': 'reading_value',
|
||||
// Reading type
|
||||
'tipo': 'reading_type',
|
||||
'type': 'reading_type',
|
||||
'reading_type': 'reading_type',
|
||||
'tipo_lectura': 'reading_type',
|
||||
// Received at
|
||||
'fecha': 'received_at',
|
||||
'date': 'received_at',
|
||||
'received_at': 'received_at',
|
||||
'fecha_lectura': 'received_at',
|
||||
'fecha_hora': 'received_at',
|
||||
// Battery
|
||||
'bateria': 'battery_level',
|
||||
'battery': 'battery_level',
|
||||
'battery_level': 'battery_level',
|
||||
'nivel_bateria': 'battery_level',
|
||||
// Signal
|
||||
'senal': 'signal_strength',
|
||||
'signal': 'signal_strength',
|
||||
'signal_strength': 'signal_strength',
|
||||
'intensidad_senal': 'signal_strength',
|
||||
};
|
||||
|
||||
return mappings[normalized] || normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize row for readings
|
||||
*/
|
||||
function normalizeReadingRow(row: Record<string, unknown>): Record<string, unknown> {
|
||||
const normalized: Record<string, unknown> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(row)) {
|
||||
const normalizedKey = normalizeReadingColumnName(key);
|
||||
normalized[normalizedKey] = value;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a reading row
|
||||
*/
|
||||
function validateReadingRow(row: Record<string, unknown>, rowIndex: number): { valid: boolean; error?: string } {
|
||||
if (!row.meter_serial || String(row.meter_serial).trim() === '') {
|
||||
return { valid: false, error: `Fila ${rowIndex}: meter_serial es requerido` };
|
||||
}
|
||||
|
||||
if (row.reading_value === null || row.reading_value === undefined || row.reading_value === '') {
|
||||
return { valid: false, error: `Fila ${rowIndex}: reading_value es requerido` };
|
||||
}
|
||||
|
||||
const value = parseFloat(String(row.reading_value));
|
||||
if (isNaN(value)) {
|
||||
return { valid: false, error: `Fila ${rowIndex}: reading_value debe ser un número` };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk upload readings from Excel buffer
|
||||
*/
|
||||
export async function bulkUploadReadings(buffer: Buffer): Promise<BulkUploadResult> {
|
||||
const result: BulkUploadResult = {
|
||||
success: true,
|
||||
totalRows: 0,
|
||||
inserted: 0,
|
||||
errors: [],
|
||||
};
|
||||
|
||||
try {
|
||||
// Parse Excel file
|
||||
const rawRows = parseExcelBuffer(buffer);
|
||||
result.totalRows = rawRows.length;
|
||||
|
||||
if (rawRows.length === 0) {
|
||||
result.success = false;
|
||||
result.errors.push({ row: 0, error: 'El archivo está vacío o no tiene datos válidos' });
|
||||
return result;
|
||||
}
|
||||
|
||||
// Normalize column names
|
||||
const rows = rawRows.map(row => normalizeReadingRow(row));
|
||||
|
||||
// Get all meters for lookup by serial number
|
||||
const metersResult = await query<{ id: string; serial_number: string }>(
|
||||
'SELECT id, serial_number FROM meters'
|
||||
);
|
||||
const meterMap = new Map(
|
||||
metersResult.rows.map(m => [m.serial_number.toLowerCase(), m.id])
|
||||
);
|
||||
|
||||
// Process each row
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i];
|
||||
const rowIndex = i + 2; // Excel row number (1-indexed + header row)
|
||||
|
||||
// Validate row
|
||||
const validation = validateReadingRow(row, rowIndex);
|
||||
if (!validation.valid) {
|
||||
result.errors.push({ row: rowIndex, error: validation.error!, data: row });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Look up meter
|
||||
const meterSerial = String(row.meter_serial).trim().toLowerCase();
|
||||
const meterId = meterMap.get(meterSerial);
|
||||
|
||||
if (!meterId) {
|
||||
result.errors.push({
|
||||
row: rowIndex,
|
||||
error: `Medidor con serial "${row.meter_serial}" no encontrado`,
|
||||
data: row,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Prepare reading data
|
||||
const readingValue = parseFloat(String(row.reading_value));
|
||||
const readingType = row.reading_type ? String(row.reading_type).trim().toUpperCase() : 'MANUAL';
|
||||
const receivedAt = row.received_at ? String(row.received_at).trim() : null;
|
||||
|
||||
// Parse battery level - handle NaN and invalid values
|
||||
let batteryLevel: number | null = null;
|
||||
if (row.battery_level !== undefined && row.battery_level !== null && row.battery_level !== '') {
|
||||
const parsed = parseFloat(String(row.battery_level));
|
||||
if (!isNaN(parsed) && isFinite(parsed)) {
|
||||
batteryLevel = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse signal strength - handle NaN and invalid values
|
||||
let signalStrength: number | null = null;
|
||||
if (row.signal_strength !== undefined && row.signal_strength !== null && row.signal_strength !== '') {
|
||||
const parsed = parseFloat(String(row.signal_strength));
|
||||
if (!isNaN(parsed) && isFinite(parsed)) {
|
||||
signalStrength = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate reading type
|
||||
const validTypes = ['AUTOMATIC', 'MANUAL', 'SCHEDULED'];
|
||||
const finalReadingType = validTypes.includes(readingType) ? readingType : 'MANUAL';
|
||||
|
||||
// Insert reading
|
||||
try {
|
||||
await query(
|
||||
`INSERT INTO meter_readings (meter_id, reading_value, reading_type, battery_level, signal_strength, received_at)
|
||||
VALUES ($1, $2, $3, $4, $5, COALESCE($6::timestamp, NOW()))`,
|
||||
[
|
||||
meterId,
|
||||
readingValue,
|
||||
finalReadingType,
|
||||
batteryLevel,
|
||||
signalStrength,
|
||||
receivedAt,
|
||||
]
|
||||
);
|
||||
|
||||
// Update meter's last reading
|
||||
await query(
|
||||
`UPDATE meters
|
||||
SET last_reading_value = $1, last_reading_at = COALESCE($2::timestamp, NOW()), updated_at = NOW()
|
||||
WHERE id = $3`,
|
||||
[readingValue, receivedAt, meterId]
|
||||
);
|
||||
|
||||
result.inserted++;
|
||||
} catch (err) {
|
||||
const error = err as Error & { code?: string; detail?: string };
|
||||
result.errors.push({
|
||||
row: rowIndex,
|
||||
error: error.message,
|
||||
data: row,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
result.success = result.errors.length === 0;
|
||||
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
result.success = false;
|
||||
result.errors.push({ row: 0, error: `Error procesando archivo: ${error.message}` });
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Excel template for readings
|
||||
*/
|
||||
export function generateReadingTemplate(): Buffer {
|
||||
const templateData = [
|
||||
{
|
||||
meter_serial: '24151158',
|
||||
reading_value: 123.45,
|
||||
reading_type: 'MANUAL',
|
||||
received_at: '2024-01-15 10:30:00',
|
||||
battery_level: 85,
|
||||
signal_strength: -70,
|
||||
},
|
||||
{
|
||||
meter_serial: '24151159',
|
||||
reading_value: 456.78,
|
||||
reading_type: 'MANUAL',
|
||||
received_at: '2024-01-15 10:35:00',
|
||||
battery_level: 90,
|
||||
signal_strength: -65,
|
||||
},
|
||||
];
|
||||
|
||||
const worksheet = XLSX.utils.json_to_sheet(templateData);
|
||||
|
||||
// Set column widths
|
||||
worksheet['!cols'] = [
|
||||
{ wch: 15 }, // meter_serial
|
||||
{ wch: 15 }, // reading_value
|
||||
{ wch: 12 }, // reading_type
|
||||
{ wch: 20 }, // received_at
|
||||
{ wch: 15 }, // battery_level
|
||||
{ wch: 15 }, // signal_strength
|
||||
];
|
||||
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, 'Lecturas');
|
||||
|
||||
// Add instructions sheet
|
||||
const instructionsData = [
|
||||
{ Campo: 'meter_serial', Descripcion: 'Número de serie del medidor (REQUERIDO)', Ejemplo: '24151158' },
|
||||
{ Campo: 'reading_value', Descripcion: 'Valor de la lectura en m³ (REQUERIDO)', Ejemplo: '123.45' },
|
||||
{ Campo: 'reading_type', Descripcion: 'Tipo: AUTOMATIC, MANUAL, SCHEDULED (opcional, default: MANUAL)', Ejemplo: 'MANUAL' },
|
||||
{ Campo: 'received_at', Descripcion: 'Fecha y hora de la lectura YYYY-MM-DD HH:MM:SS (opcional, default: ahora)', Ejemplo: '2024-01-15 10:30:00' },
|
||||
{ Campo: 'battery_level', Descripcion: 'Nivel de batería en % (opcional)', Ejemplo: '85' },
|
||||
{ Campo: 'signal_strength', Descripcion: 'Intensidad de señal en dBm (opcional)', Ejemplo: '-70' },
|
||||
];
|
||||
|
||||
const instructionsSheet = XLSX.utils.json_to_sheet(instructionsData);
|
||||
instructionsSheet['!cols'] = [
|
||||
{ wch: 20 },
|
||||
{ wch: 60 },
|
||||
{ wch: 25 },
|
||||
];
|
||||
|
||||
XLSX.utils.book_append_sheet(workbook, instructionsSheet, 'Instrucciones');
|
||||
|
||||
return Buffer.from(XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' }));
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import { query } from '../config/database';
|
||||
export interface MeterReading {
|
||||
id: string;
|
||||
meter_id: string;
|
||||
device_id: string | null;
|
||||
reading_value: number;
|
||||
reading_type: string;
|
||||
battery_level: number | null;
|
||||
@@ -67,7 +66,6 @@ export interface PaginatedResult<T> {
|
||||
*/
|
||||
export interface CreateReadingInput {
|
||||
meter_id: string;
|
||||
device_id?: string;
|
||||
reading_value: number;
|
||||
reading_type?: string;
|
||||
battery_level?: number;
|
||||
@@ -147,7 +145,7 @@ export async function getAll(
|
||||
// Get paginated data with meter, concentrator, and project info
|
||||
const dataQuery = `
|
||||
SELECT
|
||||
mr.id, mr.meter_id, mr.device_id, mr.reading_value, mr.reading_type,
|
||||
mr.id, mr.meter_id, mr.reading_value, mr.reading_type,
|
||||
mr.battery_level, mr.signal_strength, mr.raw_payload, mr.received_at, mr.created_at,
|
||||
m.serial_number as meter_serial_number, m.name as meter_name, m.location as meter_location,
|
||||
m.concentrator_id, c.name as concentrator_name,
|
||||
@@ -183,7 +181,7 @@ export async function getAll(
|
||||
export async function getById(id: string): Promise<MeterReadingWithMeter | null> {
|
||||
const result = await query<MeterReadingWithMeter>(
|
||||
`SELECT
|
||||
mr.id, mr.meter_id, mr.device_id, mr.reading_value, mr.reading_type,
|
||||
mr.id, mr.meter_id, mr.reading_value, mr.reading_type,
|
||||
mr.battery_level, mr.signal_strength, mr.raw_payload, mr.received_at, mr.created_at,
|
||||
m.serial_number as meter_serial_number, m.name as meter_name, m.location as meter_location,
|
||||
m.concentrator_id, c.name as concentrator_name,
|
||||
@@ -206,14 +204,13 @@ export async function getById(id: string): Promise<MeterReadingWithMeter | null>
|
||||
*/
|
||||
export async function create(data: CreateReadingInput): Promise<MeterReading> {
|
||||
const result = await query<MeterReading>(
|
||||
`INSERT INTO meter_readings (meter_id, device_id, reading_value, reading_type,
|
||||
`INSERT INTO meter_readings (meter_id, reading_value, reading_type,
|
||||
battery_level, signal_strength, raw_payload, received_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, COALESCE($8, NOW()))
|
||||
RETURNING id, meter_id, device_id, reading_value, reading_type,
|
||||
VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7, NOW()))
|
||||
RETURNING id, meter_id, reading_value, reading_type,
|
||||
battery_level, signal_strength, raw_payload, received_at, created_at`,
|
||||
[
|
||||
data.meter_id,
|
||||
data.device_id || null,
|
||||
data.reading_value,
|
||||
data.reading_type || 'AUTOMATIC',
|
||||
data.battery_level || null,
|
||||
|
||||
Reference in New Issue
Block a user