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

131
CAMBIOS_SESION.md Normal file
View File

@@ -0,0 +1,131 @@
# Cambios Realizados en Esta Sesión
**Fecha:** 2026-01-23
---
## Problema a Resolver
Pantalla en blanco al entrar a "Water Meters" y "Consumo" después de implementar carga masiva de lecturas.
---
## Cambios Realizados
### 1. `src/api/client.ts` (línea ~224-237)
**Cambio:** Modificado `parseResponse` para manejar respuestas con paginación
```typescript
// ANTES:
if ('success' in data) {
if (data.success === false) { throw... }
return data.data as T; // Solo devolvía data.data
}
// DESPUÉS:
if ('success' in data) {
if (data.success === false) { throw... }
// Si hay pagination, devolver objeto completo
if ('pagination' in data) {
return {
data: data.data,
pagination: data.pagination,
} as T;
}
return data.data as T;
}
```
### 2. `src/api/readings.ts`
**Cambios en interface `MeterReading`:**
```typescript
// ELIMINADO:
deviceId: string | null;
areaName: string | null;
// AGREGADO:
meterLocation: string | null;
concentratorId: string;
concentratorName: string;
projectName: string;
```
**Cambios en interface `ReadingFilters`:**
```typescript
// ELIMINADO:
areaName?: string;
// AGREGADO:
concentratorId?: string;
```
**Cambios en interface `ReadingInput`:**
```typescript
// ELIMINADO:
deviceId?: string;
```
**Cambios en función `createReading`:**
```typescript
// ELIMINADO de backendData:
device_id: data.deviceId,
```
### 3. `src/api/projects.ts` (función `fetchProjects`)
**Cambio:** Ahora maneja respuestas paginadas
```typescript
// ANTES:
const response = await apiClient.get<Record<string, unknown>[]>('/api/projects');
return transformArray<Project>(response);
// DESPUÉS:
const response = await apiClient.get<...>('/api/projects');
if (response && typeof response === 'object' && 'data' in response && Array.isArray(response.data)) {
return transformArray<Project>(response.data);
}
return transformArray<Project>(response as Record<string, unknown>[]);
```
### 4. `src/api/concentrators.ts` (función `fetchConcentrators`)
**Cambio:** Mismo patrón que projects.ts para manejar paginación
### 5. `src/pages/consumption/ConsumptionPage.tsx`
**Cambios:**
- Línea ~94: `r.areaName``r.meterLocation` (filtro de búsqueda)
- Línea ~127: `"Área"``"Ubicación"` (header CSV)
- Línea ~132: `r.areaName``r.meterLocation` (datos CSV)
- Línea ~365: `"Área"``"Ubicación"` (header tabla)
- Línea ~428: `reading.areaName``reading.meterLocation` (celda tabla)
### 6. `water-api/src/services/reading.service.ts`
**Cambios previos (ya aplicados):**
- Eliminadas todas las referencias a `device_id` en queries SQL
- La columna `device_id` no existe en la tabla `meter_readings`
---
## Estado de los Servidores
### Backend (puerto 3000) ✅ Funcionando
```bash
curl http://localhost:3000/api/readings?pageSize=1
# Devuelve datos correctamente
```
### Frontend (puerto 5173) ⚠️ Pantalla blanca
- El servidor está corriendo
- Las páginas no renderizan correctamente
- Se necesita revisar la consola del navegador para ver el error específico
---
## Para Debug
1. Abrir http://localhost:5173
2. F12 → Console
3. Navegar a "Water Meters" o "Consumo"
4. Copiar el error de la consola
El error más probable es:
- `Cannot read property 'map' of undefined` - si `transformArray` recibe undefined
- `TypeError: response.data is undefined` - si la respuesta no tiene la estructura esperada

254
ESTADO_ACTUAL.md Normal file
View File

