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:
Exteban08
2026-01-23 21:23:41 +00:00
parent c81a18987f
commit ab97987c6a
14 changed files with 1154 additions and 35 deletions

View File

@@ -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',
});
}
}

View File

@@ -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 = {};

View File

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

View File

@@ -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' }));
}

View File

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