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:
317
water-api/src/services/bulk-upload.service.ts
Normal file
317
water-api/src/services/bulk-upload.service.ts
Normal 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' }));
|
||||
}
|
||||
Reference in New Issue
Block a user