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

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