Skip to content

Sincronización

Sincronización

Prefijo: /api/sync
Requiere Auth:
Permiso requerido: configuracion, *

Módulo de sincronización del motor offline-first. NeoPOS opera con PostgreSQL en la nube como primaria y SQLite como fallback local. El SyncManager se encarga de sincronizar automáticamente en segundo plano cuando se reconecta.

Estrategia de Idempotencia — Ventas End-to-End

NeoPOS previene duplicación de ventas en todas las rutas de creación:

RutaProtecciónMecanismo
POST /api/ventas/ (online)Robustaidempotency_key + UniqueConstraint + auto-generación SHA-256
POST /api/sync/push (offline→online)RobustaBatch-level UNIQUE + event-level idempotency + sync_uuid

Flujo del cliente POS (recomendado)

1. Crear venta → generar key estable = SHA256(vendedor|fecha|hora|total|productos)
2. Almacenar key en la venta local (SQLite)
3. Enviar POST /api/ventas/ con idempotency_key
4. Si respuesta = 201 → venta sincronizada
5. Si respuesta = 409 → venta ya existe (marcar como sincronizada)
6. Si respuesta = 500/timeout → reintentar con la MISMA key

Compatibilidad de versiones

BackendClienteResultado
Viejo (sin auto-key)Viejo (sin key)DUPLICADOS (riesgo actual)
Nuevo (con auto-key)Viejo (sin key)SEGURO — backend genera key estable
NuevoNuevo (con key)SEGURO — doble protección
ObligatorioViejo (sin key)HTTP 400 — fuerza actualización

GET /api/sync/status

Descripción: Estado actual del motor de sincronización y estadísticas del orquestador.
Requiere Auth:
Permiso requerido: configuracion, leer

Respuesta exitosa: 200 OK

Response:

{
"status": "ok",
"cloud_healthy": true,
"is_syncing": false,
"stats": {
"total_cycles": 150,
"last_sync_at": "2026-07-23T10:00:00Z",
"total_pushed": 320,
"total_pulled": 450,
"failed_cycles": 2
}
}

cURL:

Terminal window
curl http://localhost/api/sync/status -H "Authorization: Bearer <token>"

POST /api/sync/trigger

Descripción: Fuerza un ciclo de sincronización inmediato en el orquestador (PUSH + PULL).
Requiere Auth:
Permiso requerido: configuracion, crear

Respuesta exitosa: 200 OK

Response:

{
"status": "ok",
"pushed": 15,
"pulled": 23
}

cURL:

Terminal window
curl -X POST http://localhost/api/sync/trigger -H "Authorization: Bearer <token>"

GET /api/sync/pending

Descripción: Número de mutaciones pendientes por sincronizar en el ChangeLog local.
Requiere Auth:
Permiso requerido: configuracion, leer

Respuesta exitosa: 200 OK

Response:

{
"pending_count": 5
}

cURL:

Terminal window
curl http://localhost/api/sync/pending -H "Authorization: Bearer <token>"

POST /api/sync/push [NUEVO]

Descripción: Envía un lote (batch) de eventos locales con soporte de idempotencia y aislamiento multitentant. El endpoint procesa los eventos asegurando que los movimientos de inventario se gestionen mediante lógica ledger de stock incremental (DeltaStockReconciler).

Contrato endurecido (hardening P0-A2):

  • Whitelist por entidad — cada data se valida contra el schema SyncData* correspondiente (SyncDataVenta, SyncDataProducto, SyncDataCliente, etc.). Campos fuera de la whitelist (ej. tenant_id, id, idempotency_key, columnas de bookkeeping del servidor) se descartan silenciosamente (modo leniente), no se aplican.
  • Stock protegido — en UPDATE de productos, stock se ignora (solo cambia vía inventario_movimientos); en CREATE de producto nuevo se permite el stock inicial.
  • RBAC por entidad — cada evento requiere el permiso (módulo, acción) según la entidad/operación. Por defecto modo auditoría: las denegaciones se registran en logs y el batch continúa. Con sync_rbac_strict=true en config, la denegación devuelve 403 y aborta.
  • Datos inválidos → el evento se salta y aparece en skipped_events (batch partial).

Auditoría de eventos sincronizados (transacciones por usuario): cada evento procesado con éxito genera una entrada LogAuditoria en el cloud con el usuario del tenant (resuelto del user_id del JWT, o del campo usuario/vendedor/responsablePago del evento). Así, el administrador del tenant ve en GET /api/logs/ la actividad de todos sus usuarios (global por tenant), incluyendo las operaciones sincronizadas desde terminales POS (apertura/cierre de caja, ventas, egresos, abonos) que antes quedaban solo en el SQLite local del dispositivo.

EntidadMóduloAcción
ventasventasVENTA_LOCAL / VENTA_ELECTRONICA (según tipoPago)
cajacajaAPERTURA_CAJA
jornada_cajajornadasCIERRE_CAJA (si estado=CERRADA) / APERTURA_CAJA
egresosegresosEGRESO / INGRESO
creditos_movimientoscreditosABONO / MOVIMIENTO_CREDITO
inventario_movimientosproductosMOVIMIENTO_STOCK
productosproductosPRODUCTO
clientesclientesCLIENTE
cotizacionescotizacionesCOTIZACION
creditoscreditosCREDITO