@@ -0,0 +1,254 @@
# Estado Actual del Proyecto Water Project
**Fecha:** 2026-01-23
**Última sesión:** Migración de NocoDB a PostgreSQL + Node.js/Express
---
## Resumen del Proyecto
Sistema de gestión de medidores de agua con:
- **Frontend:** React + TypeScript + Vite (puerto 5173)
- **Backend:** Node.js + Express + TypeScript (puerto 3000)
- **Base de datos:** PostgreSQL
### Jerarquía de datos:
```
Projects → Concentrators → Meters → Readings
```
---
## Lo que se implementó
### 1. Backend API completo (`water-api/`)
- Autenticación JWT con refresh tokens
- CRUD completo para: Projects, Concentrators, Meters, Readings, Users, Roles
- Carga masiva de medidores y lecturas via Excel
- Endpoints de resumen/estadísticas
### 2. Frontend adaptado
- Cliente API con manejo automático de JWT (`src/api/client.ts`)
- Transformación automática snake_case ↔ camelCase
- Páginas: Home, Meters, Consumption, Projects, Users, Roles
### 3. Carga Masiva
- **Medidores:** Subir Excel con columnas: `serial_number`, `name`, `concentrator_serial`, `location`, `type`, `status`
- **Lecturas:** Subir Excel con columnas: `meter_serial`, `reading_value`, `reading_type`, `battery_level`, `signal_strength`, `received_at`
### 4. Credenciales actualizadas
- **Usuario admin:** Ivan Alcaraz
- **Email:** ialcarazsalazar@consultoria-as.com
- **Password:** Aasi940812
---
## Problema Actual: Pantalla en Blanco
### Síntoma
Al entrar a "Water Meters" o "Consumo", la pantalla se queda en blanco.
### Causa identificada
El cliente API (`src/api/client.ts`) fue modificado para manejar respuestas paginadas. Cuando el backend devuelve:
```json
{
"success": true,
"data": [...],
"pagination": {...}
}
```
El cliente ahora devuelve `{ data: [...], pagination: {...} }` en lugar de solo el array.
### Archivos modificados para manejar esto:
1.`src/api/client.ts` - Detecta si hay `pagination` y devuelve el objeto completo
2.`src/api/meters.ts` - Ya manejaba respuestas paginadas
3.`src/api/readings.ts` - Ya manejaba respuestas paginadas
4.`src/api/projects.ts` - Actualizado para manejar paginación
5.`src/api/concentrators.ts` - Actualizado para manejar paginación
### Cambios en interfaces de readings.ts:
- Eliminado `deviceId` (no existe en BD)
- Cambiado `areaName` por `meterLocation`
- Agregado `concentratorId`, `concentratorName`, `projectName`
### Cambios en ConsumptionPage.tsx:
- Cambiado `areaName` por `meterLocation` en todas las referencias
- Cambiado header de tabla "Área" por "Ubicación"
---
## Para Continuar Debugging
### 1. Verificar que los APIs funcionan
```bash
# Projects
curl http://localhost:3000/api/projects
# Meters (requiere auth)
curl http://localhost:3000/api/meters?pageSize=2
# Readings
curl http://localhost:3000/api/readings?pageSize=2
```
### 2. Ver errores en el navegador
1. Abrir la aplicación en http://localhost:5173
2. Abrir DevTools (F12)
3. Ir a la pestaña "Console"
4. Navegar a "Water Meters" o "Consumo"
5. Copiar el error que aparezca
### 3. Posibles causas adicionales
Si el error persiste, verificar:
a) **Error de transformación de datos:**
- El `transformArray` puede estar recibiendo undefined
- Verificar en `src/api/readings.ts` línea ~120
b) **Error en componentes React:**
- Algún componente puede estar accediendo a propiedades que no existen
- Verificar `src/pages/consumption/ConsumptionPage.tsx`
- Verificar `src/pages/meters/MeterPage.tsx`
c) **Problema con el cliente API:**
- El `parseResponse` en `src/api/client.ts` línea ~211
- Verificar que la detección de pagination funcione correctamente
### 4. Test rápido del cliente
Agregar console.log temporal en `src/api/client.ts`:
```typescript
// En la función parseResponse, después de línea 221
console.log('API Response:', data);
```
---
## Estructura de Archivos Clave
```
water-project/
├── src/
│ ├── api/
│ │ ├── client.ts # Cliente HTTP con JWT
│ │ ├── readings.ts # API de lecturas
│ │ ├── meters.ts # API de medidores
│ │ ├── projects.ts # API de proyectos
│ │ └── concentrators.ts # API de concentradores
│ ├── pages/
│ │ ├── meters/
│ │ │ ├── MeterPage.tsx
│ │ │ ├── useMeters.ts # Hook para cargar datos
│ │ │ └── ...
│ │ └── consumption/
│ │ ├── ConsumptionPage.tsx
│ │ └── ReadingsBulkUploadModal.tsx
│ └── ...
├── water-api/
│ ├── src/
│ │ ├── controllers/
│ │ │ ├── reading.controller.ts
│ │ │ ├── meter.controller.ts
│ │ │ └── bulk-upload.controller.ts
│ │ ├── services/
│ │ │ ├── reading.service.ts
│ │ │ ├── meter.service.ts
│ │ │ └── bulk-upload.service.ts
│ │ └── routes/
│ └── ...
└── ESTADO_ACTUAL.md (este archivo)
```
---
## Comandos Útiles
```bash
# Iniciar backend
cd /home/GRH/water-project/water-api
npm run dev
# Iniciar frontend
cd /home/GRH/water-project
npm run dev
# Verificar TypeScript (ignorar TS6133 - variables no usadas)
cd /home/GRH/water-project
npx tsc --noEmit 2>&1 | grep -v "TS6133"
# Ver logs del backend
tail -f /tmp/water-api.log
# Conectar a PostgreSQL
psql -U postgres -d water_project
```
---
## Esquema de Base de Datos Relevante
### Tabla `meter_readings`
```sql
- id (UUID)
- meter_id (UUID, FK meters)
- reading_value (DECIMAL)
- reading_type (VARCHAR) -- AUTOMATIC, MANUAL, SCHEDULED
- battery_level (SMALLINT, nullable)
- signal_strength (SMALLINT, nullable)
- raw_payload (TEXT, nullable)
- received_at (TIMESTAMP)
- created_at (TIMESTAMP)
```
**NOTA:** La columna `device_id` NO EXISTE en esta tabla. Fue removida del código.
### Respuesta del API `/api/readings`
```json
{
"success": true,
"data": [
{
"id": "...",
"meter_id": "...",
"reading_value": "300.0000",
"reading_type": "MANUAL",
"battery_level": null,
"signal_strength": null,
"raw_payload": null,
"received_at": "2026-01-23T21:07:28.726Z",
"created_at": "2026-01-23T21:07:28.726Z",
"meter_serial_number": "24300001",
"meter_name": "D307_IB-113107",
"meter_location": null,
"concentrator_id": "...",
"concentrator_name": "Adamant",
"project_id": "...",
"project_name": "ADAMANT"
}
],
"pagination": {
"page": 1,
"pageSize": 100,
"total": 206,
"totalPages": 3
}
}
```
---
## Próximos Pasos Sugeridos
1. **Resolver pantalla blanca** - Obtener el error específico de la consola del navegador
2. **Probar carga masiva** - Una vez que las páginas funcionen
3. **Integración TTS** - Webhooks para The Things Stack (pendiente)
4. **Despliegue** - Configurar para producción
---
## Contacto del Plan Original
El plan completo de migración está en:
```
/home/GRH/.claude/plans/peaceful-napping-bumblebee.md
```

