Sincronización
Sincronización
Prefijo: /api/sync
Requiere Auth: Sí
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:
| Ruta | Protección | Mecanismo |
|---|---|---|
POST /api/ventas/ (online) | Robusta | idempotency_key + UniqueConstraint + auto-generación SHA-256 |
POST /api/sync/push (offline→online) | Robusta | Batch-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_key4. Si respuesta = 201 → venta sincronizada5. Si respuesta = 409 → venta ya existe (marcar como sincronizada)6. Si respuesta = 500/timeout → reintentar con la MISMA keyCompatibilidad de versiones
| Backend | Cliente | Resultado |
|---|---|---|
| Viejo (sin auto-key) | Viejo (sin key) | DUPLICADOS (riesgo actual) |
| Nuevo (con auto-key) | Viejo (sin key) | SEGURO — backend genera key estable |
| Nuevo | Nuevo (con key) | SEGURO — doble protección |
| Obligatorio | Viejo (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: Sí
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:
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: Sí
Permiso requerido: configuracion, crear
Respuesta exitosa: 200 OK
Response:
{ "status": "ok", "pushed": 15, "pulled": 23}cURL:
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: Sí
Permiso requerido: configuracion, leer
Respuesta exitosa: 200 OK
Response:
{ "pending_count": 5}cURL:
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
datase valida contra el schemaSyncData*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
UPDATEde productos,stockse ignora (solo cambia víainventario_movimientos); enCREATEde 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. Consync_rbac_strict=trueen config, la denegación devuelve403y aborta. - Datos inválidos → el evento se salta y aparece en
skipped_events(batchpartial).
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.
| Entidad | Módulo | Acción |
|---|---|---|
ventas | ventas | VENTA_LOCAL / VENTA_ELECTRONICA (según tipoPago) |
caja | caja | APERTURA_CAJA |
jornada_caja | jornadas | CIERRE_CAJA (si estado=CERRADA) / APERTURA_CAJA |
egresos | egresos | EGRESO / INGRESO |
creditos_movimientos | creditos | ABONO / MOVIMIENTO_CREDITO |
inventario_movimientos | productos | MOVIMIENTO_STOCK |
productos | productos | PRODUCTO |
clientes | clientes | CLIENTE |
cotizaciones | cotizaciones | COTIZACION |
creditos | creditos | CREDITO |
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: Sí
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:
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: Sí
Permiso requerido: configuracion, leer
Parámetros de consulta (Query Parameters):
since_version(integer, por defecto0): Versión base para obtener cambios.limit(integer, por defecto500): 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:
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: Sí
Permiso requerido: N/A (cualquier usuario autenticado con tenant_id y terminal_id en JWT)
Parámetros body: SyncPullRequest
| Campo | Tipo | Obligatorio | Descripción |
|---|---|---|---|
since_version | integer | ❌ | Versión base (por defecto usa _sync_state.last_sync_version del dispositivo) |
limit | integer | ❌ | Máximo de registros por página (default: 500) |
device_id | string | ❌ | ID 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:
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 httpxresp = 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: Sí
Permiso requerido: N/A (cualquier usuario autenticado con tenant_id en el JWT)
Body request: SyncDeviceReportRequest
| Campo | Tipo | Obligatorio | Descripción |
|---|---|---|---|
events_pushed | integer | ❌ (0) | Eventos enviados al cloud (acumulado del ciclo) |
events_pulled | integer | ❌ (0) | Eventos recibidos del cloud (acumulado del ciclo) |
pending | integer | ❌ (0) | Eventos pendientes de sincronizar en el terminal |
last_sync_at | string | ❌ | Ú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:
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:
| Campo | Tipo | Descripción |
|---|---|---|
throughput.eventos_por_hora | int | Eventos procesados en la última hora |
throughput.eventos_hoy | int | Total de eventos del día |
conflictos.total_hoy | int | Conflictos detectados hoy |
errores.total_hoy | int | Errores de sync hoy |
cola_pendiente | int | Eventos en cola sin procesar + pendientes de los terminales |
dispositivos.reportando | int | Terminales con registro de telemetría (SyncDeviceStats) |
dispositivos.activos_hora | int | Terminales que reportaron en la última hora |
dispositivos.activos_hoy | int | Terminales que reportaron hoy |
dispositivos.total_pushed | int | Suma real de eventos enviados al cloud por los terminales |
dispositivos.total_pulled | int | Suma real de eventos recibidos del cloud por los terminales |
dispositivos.cola_pendiente_total | int | Suma de pendientes reportados por los terminales |
dispositivos.ultimo_report | string | Timestamp del reporte más reciente (ISO 8601) o null |
dispositivos.listado | array | Registro por terminal (últimos 20): terminal_id, events_pushed, events_pulled, pending, last_sync_at |
orchestrator.total_cycles | int | Ciclos de sync completados |
orchestrator.last_sync_at | string | Último sync (ISO 8601) |
orchestrator.total_pushed | int | Total real de eventos enviados al cloud (agregado de terminales) |
orchestrator.total_pulled | int | Total real de eventos recibidos del cloud (agregado de terminales) |
orchestrator.failed_cycles | int | Ciclos que fallaron |
orchestrator.is_syncing | bool | Si está sincronizando ahora |
orchestrator.cloud_healthy | bool | Si el cloud está respondiendo |
cURL:
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