API Portas — Móvil

Endpoints que replican el sector móvil del sistema viejo (portas.com.ar, app Symfony smatphone) para que importas.com.ar lo implemente de nuevo

Base URL: https://api.portascloud.ar  |  v1  |  Fase 2 (fotos) — actualizado 2026-08-29

1. Qué es esta API

El sistema viejo de Portas tiene una aplicación móvil (Symfony 1.4, app smatphone) con 18 módulos y tres perfiles de usuario distintos. Esta API expone esa misma funcionalidad como endpoints REST para que la reimplemente importas.com.ar.

Convive con la API Web (escritorio) en el mismo servicio y con la misma API key. Hay solapamiento deliberado: movimientos y comprobantes existen en las dos, porque en el sistema viejo también son pantallas distintas con filtros distintos.

Fase 1. Esta primera entrega cubre la identidad del usuario, sus preferencias de notificación, los dispositivos push y las tres pantallas de consulta del perfil Cliente. Las fases 2 a 4 (captura de fotos y QR, cargas y transporte, rendiciones y ODP) se agregan sobre la misma base.

2. Autenticación: API key + id_usuario

Son dos cosas separadas y hacen falta las dos:

QuéDónde vaPara qué sirve
API key Header Authorization: Bearer <key> Autentica a la aplicación. Es el secreto. Sin ella: 401.
id_usuario Query string (o body en POST/PATCH/PUT) Dice de qué usuario es la llamada. Decide qué ve y qué puede hacer.

id_usuario es el id_usuario_web de freewaynet.web_clientes_usuarios — el mismo padón de usuarios que usa el móvil viejo. Portas no valida la clave: el login lo hace importas contra sus propias tablas, y en cada llamada nos dice quién es.

El id_usuario no es un secreto — son enteros correlativos. Lo que protege a la API es la key del header. Quien tenga la key puede consultar como cualquier usuario, así que la key no puede viajar al navegador ni a la app del cliente final: las llamadas salen del backend de importas.

Los endpoints exigen scope movil:read o movil:write según el caso. Por ahora también se aceptan web:read / web:write, para que la key que importas ya tiene en producción sirva sin cambios.

Errores propios de id_usuario

CódigoHTTPCuándo
id_usuario_requerido400No vino, o no es un entero
usuario_inexistente404No existe ese id_usuario_web
usuario_inactivo403activo <> 1
interno_inactivo403Empleado cuyo usuario interno fue dado de baja
persona_no_habilitada403La operación es de otro perfil

3. Personas y qué ve cada una

Del id_usuario sale la persona, con las mismas reglas y en el mismo orden que el executeIngresar del Symfony viejo:

PersonaSe decide porAlcanceCredenciales del viejo
cliente Existe la fila en clientes Solo su id_cliente Movil, Cliente
proveedor Tiene id_proveedor_cliente id_cliente = 0 (nada, igual que hoy) Movil, Cliente
transportista Tiene id_transportista Nada en estas pantallas (tiene las suyas, Fase 3) Movil, Transporte
empleado Ninguna de las anteriores Todas las operaciones Movil, Empleado (+ Aprueba_fondos / Aprueba_rendicion)

El parámetro opcional id_cliente (uno o varios separados por coma: ?id_cliente=84,1167) acota ese alcance, nunca lo amplía. Le sirve sobre todo al empleado, que ve todo y quiere mirar un cliente.

4. GET /v1/movil/usuario

Quién es el usuario y qué menú mostrarle. Es el equivalente de "abrir la sesión" del móvil viejo: se llama una vez al entrar.

GET /v1/movil/usuario?id_usuario=123
{
  "ok": true,
  "usuario": {
    "id_usuario_web": 123,
    "id_cliente": 1167,
    "persona": "cliente",
    "usuario": "operaciones@ferrero.com",
    "nombre": "Juan Perez",
    "cargo": "Comercio Exterior",
    "razon_social": "FERRERO ARGENTINA S.A.",
    "web_carpeta": "ferrero",
    "id_proveedor_cliente": 0,
    "id_transportista": 0,
    "interno": null,
    "notificaciones": { "impo": true, "expo": false }
  },
  "credenciales": ["Movil", "Cliente"],
  "notificaciones": {
    "impo": true, "expo": false, "oficializacion": true,
    "finalizacion_carga": true, "permiso_cumplido": false,
    "arribo_mercaderia": true, "facturacion": true, "reclamo": false,
    "vencimiento_simi_djai": false, "vencimiento_temporal": false,
    "vencimiento_forzoso": false, "digitalizacion": true,
    "reintegro_aprobado": false, "coordinacion_transporte": false
  }
}

