- backend/ → apps/backend/, frontend/ → apps/frontend-web/, database/ → apps/database/ - mobile/ → apps/frontend-mobile/ - Updated .gitmodules, CLAUDE.md v2.0.0 - Added apps/_MAP.md Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
283 lines
7.7 KiB
TypeScript
283 lines
7.7 KiB
TypeScript
/**
|
|
* Sync Service
|
|
* ERP Transportistas
|
|
* Sprint S8 - TASK-007
|
|
*
|
|
* Background synchronization service for offline operations.
|
|
*/
|
|
|
|
import NetInfo from '@react-native-community/netinfo';
|
|
import { offlineStorage } from './OfflineStorage';
|
|
import { api } from './api';
|
|
import { TipoOperacionOffline, PrioridadSync } from '../types';
|
|
|
|
const MAX_RETRIES = 5;
|
|
const SYNC_BATCH_SIZE = 10;
|
|
const GPS_BATCH_SIZE = 50;
|
|
|
|
interface SyncResult {
|
|
total: number;
|
|
exitosos: number;
|
|
fallidos: number;
|
|
errores: string[];
|
|
}
|
|
|
|
class SyncService {
|
|
private isSyncing = false;
|
|
private listeners: Set<(syncing: boolean) => void> = new Set();
|
|
|
|
// ==================== Connection Monitoring ====================
|
|
|
|
async isOnline(): Promise<boolean> {
|
|
const state = await NetInfo.fetch();
|
|
return state.isConnected === true && state.isInternetReachable === true;
|
|
}
|
|
|
|
subscribeToSyncStatus(listener: (syncing: boolean) => void): () => void {
|
|
this.listeners.add(listener);
|
|
return () => this.listeners.delete(listener);
|
|
}
|
|
|
|
private notifyListeners(syncing: boolean): void {
|
|
this.listeners.forEach((listener) => listener(syncing));
|
|
}
|
|
|
|
// ==================== Main Sync ====================
|
|
|
|
async sincronizar(): Promise<SyncResult> {
|
|
if (this.isSyncing) {
|
|
return { total: 0, exitosos: 0, fallidos: 0, errores: ['Sincronización en progreso'] };
|
|
}
|
|
|
|
const isOnline = await this.isOnline();
|
|
if (!isOnline) {
|
|
return { total: 0, exitosos: 0, fallidos: 0, errores: ['Sin conexión'] };
|
|
}
|
|
|
|
this.isSyncing = true;
|
|
this.notifyListeners(true);
|
|
|
|
const result: SyncResult = {
|
|
total: 0,
|
|
exitosos: 0,
|
|
fallidos: 0,
|
|
errores: [],
|
|
};
|
|
|
|
try {
|
|
// 1. Sync GPS positions first (high volume, low priority per item)
|
|
const gpsResult = await this.sincronizarPosicionesGPS();
|
|
result.total += gpsResult.total;
|
|
result.exitosos += gpsResult.exitosos;
|
|
result.fallidos += gpsResult.fallidos;
|
|
result.errores.push(...gpsResult.errores);
|
|
|
|
// 2. Sync queued operations by priority
|
|
const opsResult = await this.sincronizarOperaciones();
|
|
result.total += opsResult.total;
|
|
result.exitosos += opsResult.exitosos;
|
|
result.fallidos += opsResult.fallidos;
|
|
result.errores.push(...opsResult.errores);
|
|
|
|
// 3. Clean up synced data
|
|
await offlineStorage.limpiarPosicionesSincronizadas();
|
|
|
|
} catch (error) {
|
|
result.errores.push(`Error general: ${(error as Error).message}`);
|
|
} finally {
|
|
this.isSyncing = false;
|
|
this.notifyListeners(false);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
// ==================== GPS Sync ====================
|
|
|
|
private async sincronizarPosicionesGPS(): Promise<SyncResult> {
|
|
const result: SyncResult = {
|
|
total: 0,
|
|
exitosos: 0,
|
|
fallidos: 0,
|
|
errores: [],
|
|
};
|
|
|
|
try {
|
|
const posiciones = await offlineStorage.obtenerPosicionesNoSincronizadas(GPS_BATCH_SIZE);
|
|
result.total = posiciones.length;
|
|
|
|
if (posiciones.length === 0) {
|
|
return result;
|
|
}
|
|
|
|
// Send batch to server
|
|
const response = await api.post('/api/v1/gps/posiciones/batch', {
|
|
posiciones: posiciones.map((p) => ({
|
|
latitud: p.latitud,
|
|
longitud: p.longitud,
|
|
velocidad: p.velocidad,
|
|
rumbo: p.rumbo,
|
|
precision: p.precision,
|
|
timestamp: p.timestamp,
|
|
})),
|
|
});
|
|
|
|
if (response.data.success) {
|
|
// Mark as synced
|
|
const ids = posiciones.map((p) => p.id);
|
|
await offlineStorage.marcarPosicionesSincronizadas(ids);
|
|
result.exitosos = posiciones.length;
|
|
} else {
|
|
result.fallidos = posiciones.length;
|
|
result.errores.push('Error al sincronizar posiciones GPS');
|
|
}
|
|
} catch (error) {
|
|
result.errores.push(`GPS: ${(error as Error).message}`);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
// ==================== Operations Sync ====================
|
|
|
|
private async sincronizarOperaciones(): Promise<SyncResult> {
|
|
const result: SyncResult = {
|
|
total: 0,
|
|
exitosos: 0,
|
|
fallidos: 0,
|
|
errores: [],
|
|
};
|
|
|
|
try {
|
|
const operaciones = await offlineStorage.obtenerOperacionesPendientes(SYNC_BATCH_SIZE);
|
|
result.total = operaciones.length;
|
|
|
|
for (const op of operaciones) {
|
|
try {
|
|
// Skip if max retries exceeded
|
|
if (op.intentos >= MAX_RETRIES) {
|
|
result.fallidos++;
|
|
result.errores.push(`${op.tipo}: Máximo de reintentos alcanzado`);
|
|
continue;
|
|
}
|
|
|
|
const payload = JSON.parse(op.payload);
|
|
const success = await this.procesarOperacion(op.tipo, payload);
|
|
|
|
if (success) {
|
|
await offlineStorage.marcarOperacionSincronizada(op.id);
|
|
result.exitosos++;
|
|
} else {
|
|
await offlineStorage.incrementarIntentos(op.id);
|
|
result.fallidos++;
|
|
}
|
|
} catch (error) {
|
|
await offlineStorage.incrementarIntentos(op.id);
|
|
result.fallidos++;
|
|
result.errores.push(`${op.tipo}: ${(error as Error).message}`);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
result.errores.push(`Operaciones: ${(error as Error).message}`);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
private async procesarOperacion(tipo: TipoOperacionOffline, payload: object): Promise<boolean> {
|
|
switch (tipo) {
|
|
case TipoOperacionOffline.EVENTO:
|
|
return this.sincronizarEvento(payload);
|
|
case TipoOperacionOffline.POD:
|
|
return this.sincronizarPOD(payload);
|
|
case TipoOperacionOffline.CHECKLIST:
|
|
return this.sincronizarChecklist(payload);
|
|
default:
|
|
console.warn(`Tipo de operación no manejado: ${tipo}`);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private async sincronizarEvento(payload: object): Promise<boolean> {
|
|
const response = await api.post('/api/v1/tracking/eventos', payload);
|
|
return response.data.success === true;
|
|
}
|
|
|
|
private async sincronizarPOD(payload: object): Promise<boolean> {
|
|
const response = await api.post('/api/v1/viajes/pod', payload);
|
|
return response.data.success === true;
|
|
}
|
|
|
|
private async sincronizarChecklist(payload: object): Promise<boolean> {
|
|
const response = await api.post('/api/v1/viajes/checklist', payload);
|
|
return response.data.success === true;
|
|
}
|
|
|
|
// ==================== Enqueue Operations ====================
|
|
|
|
async encolarEvento(evento: object): Promise<void> {
|
|
await offlineStorage.encolarOperacion(
|
|
TipoOperacionOffline.EVENTO,
|
|
evento,
|
|
PrioridadSync.ALTA
|
|
);
|
|
|
|
// Try immediate sync if online
|
|
if (await this.isOnline()) {
|
|
this.sincronizar();
|
|
}
|
|
}
|
|
|
|
async encolarPOD(pod: object): Promise<void> {
|
|
await offlineStorage.encolarOperacion(
|
|
TipoOperacionOffline.POD,
|
|
pod,
|
|
PrioridadSync.ALTA
|
|
);
|
|
|
|
if (await this.isOnline()) {
|
|
this.sincronizar();
|
|
}
|
|
}
|
|
|
|
async encolarChecklist(checklist: object): Promise<void> {
|
|
await offlineStorage.encolarOperacion(
|
|
TipoOperacionOffline.CHECKLIST,
|
|
checklist,
|
|
PrioridadSync.MEDIA
|
|
);
|
|
|
|
if (await this.isOnline()) {
|
|
this.sincronizar();
|
|
}
|
|
}
|
|
|
|
async guardarPosicionGPS(
|
|
latitud: number,
|
|
longitud: number,
|
|
velocidad: number,
|
|
rumbo: number,
|
|
precision: number
|
|
): Promise<void> {
|
|
await offlineStorage.guardarPosicion(latitud, longitud, velocidad, rumbo, precision);
|
|
}
|
|
|
|
// ==================== Status ====================
|
|
|
|
async obtenerEstadoSync(): Promise<{
|
|
pendientes: number;
|
|
posicionesGPS: number;
|
|
ultimaSync?: string;
|
|
enProgreso: boolean;
|
|
}> {
|
|
const stats = await offlineStorage.getEstadisticas();
|
|
return {
|
|
pendientes: stats.operacionesPendientes,
|
|
posicionesGPS: stats.posicionesNoSincronizadas,
|
|
enProgreso: this.isSyncing,
|
|
};
|
|
}
|
|
}
|
|
|
|
export const syncService = new SyncService();
|