La idempotencia del batch se mantiene: re-enviar el mismo idempotency_key retorna already_processed y no duplica los logs de auditoría.

Requiere Auth:
Permiso requerido: por entidad (ventas,crear, productos,actualizar, etc.) mapeado en sync_entity_permiso(); Superadmin pasa siempre

Body request:

{
"transaction_id": "abc-123-def",
"idempotency_key": "batch-key-001",
"terminal_id": "term-001",
"user_id": 42,
"events": [
{
"entity_type": "ventas",
"entity_id": "venta-uuid-99",
"operation": "CREATE",
"data": {
"sync_uuid": "venta-uuid-99",
"total": 50000,
"cliente_id": 1
},
"idempotency_key": "venta-key-001"
},
{
"entity_type": "inventario_movimientos",
"entity_id": "inv-uuid-88",
"operation": "CREATE",
"data": {
"producto_sync_uuid": "prod-uuid-1",
"tipo_movimiento": "VENTA",
"cantidad": -2.0
},
"idempotency_key": "inv-key-001"
}
]
}

Respuesta exitosa: 200 OK

{
"status": "accepted",
"transaction_id": "abc-123-def",
"processed_events": 2,
"skipped_events": 0,
"errors": []
}

cURL:

Terminal window
curl -X POST http://localhost/api/sync/push \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"transaction_id": "abc-123-def",
"idempotency_key": "batch-key-001",
"terminal_id": "term-001",
"user_id": 42,
"events": [...]
}'

GET /api/sync/pull [NUEVO]

Descripción: Recupera cambios incrementales registrados en la nube desde una versión específica de ChangeLog. Los resultados se ordenan de manera topológica respetando las dependencias de llaves foráneas (TopologicalSorter) y se excluyen de forma automática los cambios originados por el dispositivo emisor (device_id).
Requiere Auth:
Permiso requerido: configuracion, leer

Parámetros de consulta (Query Parameters):

  • since_version (integer, por defecto 0): Versión base para obtener cambios.
  • limit (integer, por defecto 500): Límite de registros por página.
  • device_id (string, opcional): ID del dispositivo solicitante.

Respuesta exitosa: 200 OK

{
"changes": [
{
"version": 105,
"entity_type": "productos",
"entity_id": "prod-uuid-1",
"operation": "UPDATE",
"payload": {
"nombre": "Producto Actualizado",
"precio": 12000
},
"transaction_id": "tx-999",
"idempotency_key": "idem-prod-105"
}
],
"next_since": 106,
"has_more": false,
"total_available": 1
}

cURL:

Terminal window
curl "http://localhost/api/sync/pull?since_version=104&limit=100&device_id=term-001" \
-H "Authorization: Bearer <token>"

POST /api/sync/pull/apply [NUEVO]

Descripción: Obtiene y aplica cambios del cloud a la base de datos local en una sola operación. El servidor ejecuta CloudPuller.pull() que: obtiene cambios desde la última versión sincronizada, los ordena topológicamente y los aplica vía upsert a la base local.
Requiere Auth:
Permiso requerido: N/A (cualquier usuario autenticado con tenant_id y terminal_id en JWT)

Parámetros body: SyncPullRequest

CampoTipoObligatorioDescripción
since_versionintegerVersión base (por defecto usa _sync_state.last_sync_version del dispositivo)
limitintegerMáximo de registros por página (default: 500)
device_idstringID del dispositivo solicitante (por defecto terminal_id del JWT)

Respuesta exitosa: 200 OK

{
"status": "ok",
"pulled": 45,
"updated_version": 150
}

Errores: 400 (parámetros inválidos), 401 (token inválido)

cURL:

Terminal window
curl -X POST http://localhost/api/sync/pull/apply \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"since_version": 100, "limit": 500}'

Python:

import httpx
resp = httpx.post(
"http://localhost/api/sync/pull/apply",
json={"since_version": 100, "limit": 500},
headers={"Authorization": f"Bearer :token"}
)
data = resp.json() # {"status": "ok", "pulled": 45, "updated_version": 150}

JavaScript:

const resp = await fetch('http://localhost/api/sync/pull/apply', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'Authorization': `Bearer $:token`},
body: JSON.stringify({since_version: 100, limit: 500})
});
const data = await resp.json(); // {status: "ok", pulled: 45, updated_version: 150}

POST /api/sync/report [NUEVO]

Descripción: Telemetría de sincronización reportada por un terminal POS tras cada ciclo de sync. El cliente envía sus contadores acumulados (pushes/pulls/pendientes) y el panel “Métricas de Sincronización” los agrega para reflejar la actividad real del sistema (los contadores del orquestador miden solo su ciclo local→cloud que queda en 0 cuando el cliente escribe directo por REST). Best-effort e idempotente: siempre hace UPSERT por (tenant, terminal). También registra el heartbeat del terminal.
Requiere Auth:
Permiso requerido: N/A (cualquier usuario autenticado con tenant_id en el JWT)