credenciales viene con los nombres que usan los security.yml del sistema viejo, para que el menú se pueda armar con el mismo criterio.

5. PATCH /v1/movil/usuario

La pantalla "Perfil": casillas de notificación y cambio de clave. Las dos partes son opcionales — se manda lo que se quiere cambiar.

PATCH /v1/movil/usuario
{
  "id_usuario": 123,
  "notificaciones": { "facturacion": false, "arribo_mercaderia": true },
  "clave_actual": "laDeAhora",
  "clave_nueva": "unaNuevaDe8+"
}

Es un PATCH de verdad: las casillas que no se mandan quedan como estaban. (El formulario viejo apagaba todo lo que no viniera, que es correcto para un form completo y peligroso para una API.)

CódigoHTTPCuándo
nada_para_cambiar422No vino ni notificaciones ni clave_nueva
notificacion_desconocida422Casilla que no existe (la respuesta lista las válidas)
clave_corta422Menos de 8 caracteres
clave_actual_incorrecta403No coincide con la vigente
Claves en texto plano. El Symfony viejo guarda y compara clave sin hashear. Mientras los dos sistemas convivan no se puede cambiar el formato sin romper el login del viejo, así que esta API hace lo mismo — la clave nunca sale en una respuesta, pero el almacenamiento sigue siendo plano. El paso a bcrypt queda para cuando se apague el sistema viejo.

6. Notificaciones por evento

La grilla de eventos de la pantalla "Perfil": una columna para importación y otra para exportación.

GET /v1/movil/notificaciones/eventos?id_usuario=123
{
  "ok": true,
  "eventos": {
    "importacion": [
      { "id_evento": 12, "nombre": "Arribo de mercadería", "sector": "Operaciones",
        "categoria": "", "color": "#3498db", "suscripto": true }
    ],
    "exportacion": [ ... ]
  }
}
PUT /v1/movil/notificaciones/eventos
{
  "id_usuario": 123,
  "eventos": [
    { "id_evento": 12, "operacion": 1, "suscripto": false },
    { "id_evento": 15, "operacion": 2, "suscripto": true }
  ]
}

operacion: 1 = importación, 2 = exportación. Es idempotente: se puede repetir el mismo PUT sin efectos raros.

Por qué "suscripto" y no la tabla directa. En la base, web_clientes_usuarios_eventos guarda exclusiones: si hay fila, al usuario NO se le avisa, y el default es recibir todo. Está al revés de lo que sugiere el nombre, y equivocarse deja a un cliente sin avisos o lo inunda. La API expone suscripto (true = le llega) y hace la traducción del lado del servidor.

7. Dispositivos (push)

Registro del token de notificaciones push. Reemplaza /registrar_movil.

POST /v1/movil/dispositivos
{ "id_usuario": 123, "token": "fcm-token-del-dispositivo", "so": "android" }

so: android, ios o web. Se escriben las dos cosas que escribe el viejo: el token vigente en web_clientes_usuarios (que es el que lee FreeWayNet para mandar el aviso) y el historial en web_clientes_usuarios_token.

GET /v1/movil/dispositivos?id_usuario=123

Lista los dispositivos registrados. El token se devuelve parcial (abc123…xyz789): alcanza para reconocerlo y no sirve para mandar push.

DELETE /v1/movil/dispositivos
{ "id_usuario": 123, "token": "fcm-token-del-dispositivo" }

Da de baja el dispositivo. Si era el vigente, también lo apaga en el usuario — si no, FreeWayNet le seguiría mandando avisos a un teléfono desvinculado.

8. GET /v1/movil/movimientos

Módulo ultimos_mov: la cronología de hitos y eventos de las operaciones que el usuario puede ver.

GET /v1/movil/movimientos?id_usuario=123&via=sea&canal=rojo&page=1&limit=25
{
  "ok": true,
  "items": [
    {
      "id_carpeta": "2026.04.94961",
      "legajo": 94961,
      "referencia": "PO-88213",
      "factura": "A-0001-00012345",
      "fecha": "2026-08-20 14:32:00",
      "id_hito": 4,
      "hito": "Oficialización",
      "evento": "Despacho oficializado",
      "observacion": ""
    }
  ],
  "paginado": { "pagina": 1, "limite": 25, "total": 412, "paginas": 17 }
}