View File

@@ -230,6 +230,13 @@ async function parseResponse<T>(response: Response): Promise<T> {
data.error?.errors data.error?.errors
); );
} }
// If response has pagination, return object with data and pagination
if ('pagination' in data) {
return {
data: data.data,
pagination: data.pagination,
} as T;
}
return data.data as T; return data.data as T;
} }
} }

View File

@@ -74,8 +74,15 @@ export interface ConcentratorInput {
*/ */
export async function fetchConcentrators(projectId?: string): Promise<Concentrator[]> { export async function fetchConcentrators(projectId?: string): Promise<Concentrator[]> {
const params = projectId ? { project_id: projectId } : undefined; const params = projectId ? { project_id: projectId } : undefined;
const response = await apiClient.get<Record<string, unknown>[]>('/api/concentrators', { params }); const response = await apiClient.get<{ data: Record<string, unknown>[]; pagination?: unknown } | Record<string, unknown>[]>('/api/concentrators', { params });
return transformArray<Concentrator>(response);
// Handle paginated response
if (response && typeof response === 'object' && 'data' in response && Array.isArray(response.data)) {
return transformArray<Concentrator>(response.data);
}
// Handle array response (fallback)
return transformArray<Concentrator>(response as Record<string, unknown>[]);
} }
/** /**

View File

@@ -88,14 +88,23 @@ export interface MeterReading {
* @returns Promise resolving to an array of meters * @returns Promise resolving to an array of meters
*/ */
export async function fetchMeters(filters?: { concentratorId?: string; projectId?: string }): Promise<Meter[]> { export async function fetchMeters(filters?: { concentratorId?: string; projectId?: string }): Promise<Meter[]> {
const params: Record<string, string> = {}; const params: Record<string, string> = {
pageSize: '1000', // Request up to 1000 meters
};
if (filters?.concentratorId) params.concentrator_id = filters.concentratorId; if (filters?.concentratorId) params.concentrator_id = filters.concentratorId;
if (filters?.projectId) params.project_id = filters.projectId; if (filters?.projectId) params.project_id = filters.projectId;
const response = await apiClient.get<Record<string, unknown>[]>('/api/meters', { const response = await apiClient.get<{ data: Record<string, unknown>[]; pagination: unknown }>('/api/meters', {
params: Object.keys(params).length > 0 ? params : undefined params
}); });
return transformArray<Meter>(response);
// Handle paginated response
if (response && typeof response === 'object' && 'data' in response) {
return transformArray<Meter>(response.data);
}
// Fallback for non-paginated response
return transformArray<Meter>(response as unknown as Record<string, unknown>[]);
} }
/** /**
@@ -192,13 +201,19 @@ export interface BulkUploadResult {
* @returns Promise resolving to upload result * @returns Promise resolving to upload result
*/ */
export async function bulkUploadMeters(file: File): Promise<BulkUploadResult> { export async function bulkUploadMeters(file: File): Promise<BulkUploadResult> {
const token = localStorage.getItem('grh_access_token');
if (!token) {
throw new Error('No hay sesión activa. Por favor inicia sesión nuevamente.');
}
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000'}/api/bulk-upload/meters`, { const response = await fetch(`${import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000'}/api/bulk-upload/meters`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`, 'Authorization': `Bearer ${token}`,
}, },
body: formData, body: formData,
}); });
@@ -215,15 +230,27 @@ export async function bulkUploadMeters(file: File): Promise<BulkUploadResult> {
* Download meter template Excel file * Download meter template Excel file
*/ */
export async function downloadMeterTemplate(): Promise<void> { export async function downloadMeterTemplate(): Promise<void> {
const token = localStorage.getItem('grh_access_token');
if (!token) {
throw new Error('No hay sesión activa. Por favor inicia sesión nuevamente.');
}
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000'}/api/bulk-upload/meters/template`, { const response = await fetch(`${import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000'}/api/bulk-upload/meters/template`, {
method: 'GET', method: 'GET',
headers: { headers: {
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`, 'Authorization': `Bearer ${token}`,
}, },
}); });
if (!response.ok) { if (!response.ok) {
throw new Error('Error descargando la plantilla'); // Try to get error message from response
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
const errorData = await response.json();
throw new Error(errorData.error || `Error ${response.status}: ${response.statusText}`);
}
throw new Error(`Error ${response.status}: ${response.statusText}`);
} }
const blob = await response.blob(); const blob = await response.blob();

