Migrar backend a PostgreSQL + Node.js/Express con nuevas funcionalidades

Backend (water-api/):
- Crear API REST completa con Express + TypeScript
- Implementar autenticación JWT con refresh tokens
- CRUD completo para: projects, concentrators, meters, gateways, devices, users, roles
- Agregar validación con Zod para todas las entidades
- Implementar webhooks para The Things Stack (LoRaWAN)
- Agregar endpoint de lecturas con filtros y resumen de consumo
- Implementar carga masiva de medidores via Excel (.xlsx)

Frontend:
- Crear cliente HTTP con manejo automático de JWT y refresh
- Actualizar todas las APIs para usar nuevo backend
- Agregar sistema de autenticación real (login, logout, me)
- Agregar selector de tipo (LORA, LoRaWAN, Grandes) en concentradores y medidores
- Agregar campo Meter ID en medidores
- Crear modal de carga masiva para medidores
- Agregar página de consumo con gráficas y filtros
- Corregir carga de proyectos independiente de datos existentes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Exteban08
2026-01-23 10:13:26 +00:00
parent 2b5735d78d
commit c81a18987f
92 changed files with 14088 additions and 1866 deletions

View File

@@ -0,0 +1,317 @@
import * as XLSX from 'xlsx';
import { query } from '../config/database';
/**
* Result of a bulk upload operation
*/
export interface BulkUploadResult {
success: boolean;
totalRows: number;
inserted: number;
errors: Array<{
row: number;
error: string;
data?: Record<string, unknown>;
}>;
}
/**
* Expected columns in the Excel file for meters
*/
interface MeterRow {
serial_number: string;
meter_id?: string;
name: string;
concentrator_serial: string; // We'll look up the concentrator by serial
location?: string;
type?: string;
status?: string;
installation_date?: string;
}
/**
* Parse Excel file buffer and return rows
*/
function parseExcelBuffer(buffer: Buffer): Record<string, unknown>[] {
const workbook = XLSX.read(buffer, { type: 'buffer' });
const sheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[sheetName];
// Convert to JSON with header row
const rows = XLSX.utils.sheet_to_json<Record<string, unknown>>(worksheet, {
defval: null,
raw: false,
});
return rows;
}
/**
* Normalize column names (handle variations)
*/
function normalizeColumnName(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');
// Map common variations
const mappings: Record<string, string> = {
'serial': 'serial_number',
'numero_de_serie': 'serial_number',
'serial_number': 'serial_number',
'meter_id': 'meter_id',
'meterid': 'meter_id',
'id_medidor': 'meter_id',
'nombre': 'name',
'name': 'name',
'concentrador': 'concentrator_serial',
'concentrator': 'concentrator_serial',
'concentrator_serial': 'concentrator_serial',
'serial_concentrador': 'concentrator_serial',
'ubicacion': 'location',
'location': 'location',
'tipo': 'type',
'type': 'type',
'estado': 'status',
'status': 'status',
'fecha_instalacion': 'installation_date',
'installation_date': 'installation_date',
'fecha_de_instalacion': 'installation_date',
};
return mappings[normalized] || normalized;
}
/**
* Normalize row data with column name mapping
*/
function normalizeRow(row: Record<string, unknown>): Record<string, unknown> {
const normalized: Record<string, unknown> = {};
for (const [key, value] of Object.entries(row)) {
const normalizedKey = normalizeColumnName(key);
normalized[normalizedKey] = value;
}
return normalized;
}
/**
* Validate a meter row
*/
function validateMeterRow(row: Record<string, unknown>, rowIndex: number): { valid: boolean; error?: string } {
if (!row.serial_number || String(row.serial_number).trim() === '') {
return { valid: false, error: `Fila ${rowIndex}: serial_number es requerido` };
}
if (!row.name || String(row.name).trim() === '') {
return { valid: false, error: `Fila ${rowIndex}: name es requerido` };
}
if (!row.concentrator_serial || String(row.concentrator_serial).trim() === '') {
return { valid: false, error: `Fila ${rowIndex}: concentrator_serial es requerido` };
}
return { valid: true };
}
/**
* Bulk upload meters from Excel buffer
*/
export async function bulkUploadMeters(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 => normalizeRow(row));
// Get all concentrators for lookup
const concentratorsResult = await query<{ id: string; serial_number: string }>(
'SELECT id, serial_number FROM concentrators'
);
const concentratorMap = new Map(
concentratorsResult.rows.map(c => [c.serial_number.toLowerCase(), c.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 = validateMeterRow(row, rowIndex);
if (!validation.valid) {
result.errors.push({ row: rowIndex, error: validation.error!, data: row });
continue;
}
// Look up concentrator
const concentratorSerial = String(row.concentrator_serial).trim().toLowerCase();
const concentratorId = concentratorMap.get(concentratorSerial);
if (!concentratorId) {
result.errors.push({
row: rowIndex,
error: `Concentrador con serial "${row.concentrator_serial}" no encontrado`,
data: row,
});
continue;
}
// Prepare meter data
const meterData: MeterRow = {
serial_number: String(row.serial_number).trim(),
meter_id: row.meter_id ? String(row.meter_id).trim() : undefined,
name: String(row.name).trim(),
concentrator_serial: String(row.concentrator_serial).trim(),
location: row.location ? String(row.location).trim() : undefined,
type: row.type ? String(row.type).trim().toUpperCase() : 'LORA',
status: row.status ? String(row.status).trim().toUpperCase() : 'ACTIVE',
installation_date: row.installation_date ? String(row.installation_date).trim() : undefined,
};
// Validate type
const validTypes = ['LORA', 'LORAWAN', 'GRANDES'];
if (!validTypes.includes(meterData.type!)) {
meterData.type = 'LORA';
}
// Validate status
const validStatuses = ['ACTIVE', 'INACTIVE', 'MAINTENANCE', 'FAULTY', 'REPLACED'];
if (!validStatuses.includes(meterData.status!)) {
meterData.status = 'ACTIVE';
}
// Insert meter
try {
await query(
`INSERT INTO meters (serial_number, meter_id, name, concentrator_id, location, type, status, installation_date)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[
meterData.serial_number,
meterData.meter_id || null,
meterData.name,
concentratorId,
meterData.location || null,
meterData.type,
meterData.status,
meterData.installation_date || null,
]
);
result.inserted++;
} catch (err) {
const error = err as Error & { code?: string; detail?: string };
let errorMessage = error.message;
if (error.code === '23505') {
errorMessage = `Serial "${meterData.serial_number}" ya existe en la base de datos`;
}
result.errors.push({
row: rowIndex,
error: errorMessage,
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 meters
*/
export function generateMeterTemplate(): Buffer {
const templateData = [
{
serial_number: 'EJEMPLO-001',
meter_id: 'MID-001',
name: 'Medidor Ejemplo 1',
concentrator_serial: 'CONC-001',
location: 'Ubicación ejemplo',
type: 'LORA',
status: 'ACTIVE',
installation_date: '2024-01-15',
},
{
serial_number: 'EJEMPLO-002',
meter_id: 'MID-002',
name: 'Medidor Ejemplo 2',
concentrator_serial: 'CONC-001',
location: 'Otra ubicación',
type: 'LORAWAN',
status: 'ACTIVE',
installation_date: '2024-01-16',
},
];
const worksheet = XLSX.utils.json_to_sheet(templateData);
// Set column widths
worksheet['!cols'] = [
{ wch: 15 }, // serial_number
{ wch: 12 }, // meter_id
{ wch: 25 }, // name
{ wch: 20 }, // concentrator_serial
{ wch: 25 }, // location
{ wch: 10 }, // type
{ wch: 12 }, // status
{ wch: 15 }, // installation_date
];
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, 'Medidores');
// Add instructions sheet
const instructionsData = [
{ Campo: 'serial_number', Descripcion: 'Número de serie del medidor (REQUERIDO, único)', Ejemplo: 'MED-2024-001' },
{ Campo: 'meter_id', Descripcion: 'ID del medidor (opcional)', Ejemplo: 'ID-001' },
{ Campo: 'name', Descripcion: 'Nombre del medidor (REQUERIDO)', Ejemplo: 'Medidor Casa 1' },
{ Campo: 'concentrator_serial', Descripcion: 'Serial del concentrador (REQUERIDO)', Ejemplo: 'CONC-001' },
{ Campo: 'location', Descripcion: 'Ubicación (opcional)', Ejemplo: 'Calle Principal #123' },
{ Campo: 'type', Descripcion: 'Tipo: LORA, LORAWAN, GRANDES (opcional, default: LORA)', Ejemplo: 'LORA' },
{ Campo: 'status', Descripcion: 'Estado: ACTIVE, INACTIVE, MAINTENANCE, FAULTY, REPLACED (opcional, default: ACTIVE)', Ejemplo: 'ACTIVE' },
{ Campo: 'installation_date', Descripcion: 'Fecha de instalación YYYY-MM-DD (opcional)', Ejemplo: '2024-01-15' },
];
const instructionsSheet = XLSX.utils.json_to_sheet(instructionsData);
instructionsSheet['!cols'] = [
{ wch: 20 },
{ wch: 60 },
{ wch: 20 },
];
XLSX.utils.book_append_sheet(workbook, instructionsSheet, 'Instrucciones');
return Buffer.from(XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' }));
}