Acepta todos los filtros comunes. limit va de 1 a 200 (25 por defecto).

9. GET /v1/movil/comprobantes-adeudados

Módulo comp_adeudados: lo que el cliente todavía debe.

GET /v1/movil/comprobantes-adeudados?id_usuario=123&estado=vencidos

estado: todos (default), vencidos, a_vencer. Más todos los filtros comunes.

{
  "ok": true,
  "items": [
    {
      "id_comprobante": "A-0001-00012345",
      "legajo": 94961,
      "id_carpeta": "2026.04.94961",
      "operacion": "22001EC01030375C",
      "referencia": "PO-88213",
      "mercaderia": "Chocolate",
      "factura_proveedor": "INV-2210",
      "fecha": "2026-07-15",
      "vencimiento": "2026-08-14",
      "total": 154320.55,
      "total_comprobante": 480000.00,
      "vencido": true,
      "dias_vencido": 10,
      "cliente": ""
    }
  ],
  "estado": "vencidos",
  "resumen": {
    "comprobantes": 12, "monto": 1854320.55,
    "vencidos": 5, "monto_vencido": 640210.00, "mas_antiguo": "2026-05-02"
  },
  "paginado": { "pagina": 1, "limite": 25, "total": 12, "paginas": 1 }
}
total es el SALDO, no el importe del comprobante. Es lo que queda por cobrar, ya descontadas las imputaciones, y es lo que cuadra con la cuenta corriente de FreeWayNet. El valor original del comprobante va aparte, en total_comprobante. Usar el segundo para mostrar deuda infla el total.

cliente viene con la razón social solo cuando la llamada es de un empleado (que ve varios clientes); para un cliente siempre es la propia y va vacío.

operacion acá NO es "importación / exportación". La vista view_cli_comprobantes_adeudados devuelve en esa columna el número de despacho (por ejemplo 22001EC01030375C), no el tipo de operación. El nombre viene heredado de la vista y es el mismo valor que devuelve /v1/web/comprobantes. Para separar impo de expo está el filtro tipo=import|export, que se resuelve contra carpetas.operacion.

10. Circulares y newsletters

Los dos tablones de novedades. Misma forma para los dos.

GET /v1/movil/circulares?id_usuario=123&q=aduana
GET /v1/movil/circulares/{id}?id_usuario=123
GET /v1/movil/newsletters?id_usuario=123
GET /v1/movil/newsletters/{id}?id_usuario=123

El listado no trae el cuerpo (son textos largos con HTML): solo título, fecha, link y palabras clave. El detalle agrega texto y texto_html. q busca en título y palabras clave.

Una diferencia que conviene mirar. El móvil viejo lista todo lo publicado sin mirar todos_los_clientes / id_clientes: si alguna circular estaba pensada para un cliente puntual, hoy la ven todos. Se replicó el comportamiento actual para no cambiar en la migración lo que la gente ve, pero los campos se devuelven en cada item por si hay que filtrar.

11. POST /v1/movil/legajos/{id}/fotos

Reemplaza las tres pantallas de fotos del móvil viejo (Foto Precinto, Inspección Mercadería y Subir Remito del transportista). Una sola llamada hace lo que antes eran dos pasos (subir y "Notificar"): crea el evento en la operación, guarda las fotos y deja la notificación al cliente pendiente.

POST /v1/movil/legajos/2026.04.94979/fotos   multipart/form-data
CampoDescripción
id_usuarioobligatorioComo en todos los endpoints móviles (puede ir en la query string).
tipoobligatorio precinto → evento 176 FOTO PRECINTO (empleado)
inspeccion → evento 188 INSPECCIÓN FÍSICA CANAL ROJO (empleado)
remito → evento 240 FOTO REMITO (transportista de esa carga, o empleado)
fotos[]1 a 10 Imágenes jpg / png / webp, hasta 12 MB cada una. Se normalizan en el servidor: orientación corregida, lado mayor 1600 px, JPEG. También se acepta un campo simple foto.
observacionopcional Texto libre (hasta 2000). Para inspeccion es lo que se registra como observación del evento ("Todo ok.") y permite crear el evento sin foto, como el viejo. Para precinto y remito el título se arma solo: FOTO PRECINTO (94979) - 7381ST.
id_registroopcional Para agregar fotos a un evento creado por una llamada anterior del mismo tipo (lo devuelve la respuesta) en vez de crear otro. Si se omite, cada llamada crea un evento nuevo — el mail al cliente igual agrupa todo lo de la carpeta.
curl -X POST "https://api.portascloud.ar/v1/movil/legajos/2026.04.94979/fotos?id_usuario=123" \
     -H "Authorization: Bearer $API_KEY" \
     -F tipo=precinto \
     -F "fotos[]=@IMG_20260828_110608.jpg" \
     -F "fotos[]=@IMG_20260828_110612.jpg"