View File

@@ -61,8 +61,15 @@ export interface ProjectInput {
* @returns Promise resolving to an array of projects * @returns Promise resolving to an array of projects
*/ */
export async function fetchProjects(): Promise<Project[]> { export async function fetchProjects(): Promise<Project[]> {
const response = await apiClient.get<Record<string, unknown>[]>('/api/projects'); const response = await apiClient.get<{ data: Record<string, unknown>[]; pagination?: unknown } | Record<string, unknown>[]>('/api/projects');
return transformArray<Project>(response);
// Handle paginated response
if (response && typeof response === 'object' && 'data' in response && Array.isArray(response.data)) {
return transformArray<Project>(response.data);
}
// Handle array response (fallback)
return transformArray<Project>(response as Record<string, unknown>[]);
} }
/** /**

View File

@@ -36,7 +36,6 @@ function transformArray<T>(arr: Record<string, unknown>[]): T[] {
export interface MeterReading { export interface MeterReading {
id: string; id: string;
meterId: string; meterId: string;
deviceId: string | null;
readingValue: number; readingValue: number;
readingType: string; readingType: string;
batteryLevel: number | null; batteryLevel: number | null;
@@ -47,8 +46,11 @@ export interface MeterReading {
// From join with meters // From join with meters
meterSerialNumber: string; meterSerialNumber: string;
meterName: string; meterName: string;
areaName: string | null; meterLocation: string | null;
concentratorId: string;
concentratorName: string;
projectId: string; projectId: string;
projectName: string;
} }
/** /**
@@ -85,7 +87,7 @@ export interface PaginatedResponse<T> {
export interface ReadingFilters { export interface ReadingFilters {
meterId?: string; meterId?: string;
projectId?: string; projectId?: string;
areaName?: string; concentratorId?: string;
startDate?: string; startDate?: string;
endDate?: string; endDate?: string;
readingType?: string; readingType?: string;
@@ -103,7 +105,7 @@ export async function fetchReadings(filters?: ReadingFilters): Promise<Paginated
if (filters?.meterId) params.meter_id = filters.meterId; if (filters?.meterId) params.meter_id = filters.meterId;
if (filters?.projectId) params.project_id = filters.projectId; if (filters?.projectId) params.project_id = filters.projectId;
if (filters?.areaName) params.area_name = filters.areaName; if (filters?.concentratorId) params.concentrator_id = filters.concentratorId;
if (filters?.startDate) params.start_date = filters.startDate; if (filters?.startDate) params.start_date = filters.startDate;
if (filters?.endDate) params.end_date = filters.endDate; if (filters?.endDate) params.end_date = filters.endDate;
if (filters?.readingType) params.reading_type = filters.readingType; if (filters?.readingType) params.reading_type = filters.readingType;
@@ -147,7 +149,6 @@ export async function fetchConsumptionSummary(projectId?: string): Promise<Consu
*/ */
export interface ReadingInput { export interface ReadingInput {
meterId: string; meterId: string;
deviceId?: string;
readingValue: number; readingValue: number;
readingType?: string; readingType?: string;
batteryLevel?: number; batteryLevel?: number;
@@ -164,7 +165,6 @@ export interface ReadingInput {
export async function createReading(data: ReadingInput): Promise<MeterReading> { export async function createReading(data: ReadingInput): Promise<MeterReading> {
const backendData = { const backendData = {
meter_id: data.meterId, meter_id: data.meterId,
device_id: data.deviceId,
reading_value: data.readingValue, reading_value: data.readingValue,
reading_type: data.readingType, reading_type: data.readingType,
battery_level: data.batteryLevel, battery_level: data.batteryLevel,
@@ -184,3 +184,88 @@ export async function createReading(data: ReadingInput): Promise<MeterReading> {
export async function deleteReading(id: string): Promise<void> { export async function deleteReading(id: string): Promise<void> {
return apiClient.delete<void>(`/api/readings/${id}`); return apiClient.delete<void>(`/api/readings/${id}`);
} }
/**
* Bulk upload result interface
*/
export interface BulkUploadResult {
success: boolean;
data: {
totalRows: number;
inserted: number;
failed: number;
errors: Array<{
row: number;
error: string;
data?: Record<string, unknown>;
}>;
};
}
/**
* Bulk upload readings from Excel file
* @param file - Excel file to upload
* @returns Promise resolving to upload result
*/
export async function bulkUploadReadings(file: File): Promise<BulkUploadResult> {
const token = localStorage.getItem('grh_access_token');
if (!token) {
throw new Error('No hay sesión activa. Por favor inicia sesión nuevamente.');
}
const formData = new FormData();
formData.append('file', file);
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000'}/api/bulk-upload/readings`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
},
body: formData,
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Error en la carga masiva');
}
return response.json();
}
/**
* Download readings template Excel file
*/
export async function downloadReadingTemplate(): Promise<void> {
const token = localStorage.getItem('grh_access_token');
if (!token) {
throw new Error('No hay sesión activa. Por favor inicia sesión nuevamente.');
}
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000'}/api/bulk-upload/readings/template`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (!response.ok) {
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
const errorData = await response.json();
throw new Error(errorData.error || `Error ${response.status}: ${response.statusText}`);
}
throw new Error(`Error ${response.status}: ${response.statusText}`);
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'plantilla_lecturas.xlsx';
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
}

View File

@@ -12,6 +12,7 @@ import {
Filter, Filter,
X, X,
Activity, Activity,
Upload,
} from "lucide-react"; } from "lucide-react";
import { import {
fetchReadings, fetchReadings,
@@ -21,6 +22,7 @@ import {
type Pagination, type Pagination,
} from "../../api/readings"; } from "../../api/readings";
import { fetchProjects, type Project } from "../../api/projects"; import { fetchProjects, type Project } from "../../api/projects";
import ReadingsBulkUploadModal from "./ReadingsBulkUploadModal";
export default function ConsumptionPage() { export default function ConsumptionPage() {
const [readings, setReadings] = useState<MeterReading[]>([]); const [readings, setReadings] = useState<MeterReading[]>([]);
@@ -41,6 +43,7 @@ export default function ConsumptionPage() {
const [endDate, setEndDate] = useState<string>(""); const [endDate, setEndDate] = useState<string>("");
const [search, setSearch] = useState<string>(""); const [search, setSearch] = useState<string>("");
const [showFilters, setShowFilters] = useState(false); const [showFilters, setShowFilters] = useState(false);
const [showBulkUpload, setShowBulkUpload] = useState(false);
useEffect(() => { useEffect(() => {
const loadProjects = async () => { const loadProjects = async () => {
@@ -92,7 +95,7 @@ export default function ConsumptionPage() {
(r) => (r) =>
(r.meterSerialNumber ?? "").toLowerCase().includes(q) || (r.meterSerialNumber ?? "").toLowerCase().includes(q) ||
(r.meterName ?? "").toLowerCase().includes(q) || (r.meterName ?? "").toLowerCase().includes(q) ||
(r.areaName ?? "").toLowerCase().includes(q) || (r.meterLocation ?? "").toLowerCase().includes(q) ||
String(r.readingValue).includes(q) String(r.readingValue).includes(q)
); );
}, [readings, search]); }, [readings, search]);
@@ -121,12 +124,12 @@ export default function ConsumptionPage() {
}; };
const exportToCSV = () => { const exportToCSV = () => {
const headers = ["Fecha", "Medidor", "Serial", "Área", "Valor", "Tipo", "Batería", "Señal"]; const headers = ["Fecha", "Medidor", "Serial", "Ubicación", "Valor", "Tipo", "Batería", "Señal"];
const rows = filteredReadings.map((r) => [ const rows = filteredReadings.map((r) => [
formatFullDate(r.receivedAt), formatFullDate(r.receivedAt),
r.meterName || "—", r.meterName || "—",
r.meterSerialNumber || "—", r.meterSerialNumber || "—",
r.areaName || "—", r.meterLocation || "—",
r.readingValue.toFixed(2), r.readingValue.toFixed(2),
r.readingType || "—", r.readingType || "—",
r.batteryLevel !== null ? `${r.batteryLevel}%` : "—", r.batteryLevel !== null ? `${r.batteryLevel}%` : "—",
@@ -162,6 +165,13 @@ export default function ConsumptionPage() {
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button
onClick={() => setShowBulkUpload(true)}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-gradient-to-r from-emerald-500 to-teal-600 rounded-xl hover:from-emerald-600 hover:to-teal-700 transition-all shadow-sm shadow-emerald-500/25"
>
<Upload size={16} />
Carga Masiva
</button>
<button <button
onClick={() => loadData(pagination.page)} onClick={() => loadData(pagination.page)}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-slate-600 bg-white border border-slate-200 rounded-xl hover:bg-slate-50 hover:border-slate-300 transition-all shadow-sm" className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-slate-600 bg-white border border-slate-200 rounded-xl hover:bg-slate-50 hover:border-slate-300 transition-all shadow-sm"
@@ -353,7 +363,7 @@ export default function ConsumptionPage() {
Serial Serial
</th> </th>
<th className="px-5 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider"> <th className="px-5 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">
Área Ubicación
</th> </th>
<th className="px-5 py-3 text-right text-xs font-semibold text-slate-500 uppercase tracking-wider"> <th className="px-5 py-3 text-right text-xs font-semibold text-slate-500 uppercase tracking-wider">
Consumo Consumo
@@ -415,7 +425,7 @@ export default function ConsumptionPage() {
</code> </code>
</td> </td>
<td className="px-5 py-3.5"> <td className="px-5 py-3.5">
<span className="text-sm text-slate-600">{reading.areaName || "—"}</span> <span className="text-sm text-slate-600">{reading.meterLocation || "—"}</span>
</td> </td>
<td className="px-5 py-3.5 text-right"> <td className="px-5 py-3.5 text-right">
<span className="text-sm font-semibold text-slate-800 tabular-nums"> <span className="text-sm font-semibold text-slate-800 tabular-nums">
@@ -440,6 +450,16 @@ export default function ConsumptionPage() {
</div> </div>
</div> </div>
</div> </div>
{showBulkUpload && (
<ReadingsBulkUploadModal
onClose={() => setShowBulkUpload(false)}
onSuccess={() => {
loadData(1);
setShowBulkUpload(false);
}}
/>
)}
</div> </div>
); );
} }

View File

@@ -0,0 +1,210 @@
import { useState, useRef } from "react";
import { Upload, Download, X, AlertCircle, CheckCircle } from "lucide-react";
import { bulkUploadReadings, downloadReadingTemplate, type BulkUploadResult } from "../../api/readings";
type Props = {
onClose: () => void;
onSuccess: () => void;
};
export default function ReadingsBulkUploadModal({ onClose, onSuccess }: Props) {
const [file, setFile] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
const [result, setResult] = useState<BulkUploadResult | null>(null);
const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0];
if (selectedFile) {
// Validate file type
const validTypes = [
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.ms-excel",
];
if (!validTypes.includes(selectedFile.type)) {
setError("Solo se permiten archivos Excel (.xlsx, .xls)");
return;
}
setFile(selectedFile);
setError(null);
setResult(null);
}
};
const handleUpload = async () => {
if (!file) return;
setUploading(true);
setError(null);
setResult(null);
try {
const uploadResult = await bulkUploadReadings(file);
setResult(uploadResult);
if (uploadResult.data.inserted > 0) {
onSuccess();
}
} catch (err) {
setError(err instanceof Error ? err.message : "Error en la carga");
} finally {
setUploading(false);
}
};
const handleDownloadTemplate = async () => {
try {
await downloadReadingTemplate();
} catch (err) {
setError(err instanceof Error ? err.message : "Error descargando plantilla");
}
};
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 w-[600px] max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-semibold">Carga Masiva de Lecturas</h2>
<button onClick={onClose} className="text-gray-500 hover:text-gray-700">
<X size={20} />
</button>
</div>
{/* Instructions */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4">
<h3 className="font-medium text-blue-800 mb-2">Instrucciones:</h3>
<ol className="text-sm text-blue-700 space-y-1 list-decimal list-inside">
<li>Descarga la plantilla Excel con el formato correcto</li>
<li>Llena los datos de las lecturas (meter_serial y reading_value son obligatorios)</li>
<li>El meter_serial debe coincidir con un medidor existente</li>
<li>Sube el archivo Excel completado</li>
</ol>
</div>
{/* Download Template Button */}
<button
onClick={handleDownloadTemplate}
className="flex items-center gap-2 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 mb-4"
>
<Download size={16} />
Descargar Plantilla Excel
</button>
{/* File Input */}
<div className="border-2 border-dashed border-gray-300 rounded-lg p-6 mb-4 text-center">
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
accept=".xlsx,.xls"
className="hidden"
/>
{file ? (
<div className="flex items-center justify-center gap-2">
<CheckCircle className="text-green-500" size={20} />
<span className="text-gray-700">{file.name}</span>
<button
onClick={() => {
setFile(null);
setResult(null);
if (fileInputRef.current) fileInputRef.current.value = "";
}}
className="text-red-500 hover:text-red-700 ml-2"
>
<X size={16} />
</button>
</div>
) : (
<div>
<Upload className="mx-auto text-gray-400 mb-2" size={32} />
<p className="text-gray-600 mb-2">
Arrastra un archivo Excel aquí o
</p>
<button
onClick={() => fileInputRef.current?.click()}
className="text-blue-600 hover:text-blue-800 font-medium"
>
selecciona un archivo
</button>
</div>
)}
</div>
{/* Error Message */}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-4 flex items-start gap-2">
<AlertCircle className="text-red-500 shrink-0" size={20} />
<p className="text-red-700 text-sm">{error}</p>
</div>
)}
{/* Upload Result */}
{result && (
<div
className={`border rounded-lg p-4 mb-4 ${
result.success
? "bg-green-50 border-green-200"
: "bg-yellow-50 border-yellow-200"
}`}
>
<h4 className="font-medium mb-2">
{result.success ? "Carga completada" : "Carga completada con errores"}
</h4>
<div className="text-sm space-y-1">
<p>Total de filas: {result.data.totalRows}</p>
<p className="text-green-600">Insertadas: {result.data.inserted}</p>
{result.data.failed > 0 && (
<p className="text-red-600">Fallidas: {result.data.failed}</p>
)}
</div>
{/* Error Details */}
{result.data.errors.length > 0 && (
<div className="mt-3">
<h5 className="font-medium text-sm mb-2">Errores:</h5>
<div className="max-h-40 overflow-y-auto bg-white rounded border p-2">
{result.data.errors.map((err, idx) => (
<div key={idx} className="text-xs text-red-600 py-1 border-b last:border-0">
Fila {err.row}: {err.error}
</div>
))}
</div>
</div>
)}
</div>
)}
{/* Actions */}
<div className="flex justify-end gap-2 pt-3 border-t">
<button
onClick={onClose}
className="px-4 py-2 rounded hover:bg-gray-100"
>
{result ? "Cerrar" : "Cancelar"}
</button>
{!result && (
<button
onClick={handleUpload}
disabled={!file || uploading}
className="flex items-center gap-2 bg-[#4c5f9e] text-white px-4 py-2 rounded hover:bg-[#3d4d7e] disabled:opacity-50 disabled:cursor-not-allowed"
>
{uploading ? (
<>
<span className="animate-spin"></span>
Cargando...
</>
) : (
<>
<Upload size={16} />
Cargar Lecturas
</>
)}
</button>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,6 +1,11 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import multer from 'multer'; 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 // Configure multer for memory storage
const storage = multer.memoryStorage(); const storage = multer.memoryStorage();
@@ -11,13 +16,17 @@ export const upload = multer({
fileSize: 10 * 1024 * 1024, // 10MB max fileSize: 10 * 1024 * 1024, // 10MB max
}, },
fileFilter: (_req, file, cb) => { fileFilter: (_req, file, cb) => {
// Accept Excel files only // Accept Excel files only - check both MIME type and extension
const allowedMimes = [ const allowedMimes = [
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', // .xlsx 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', // .xlsx
'application/vnd.ms-excel', // .xls '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); cb(null, true);
} else { } else {
cb(new Error('Solo se permiten archivos Excel (.xlsx, .xls)')); 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> { export async function getAll(req: Request, res: Response): Promise<void> {
try { try {
const page = parseInt(req.query.page as string, 10) || 1; 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 = {}; const filters: meterService.MeterFilters = {};

View File

@@ -1,5 +1,11 @@
import { Router } from 'express'; 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'; import { authenticateToken } from '../middleware/auth.middleware';
const router = Router(); const router = Router();
@@ -19,4 +25,16 @@ router.post('/meters', upload.single('file'), uploadMeters);
*/ */
router.get('/meters/template', downloadMeterTemplate); 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; export default router;

View File

@@ -315,3 +315,294 @@ export function generateMeterTemplate(): Buffer {
return Buffer.from(XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' })); 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 { export interface MeterReading {
id: string; id: string;
meter_id: string; meter_id: string;
device_id: string | null;
reading_value: number; reading_value: number;
reading_type: string; reading_type: string;
battery_level: number | null; battery_level: number | null;
@@ -67,7 +66,6 @@ export interface PaginatedResult<T> {
*/ */
export interface CreateReadingInput { export interface CreateReadingInput {
meter_id: string; meter_id: string;
device_id?: string;
reading_value: number; reading_value: number;
reading_type?: string; reading_type?: string;
battery_level?: number; battery_level?: number;
@@ -147,7 +145,7 @@ export async function getAll(
// Get paginated data with meter, concentrator, and project info // Get paginated data with meter, concentrator, and project info
const dataQuery = ` const dataQuery = `
SELECT 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, 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.serial_number as meter_serial_number, m.name as meter_name, m.location as meter_location,
m.concentrator_id, c.name as concentrator_name, 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> { export async function getById(id: string): Promise<MeterReadingWithMeter | null> {
const result = await query<MeterReadingWithMeter>( const result = await query<MeterReadingWithMeter>(
`SELECT `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, 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.serial_number as meter_serial_number, m.name as meter_name, m.location as meter_location,
m.concentrator_id, c.name as concentrator_name, 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> { export async function create(data: CreateReadingInput): Promise<MeterReading> {
const result = await query<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) battery_level, signal_strength, raw_payload, received_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, COALESCE($8, NOW())) VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7, NOW()))
RETURNING id, meter_id, device_id, reading_value, reading_type, RETURNING id, meter_id, reading_value, reading_type,
battery_level, signal_strength, raw_payload, received_at, created_at`, battery_level, signal_strength, raw_payload, received_at, created_at`,
[ [
data.meter_id, data.meter_id,
data.device_id || null,
data.reading_value, data.reading_value,
data.reading_type || 'AUTOMATIC', data.reading_type || 'AUTOMATIC',
data.battery_level || null, data.battery_level || null,