Body request: SyncDeviceReportRequest

CampoTipoObligatorioDescripción
events_pushedinteger❌ (0)Eventos enviados al cloud (acumulado del ciclo)
events_pulledinteger❌ (0)Eventos recibidos del cloud (acumulado del ciclo)
pendinginteger❌ (0)Eventos pendientes de sincronizar en el terminal
last_sync_atstringÚltima sincronización (ISO 8601 o YYYY-MM-DD HH:MM:SS)

Body request (ejemplo):

{
"events_pushed": 12,
"events_pulled": 34,
"pending": 2,
"last_sync_at": "2026-08-16 13:38:21"
}

Respuesta exitosa: 200 OK

{
"ok": true,
"terminal_id": "term-001"
}

En error de persistencia responde 200 con {"ok": false} (best-effort, no rompe el ciclo del cliente).
Errores: 403 (sin tenant context)

cURL:

Terminal window
curl -X POST http://localhost/api/sync/report \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"events_pushed": 12, "events_pulled": 34, "pending": 2, "last_sync_at": "2026-08-16 13:38:21"}'

JavaScript:

const resp = await fetch('http://localhost/api/sync/report', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'Authorization': `Bearer $:token`},
body: JSON.stringify({events_pushed: 12, events_pulled: 34, pending: 2})
});
const data = await resp.json(); // {ok: true, terminal_id: "term-001"}

GET /api/sync/metrics

Descripción: Métricas del sistema de sincronización: throughput, conflictos, errores, cola pendiente y sección dispositivos con la telemetría real reportada por los terminales vía POST /api/sync/report (totales push/pull, activos por ventana y registro por terminal). Los campos orchestrator.total_pushed y orchestrator.total_pulled ahora reflejan la actividad de los terminales (suma de SyncDeviceStats), no los contadores del orquestador.
Requiere Auth: Sí (rol=3)
Permiso requerido: N/A (solo Superadmin)

Respuesta exitosa: 200 OK
Errores: 403 (no es Superadmin)

Response:

{
"throughput": {
"eventos_por_hora": 750,
"eventos_hoy": 15000
},
"conflictos": {
"total_hoy": 3
},
"errores": {
"total_hoy": 5
},
"cola_pendiente": 42,
"dispositivos": {
"reportando": 4,
"activos_hora": 2,
"activos_hoy": 4,
"total_pushed": 320,
"total_pulled": 450,
"cola_pendiente_total": 6,
"ultimo_report": "2026-08-16T13:38:21+00:00",
"listado": [
{
"terminal_id": "term-001",
"events_pushed": 150,
"events_pulled": 200,
"pending": 1,
"last_sync_at": "2026-08-16T13:38:21"
}
]
},
"orchestrator": {
"total_cycles": 150,
"last_sync_at": "2026-07-24T10:00:00Z",
"total_pushed": 320,
"total_pulled": 450,
"failed_cycles": 2,
"is_syncing": false,
"cloud_healthy": true,
"cloud_enabled": true
}
}

Campos:

CampoTipoDescripción
throughput.eventos_por_horaintEventos procesados en la última hora
throughput.eventos_hoyintTotal de eventos del día
conflictos.total_hoyintConflictos detectados hoy
errores.total_hoyintErrores de sync hoy
cola_pendienteintEventos en cola sin procesar + pendientes de los terminales
dispositivos.reportandointTerminales con registro de telemetría (SyncDeviceStats)
dispositivos.activos_horaintTerminales que reportaron en la última hora
dispositivos.activos_hoyintTerminales que reportaron hoy
dispositivos.total_pushedintSuma real de eventos enviados al cloud por los terminales
dispositivos.total_pulledintSuma real de eventos recibidos del cloud por los terminales
dispositivos.cola_pendiente_totalintSuma de pendientes reportados por los terminales
dispositivos.ultimo_reportstringTimestamp del reporte más reciente (ISO 8601) o null
dispositivos.listadoarrayRegistro por terminal (últimos 20): terminal_id, events_pushed, events_pulled, pending, last_sync_at
orchestrator.total_cyclesintCiclos de sync completados
orchestrator.last_sync_atstringÚltimo sync (ISO 8601)
orchestrator.total_pushedintTotal real de eventos enviados al cloud (agregado de terminales)
orchestrator.total_pulledintTotal real de eventos recibidos del cloud (agregado de terminales)
orchestrator.failed_cyclesintCiclos que fallaron
orchestrator.is_syncingboolSi está sincronizando ahora
orchestrator.cloud_healthyboolSi el cloud está respondiendo

cURL:

Terminal window
curl http://localhost/api/sync/metrics -H "Authorization: Bearer <token>"

JavaScript:

const resp = await fetch('http://localhost/api/sync/metrics', {
headers: {'Authorization': `Bearer $:token`}
});
const metrics = await resp.json();
// metrics.throughput.eventos_por_hora → 750
// metrics.cola_pendiente → 42
// metrics.dispositivos.total_pushed → 320 (actividad real de terminales)
// metrics.devices... → ver metrics.dispositivos.listado