Respuesta 201:

{
  "ok": true,
  "id_carpeta": "2026.04.94979",
  "tipo": "precinto",
  "id_evento": 176,
  "id_registro": 812345,
  "evento_nuevo": true,
  "observacion": "FOTO PRECINTO (94979) - 7381ST",
  "fotos": [
    { "id_archivo": 601234, "nombre_archivo": "IMG_20260828_110608.jpg",
      "tamanio_bytes": 190935, "download_url": "https://api.portascloud.ar/v1/d/<token>",
      "ya_existia": false }
  ],
  "notificacion": "pendiente"
}

Qué pasa después. Las fotos quedan en el storage de Portas (descargables ya por download_url), el Sincronizador las lleva a la red de Portas a la carpeta del evento (FreeWayNet las muestra con el clip) y el circuito de notificaciones le avisa al cliente con las fotos, respetando sus preferencias por evento — igual que cuando se cargaban desde el móvil viejo.

HTTPCódigoCuándo
403persona_no_habilitadaUn cliente intenta subir; el tipo no es para su persona.
403carpeta_ajenaTransportista sobre una carga que no es suya.
404carpeta_inexistente / evento_inexistente
409carpeta_anulada
415extension_not_allowed / no_es_imagenNo es jpg/png/webp o el archivo está corrupto.
422tipo_invalido / sin_fotos / demasiadas_fotos
400upload_errorFoto por encima del límite de tamaño o incompleta.

12. Filtros comunes de operación

Los aceptan /movil/movimientos y /movil/comprobantes-adeudados (y los listados de las fases siguientes). Son los mismos catorce filtros que el Symfony viejo repetía en cada módulo.

ParámetroValoresQué filtra
tipoimport / exportImportación o exportación
legajonúmeroNúmero de legajo (sufijo del id_carpeta)
viaair / sea / groundVía de transporte
canalgreen / orange / redCanal aduanero (rojo agrupa 3, 4 y 6)
mercaderiatextoNombre de la mercadería (parcial)
referenciatextoReferencia del cliente (parcial)
facturatextoFactura del proveedor (parcial)
comprobantetextoNúmero de comprobante (parcial)
despachotextoNúmero de despacho completo (parcial)
destinacioncódigoDestinación aduanera
of_desde / of_hastafechaFecha de oficialización
fin_desde / fin_hastafechaFecha de finalización de carga
id_cliente84 o 84,1167Acota el alcance a esos clientes
page / limitenterosPaginado

Las fechas se aceptan como AAAA-MM-DD o DD/MM/AAAA. Un filtro mal escrito se ignora en silencio, igual que en el sistema viejo.

Los cuatro últimos filtros de la tabla (factura, despacho, destinación y las fechas) obligan a unir view_consulta_caratula_resumen, que se resuelve en vivo y es la parte cara de la consulta. Solo se une cuando alguno de ellos viene: pedirlos sin necesidad hace más lenta la página.

13. Errores

Formato único, igual que el resto de la API:

{ "ok": false, "error": "usuario_inexistente", "message": "No existe el usuario web 999" }
HTTPSignifica
400Falta o está mal un parámetro estructural (típicamente id_usuario)
401API key ausente o inválida
403Scope insuficiente, usuario inactivo, persona no habilitada, clave incorrecta
404El usuario o el recurso no existe
422El pedido se entiende pero los datos no sirven
502No se pudo llegar a la base de la LAN de Portas

14. Fases siguientes

FaseMódulo viejoQué trae
2 carpetas Hecho: foto de precinto, foto de inspección y remito del transportista (sección 11). Pendiente: fotos y audio genéricos, texto, liberar mercadería, generación y lectura de QR
3 cargas, transp_oficios Pendientes de carga y liberación; búsqueda y subida de remito del transportista
4 rendiciones, gastos_odp, comprobantes_odp Rendiciones, pedidos de fondos, aprobaciones, topes, eventos y tableros

Todas se montan sobre la misma base: id_usuario, las mismas personas y el mismo juego de filtros.