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:
@@ -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' }));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user