API Portas — Web (Escritorio)

Endpoints que consume escritorio.portascloud.ar, la aplicación para probar los endpoints que se van a utilizar en importas.com.ar

Base URL: https://api.portascloud.ar  |  v1  |  actualizado 2026-08-11 (filtrado por id_cliente y sesion opcional)

1. Qué es esta API

Estos endpoints sirven a la aplicación escritorio.portascloud.ar, que reemplaza gradualmente al escritorio Symfony 1.4 alojado en portas.com.ar/escritorio.

La aplicación no tiene credenciales de base de datos: todo el acceso a datos y archivos pasa por esta API. Eso es deliberado — permite mover la aplicación a otro servidor cambiando una sola URL, sin tocar código.

Ojo con los usuarios. Los usuarios de estos endpoints salen de freewaynet.web_clientes_usuarios (clientes finales de Portas que entran al escritorio). No son los mismos que los de /v1/usuarios, que salen de la tabla usuarios y son del backoffice. Son dos padrones distintos con IDs propios.

2. Autenticación: dos niveles

Hay dos credenciales distintas, con roles distintos:

CredencialVa enIdentifica aQuién la tiene
API key Authorization: Bearer <key> La aplicación El servidor de la app (en su .env). Nunca el navegador.
Token de sesión X-Sesion: <token> El usuario final Se emite en el login y vive en la sesión del servidor de la app.
La sesión dejó de ser obligatoria (2026-08-11). Hasta esa fecha los endpoints de datos exigían sesión y la API imponia el filtro por cliente. Ahora un integrador que maneja sus propios usuarios — y decide por su cuenta qué cliente puede ver cada uno — puede llamar solo con la API key y acotar con el parámetro id_cliente (ver la sección siguiente).

Consecuencia directa: la API key pasa a ser la credencial crítica. Quien la tenga puede leer datos de cualquier cliente. Tiene que vivir en el backend del integrador, nunca en el navegador. Si el token de sesión se manda pero está vencido, la respuesta sigue siendo 401: un problema de sesión no se disfraza de resultado vacío.

Scopes

ScopeHabilita
web:readLectura: GET /v1/web/sesion y futuros listados
web:writeEscritura: login, logout y futuras acciones del usuario

Se mantienen separados de datos:read / datos:write (que usa FreeWayNet) para que una filtración de la key del escritorio no habilite el alta de legajos.

Escritura sobre la LAN. La API no envía mails ni push (no tiene ninguna capacidad de envío). La única escritura de esta API sobre la base de Portas es POST /v1/web/notificaciones/{id}/visto, gobernada por WEB_LAN_ESCRITURA en el .env: estuvo en modo solo lectura (respondía 503 modo_solo_lectura) hasta que se habilitó en producción el 2026-09-03 para que importas marque leídas las notificaciones al mostrarlas.

Cabecera adicional recomendada

X-Cliente-Ip: 200.45.12.33

Como la app puede correr en el mismo servidor que la API, sin esta cabecera el log de accesos registraría siempre la IP del propio servidor. La app reenvía acá la IP real del visitante.

2b. Filtrar por cliente: id_cliente

Todos los endpoints /v1/web/* que devuelven datos aceptan el parámetro id_cliente. Acepta uno o varios separados por coma (?id_cliente=84,1167). Por compatibilidad también se acepta cliente, que es como se llamaba en los ocho reportes.

GET /v1/web/comprobantes?id_cliente=84 Authorization: Bearer <api-key>

Si no se manda, no hay error

La respuesta trae todos los clientes. Es deliberado: cuando el integrador tiene su propia tabla de usuarios y sus propias reglas de quién ve qué, el recorte es responsabilidad suya y la API no puede adivinarlo. No devolvemos 422 por un parámetro ausente.

El parámetro ACOTA, nunca amplía

Se combina con el alcance de la sesión mediante AND. Un usuario con sesión de cliente que mande el id_cliente de otro no ve nada — su propio alcance ya lo encierra:

Quién llamaSin id_clienteCon id_cliente=84
Sin sesión (solo API key)todos los clientessolo el 84
Sesión delegada por id_clientetodos los clientessolo el 84
Personal de Portastodos los clientessolo el 84
Usuario del cliente 84solo el 84solo el 84
Usuario del cliente 12solo el 12nada

Documentos y descargas

En /v1/web/legajos/{id_carpeta}/documentos el parámetro se usa para verificar que el legajo sea de ese cliente: si no lo es, 404. Sin id_cliente se sirve igual, con el mismo criterio que el resto. Conviene mandarlo: los id_carpeta son predecibles (anio.despachante.nro_orden) y es la única barrera que evita que uno adivinado devuelva documentación ajena.

Quedó registrado quién consulta qué. Cada llamada se guarda con el endpoint, el id_cliente efectivo y el nombre del usuario final. Ese nombre se toma del parámetro usuario de la request, o de la sesión si se abrió con /v1/web/sesion-delegada. Los dos son opcionales, pero mandarlos es lo que permite responder después "quién consultó los comprobantes de tal cliente".

3. Iniciar sesión

POST/v1/web/login  —  scope web:write

Cuerpo

CampoTipoObligatorioDescripción
usuariostring Normalmente el email del usuario
clavestring Contraseña

Ejemplo

curl -X POST https://api.portascloud.ar/v1/web/login \ -H "Authorization: Bearer <api-key>" \ -H "Content-Type: application/json" \ -H "X-Cliente-Ip: 200.45.12.33" \ -d '{"usuario":"persona@empresa.com","clave":"..."}'

Respuesta 200

{ "ok": true, "sesion": { "token": "9f3c...<64 hex>", "vence": "2026-07-28 05:35:16" }, "usuario": { "id_usuario_web": 783, "id_cliente": 1167, "persona": "cliente", "usuario": "persona@empresa.com", "nombre": "Nombre Apellido", "cargo": "COMEX", "avatar": "", "razon_social": "EMPRESA S.A.", "web_carpeta": "empresa", "es_proveedor": false, "es_transportista": false, "id_proveedor_cliente": 0, "id_transportista": 0, "interno": null, "notificaciones": { "impo": true, "expo": false } } }
El campo token se devuelve una sola vez: en la base se guarda únicamente su hash SHA-256. Si se pierde, hay que volver a hacer login.

Las cuatro personas

El escritorio no tiene un solo tipo de usuario. persona se decide con el mismo orden que executeIngresar del Symfony viejo, y determina tanto el menú como qué operaciones se ven:

personaCondiciónAlcance de los datos
clienteexiste la fila en clientes (no alcanza con que id_cliente tenga valor)carpetas.id_cliente
proveedortiene id_proveedor_cliente el viejo le fuerza id_cliente = 0, asi que las pantallas de cliente le salen vacías. Su pantalla propia es /clientes_oficios
transportistatiene id_transportista su menú solo tiene /transp_oficios
empleadoninguna de las anteriores — personal de Portas sin filtro: ve todas las operaciones, de todos los clientes

Cuando persona es empleado, el objeto interno trae { id_usuario, id_perfil, aprueba_proyectos }. La credencial la da el perfil interno 18 (Gerencia); el viejo además pisa el perfil del usuario 102 con el 18.

El alcance se aplica en un solo lugar (src/WebAlcance.php) y entra en la clave de caché. Repetir el WHERE id_cliente a mano en cada endpoint es lo que tarde o temprano deja uno sin filtro.

Respuesta 401

{ "ok": false, "error": "credenciales_invalidas", "message": "Usuario o contraseña incorrectos" }

Se responde lo mismo si el usuario no existe o si la clave es incorrecta — a propósito, para no revelar qué usuarios existen. Si el usuario existe pero está dado de baja: usuario_inactivo.

Duración

La sesión vence a las 12 horas. Cada uso válido actualiza fecha_ultimo_uso, pero no extiende el vencimiento.

3b. Sesión delegada — para trazabilidad

POST/v1/web/sesion-delegada  —  scope web:write

Para un sistema que tiene su propia tabla de usuarios y autentica por su cuenta — hoy, importas.com.ar. Registra quién va a consultar y devuelve un token igual al del login.

Es opcional, y su valor hoy es el seguimiento. Desde que la sesión dejó de ser obligatoria y el filtrado se hace con id_cliente por parámetro, este endpoint no hace falta para consultar datos: se puede llamar a todo solo con la API key. Lo que aporta es que cada llamada posterior quede atribuida a un usuario sin repetir el dato en cada request. La alternativa liviana es mandar usuario como parámetro en cada llamada.

El id_cliente de la sesión es informativo: queda en el log pero no filtra. Quien filtra es el parámetro de cada llamada. Así una misma sesión sirve para consultar varios clientes.

Modo A — por usuario de Portas

Si el sistema externo autentica contra los mismos usuarios de Portas (web_clientes_usuarios, la tabla de la LAN), lo único que hay que mandar es cuál es. Portas resuelve el resto contra su propia base: es el login sin el chequeo de la clave, porque esa ya la validó el sistema externo.

curl -X POST https://api.portascloud.ar/v1/web/sesion-delegada \ -H "Authorization: Bearer <api-key>" \ -H "Content-Type: application/json" \ -d '{"usuario":"jperez@empresa.com"}'

Devuelve lo mismo que el login — sesion + el objeto usuario completo — y la sesión es idéntica a la de un login propio: mismo id_usuario_web, misma persona, mismo alcance.

Cuándo conviene este modo. Cuando los usuarios del sistema externo son los de Portas. Ahí el alcance lo decide Portas contra su propia tabla y la sesión encierra de verdad: quien llama no puede elegir a qué cliente entrar ni convertir a un usuario de cliente en interno. Es el único modo en que la sesión sigue imponiendo un límite; en el modo B es puramente informativa.

Resuelve además el caso del personal de Portas, que no tiene un cliente asignado: sale persona: "empleado" con id_cliente: 0 y alcance a todas las operaciones, sin que el integrador tenga que pedirlo ni saber distinguirlo.

Errores del modo A

CódigoCuándo
404 usuario_inexistenteese usuario no existe en Portas
403 usuario_inactivoexiste pero está dado de baja

Modo B — por cliente (el que usa importas)

Para cuando los usuarios del sistema externo no existen en Portas. Se manda el nombre del usuario — tal cual lo tenga en su base, no hace falta que signifique nada acá — y opcionalmente un id_cliente de referencia.

Recordar que ese id_cliente queda para el log y no filtra: lo que decide qué datos vuelven es el parámetro id_cliente de cada llamada. Una misma sesión sirve para consultar cualquier cliente.

CampoTipoObligatorioDescripción
usuariostring Modo A. Si viene, se ignoran los demás campos
id_clienteintSÍ (modo B) Cliente de Portas al que va a ver esta sesión
personastringNo cliente (default) o empleado. Con empleado la sesión ve todos los clientes y id_cliente no se pide (si viene, se ignora)
usuario_externostringNo Identificador del usuario en el sistema que llama. Se guarda para auditoría: sin él, la sesión queda trazada solo a nivel aplicación
curl -X POST https://api.portascloud.ar/v1/web/sesion-delegada \ -H "Authorization: Bearer <api-key>" \ -H "Content-Type: application/json" \ -d '{"id_cliente":84,"usuario_externo":"jperez@empresa.com"}' # personal de Portas (ve todos los clientes): -d '{"persona":"empleado","usuario_externo":"jperez@empresa.com"}'

Respuesta 200

{ "ok": true, "sesion": { "token": "0a7d...<64 hex>", "vence": "2026-08-11 07:26:11" }, "persona": "cliente", "cliente": { "id_cliente": 84, "razon_social": "EMPRESA S.A." } }

De acá en adelante, los dos modos son iguales

El token es el mismo tipo que devuelve el login: se manda en X-Sesion y no cambia nada más. Dura 12 horas, se cierra con POST /v1/web/logout y GET /v1/web/sesion lo responde igual que a un login propio, con origen: "delegada" en el bloque sesion.

La API key pasa a ser la credencial crítica. Portas no pide la clave del usuario final: confía en la key. En el modo B, además, quien la tenga puede abrir sesión para cualquier cliente. Así que la key no puede salir del backend del integrador — nunca en el navegador ni en código del lado del cliente. Cada sesión queda registrada con qué aplicación la abrió y con qué usuario.

Errores comunes

CódigoCuándo
422 parametro_faltantemodo B sin id_cliente
422 parametro_invalidopersona distinta de cliente / empleado
404 cliente_inexistenteese id_cliente no existe en FreeWayNet. Se valida para que no se abran sesiones que después no devuelven nada y parecen un error de la API — no es un control de permisos
403 cliente_inactivoel cliente está dado de baja
403 missing_scopela key no tiene web:write

4. Consultar la sesión vigente

GET/v1/web/sesion  —  scope web:read

Revalida el token y devuelve el usuario actualizado. La app lo llama en cada pantalla para detectar sesiones vencidas y usuarios dados de baja mientras estaban adentro.

curl https://api.portascloud.ar/v1/web/sesion \ -H "Authorization: Bearer <api-key>" \ -H "X-Sesion: <token>"

Respuesta 200

{ "ok": true, "usuario": { "id_usuario_web": 783, "id_cliente": 1167, "persona": "cliente", "usuario": "persona@empresa.com", "nombre": "Nombre Apellido", "razon_social": "EMPRESA S.A.", ... resto igual que en el login ... }, "sesion": { "vence": "2026-07-28 05:35:16", "fecha_alta": "2026-07-27 17:35:16", "fecha_ultimo_uso": "2026-07-27 17:41:02" } }
¿Dónde saco el id_cliente? De acá, o del usuario que devuelve el login: son el mismo objeto. Es la única forma, y es a propósito — el token es opaco, no lleva ningún dato adentro.

Sirve para mostrar, no para pedir datos. No hay que mandarlo en las consultas: el servidor ya sabe de quién es la sesión y filtra por el id_cliente que tiene guardado contra el token, en todos los endpoints. Si igual se manda uno distinto, se ignora. Usarlo para el saludo, el menú, los títulos de pantalla; nunca como filtro de seguridad.

Respuesta 401

sesion_invalida (inexistente, vencida o cerrada) o usuario_inactivo. En el segundo caso la sesión se cierra sola con motivo forzada.

5. Cerrar sesión

POST/v1/web/logout  —  scope web:write

Es idempotente: si el token ya estaba cerrado o no existe, responde 200 igual. Cerrar sesión nunca debería fallarle al usuario.

{ "ok": true, "cerrada": true }

cerrada: false significa que no había ninguna sesión abierta con ese token.

6. Listado de operaciones

GET/v1/web/oficios  —  scope web:read (sesión opcional)

Reemplaza /oficios, /oficio_traer_pag y /historial del sistema viejo. El cliente sale de la sesión, nunca del request: un usuario no puede pedir los legajos de otro cliente.

Parámetros (todos opcionales, por query string)

ParámetroTipoDescripción
historial0 | 1 Sin él: operaciones en curso (oculta anuladas y cobradas). Con 1: historial completo
pageintPágina, base 1 (default 1)
limitintFilas por página (default 25, máximo 200)
legajostringNúmero de legajo (94961) o id completo (2026.04.94961)
referenciastringBúsqueda parcial
mercaderiastringBúsqueda parcial
tipoimpo | expoImportación o exportación
desde / hastafecha Rango de oficialización. Acepta AAAA-MM-DD o DD-MM-AAAA
Igual que el sistema viejo: los estados cerrados se ocultan solo si no hay filtros activos. Si el usuario busca algo puntual, lo encuentra aunque esté anulado o cobrado.

Respuesta 200

{ "ok": true, "items": [ { "id_carpeta": "2026.04.94961", "legajo": 94961, "referencia": "5186PT", "mercaderia": "NUT + KINDER + ROCHER", "tipo": "Importación", "via": "Maritima", "fecha_oficializacion": "2026-07-27", "nro_despacho_completo": "26001IC05015473Z", "factura_proveedor": "...", "responsable": "...", "tipo_mercaderia": "PRODUCTO TERMINADO", "unidad_negocio": "COMERCIAL", "estado": "Carga Liberada", "estado_pago": null, "canal": "verde", "documentos": { "total": 1, "hay": true, "completo": false } } ], "paginado": { "pagina": 1, "limite": 25, "total": 7809, "paginas": 313 }, "listado": "en_curso" }

Campos derivados

CampoCómo se calcula
estado Estado del legajo, pisado por la autorización de pago si existe: 0 = Pago Visto COMEX, 1 = Pago Contabilizado, 2 = Pago Autorizado
canalCanal aduanero: verde (1), naranja (2), rojo (3, 4, 6), o null
documentos.completo true si están los 3 documentos del paquete (despacho AFIP, factura Portas y gastos)
documentos.total Cantidad de documentos publicados (items 1, 9, 11, 12, 17, 18)

Rendimiento: por qué esta consulta va en dos pasos

Antes de "simplificar" esta consulta, leer esto. La columna estado de view_consulta_caratula_resumen no es una columna: es la función almacenada GetEstadoCliente(id_carpeta), que se ejecuta una vez por fila. Filtrar u ordenar por ella obliga a materializar la vista completa del cliente. Medido: 5,8 s.
PasoQué haceCosto
1Pagina sobre la tabla carpetas (indexada por cliente, fecha y anulada)0,13 s
2Trae el detalle solo de los ids de esa página: la función corre 25 veces, no 10.8910,07 s
El COUNT total se cachea 5 minutos (cambia de a un legajo por vez)0,00 s con hit

Equivalencia verificada: estado NOT IN ('Anulada','Anulada Aduana','Cobrada') devuelve 7797 legajos y anulada=0 AND anulada_aduana=0 devuelve 7809; la diferencia de 12 son exactamente los "Cobrada", que se descartan en el paso 2.

Además, el template viejo dispara 4 consultas por fila (resumen, dos conteos de documentos y autorización de pago): 80 round-trips para 20 filas. Acá son dos consultas fijas, independientemente del tamaño de la página.

7. Documentos y descargas

GET/v1/web/legajos/{id_carpeta}/documentos

GET/v1/web/legajos/{id_carpeta}/documentos/{familia}.{pdf|zip}

Ambos con scope web:read (sesión opcional). Reemplazan /oficios/descargas, bajar_pdf, bajar_factura y bajar_zip del sistema viejo.

Se apoyan en los entregables que la API ya genera (PDF consolidado o ZIP por familia): son exactamente los archivos que el cliente quiere bajar, ya unificados. No se listan los archivos sueltos del storage — el cliente nunca vio eso en el sistema viejo, y ahí hay documentación interna (los escaneos crudos Scan000NN.pdf).

Respuesta del listado

{ "ok": true, "id_carpeta": "2026.04.94961", "documentos": [ { "familia": "pdf_provisorio", "nombre": "Despacho provisorio", "formatos": [ { "tipo": "pdf", "nombre": "...", "tamanio_bytes": 205824, "documentos": 1, "generado_en": "...", "url": "/v1/web/legajos/2026.04.94961/documentos/pdf_provisorio.pdf" } ] } ], "historico_en_sistema_anterior": false }

Familias

FamiliaNombre presentableFormatosEquivale en el viejo a
f3101Despacho / F3101pdfDespacho AFIP (item 1)
pdf_provisorioDespacho provisoriopdfitem 17
factura_portasFactura Portaspdfitem 11
gastosGastos de tercerospdf, zipitem 9
bcraPresentación BCRApdfitem 18
djaiDJAI / SIMIpdfitem 12
cert_importacionCertificado de Importaciónpdf

Seguridad de las descargas

Diferencia importante con el sistema viejo. El escritorio Symfony arma el link de descarga con el path del archivo encriptado con una clave fija que está en el código fuente: quien conozca esa clave puede construir el link de cualquier archivo de cualquier cliente. Acá cada descarga valida que el legajo pertenezca al cliente de la sesión, y un legajo ajeno responde 404 (no 403: responder "prohibido" confirmaría que existe). Verificado: dueño 200 / ajeno 404 / sin sesión 401.

Cada descarga queda registrada en web_descargas (usuario, legajo, familia, IP, fecha). El sistema viejo no las registra en ningún lado.

Cobertura histórica

El campo historico_en_sistema_anterior vale true cuando el legajo no tiene entregables y es anterior a 2022, que es desde cuando el storage nuevo tiene contenido. No significa que el legajo no tenga documentos: significa que están en el sistema anterior.

AñoLegajos con documento (viejo)Con entregable (nuevo)Cobertura
20261.6401.662100%
20252.9812.980100%
20243.3613.34499,5%
20233.7523.73699,6%
20223.7723.75899,6%
2011–2021~38.00000% — falta backfill

8. Movimientos y notificaciones

Últimos movimientos

GET/v1/web/movimientos  —  scope web:read (sesión opcional)

Cronología de hitos y eventos de las operaciones del cliente. Reemplaza /ultimos_mov y /movimiento_traer_pag.

ParámetroDescripción
legajoNúmero de legajo
desde / hastaRango de fechas (AAAA-MM-DD o DD-MM-AAAA)
page / limitPaginado (default 25, máx 200)
{ "ok": true, "items": [ { "id_carpeta": "2026.04.94961", "legajo": 94961, "referencia": "5186PT", "factura": "...", "fecha": "2026-07-27 00:00:00", "id_hito": 3, "hito": "OFICIALIZACION", "evento": "", "observacion": "" } ], "paginado": { "pagina": 1, "limite": 25, "total": 16734, "paginas": 670 } }

El total se cachea 5 minutos, igual que en el listado de oficios: contar 16.734 movimientos cuesta 0,55 s y traer la página 0,29 s.

Notificaciones

GET/v1/web/notificaciones  —  scope web:read (sesión opcional)

POST/v1/web/notificaciones/{id}/visto  —  scope web:write (sesión opcional)

Es lo que el sistema viejo muestra en el sobre de la barra superior y en la pantalla /default/notificaciones: resumen.sin_ver es el número del badge. El viejo marca todo como visto al abrir la pantalla; acá eso lo hace el integrador con un POST .../visto por cada ítem que vino con "vista": false.

Las notificaciones son por usuario (notificaciones.id_usuario_web), no por cliente: dos personas de la misma empresa no ven lo mismo. De quién son (desde 2026-09-03): Es el mismo modelo de confianza que el id_cliente por parámetro del resto de la API: la barrera es la API key, y el integrador decide qué usuario suyo ve qué. Marcar una notificación de otro usuario devuelve 404 y la deja sin marcar.
ParámetroDescripción
id_usuario_webUsuario cuyas notificaciones se leen o marcan (ver arriba). En el POST se acepta por query string o en el cuerpo JSON
sin_ver1 para traer solo las no leídas
page / limitPaginado (default 20, máx 100)
GET /v1/web/notificaciones?id_usuario_web=727&sin_ver=1 POST /v1/web/notificaciones/2003624/visto?id_usuario_web=727
{ "ok": true, "items": [ { "id": 2003624, "tipo": "D", "titulo": "DIGITALIZACION PROVISORIA (93347)", "texto": "...", "id_carpeta": "2026.04.93347", "fecha": "2026-02-03 17:00:18", "vista": true, "fecha_vista": "..." } ], "resumen": { "total": 2, "sin_ver": 0 }, "paginado": { "pagina": 1, "limite": 20 } }

Se devuelve texto (plano) y no texto_html: el HTML viene de plantillas de mail del sistema viejo y no es seguro inyectarlo tal cual en la página.

POST .../visto es idempotente: marcar una ya leída responde 200.

9. Home (dashboard)

GET/v1/web/dashboard  —  scope web:read (sesión opcional)

Todo el home en una sola llamada: tarjetas de resumen, próximos arribos y últimos movimientos. Es deliberadamente un endpoint compuesto — el sistema viejo hace lo contrario y dispara media docena de llamadas AJAX para pintar la misma pantalla.

{ "ok": true, "resumen": { "operaciones_en_curso": 7809, "arribos_por_llegar": 56, "arribos_pendientes": 16, "notificaciones_sin_ver": 0 }, "arribos": { "items": [ { "id_carpeta": "...", "legajo": 94962, "referencia": "5185PT", "fecha": "2026-07-28", "dias": -1, "via": "Terrestre", "terminal": "...", "estado": "...", "ya_llego": false } ], "son_pendientes": false }, "movimientos": [ { "id_carpeta": "...", "legajo": 95069, "fecha": "...", "hito": "OFICIALIZACION", "evento": "" } ] }

son_pendientes: true significa que el cliente no tiene arribos futuros y se están mostrando los que ya llegaron y siguen abiertos — que es lo que quiere ver en ese caso.

Caché

Los cuatro conteos y las dos listas se cachean 5 minutos en web_totales_cache. Medición del home completo, punta a punta:

ClienteSin cachéCon caché
Ferrero (7.809 operaciones)0,63 s0,09 s
Coca Cola FEMSA (10.651)2,78 s0,08 s
Lección de la implementación: cachear solo el conteo más caro no servía de nada — el home no bajaba de 0,78 s hasta cachear también los otros tres números y las dos listas. La consulta de últimos movimientos (LIMIT 6) costaba 0,77 s en un cliente grande, más que todos los conteos juntos.

9b. Indicadores: Exportaciones / Importaciones

GET/v1/web/operaciones?tipo=expo|impo  —  scope web:read (sesión opcional)

Réplica de /dashboard_expo y /dashboard_impo: el estado actual de las operaciones activas, con las fechas del ciclo logístico y los semáforos.

Qué se considera "activa"

CondiciónDetalle
Clienteel de la sesión
DespachanteNOT IN (40, 97, 96)
Operación2 = exportación, 1 = importación
Cargasin finalizar, o finalizada hace menos de 30 días — esto es lo que hace que sea "estado actual" y no el historial completo
Estadosin anuladas ni cobradas

Semáforos

Cada fila trae transito y liberacion con esta forma:

{ "dias": 12, "estado": "ok", "umbral": 30, "porcentaje": 100 }
estadoSignificado
okdentro del plazo de referencia
alertahasta 3 días de exceso
excedidomás de 3 días de exceso
en_cursotodavía no terminó (cuenta desde el inicio hasta hoy)
sin_datosfalta la fecha de inicio

Umbrales de tránsito por vía (los del sistema viejo): aéreo 4 días, marítimo 30, terrestre y ferrocarril 10. Liberación: 7 días.

Corrección respecto del original: el sistema viejo pinta amarillo solo cuando el exceso es exactamente de 3 días (== 3), asi que un exceso de 1 o 2 días se veía rojo igual que uno de 40. Acá amarillo es "hasta 3 días de exceso" (<= 3), que es lo que la regla evidentemente quiso decir.

Rendimiento

El template viejo dispara seis consultas por fila (resumen, digitalizaciones, clientes, embarques, cargas y evento de ingreso a depósito). Acá son dos consultas fijas.

Además se fuerza IGNORE INDEX (oficializacion): sin eso MySQL resuelve el ORDER BY escaneando el índice de fecha hacia atrás hasta juntar la página, y con un filtro selectivo (25 exportaciones entre miles de carpetas) recorre medio índice — 4,0 s medidos, 2,0 s forzando el índice por cliente. La lista de ids y el total se cachean 5 minutos: la carga siguiente es de 0,06 s.

Verificado contra el sistema viejo: mismas operaciones, mismo orden y mismos valores (comparado fila por fila con la respuesta AJAX de dashboard_traer_pag).

9c. Cronograma de arribos

GET/v1/web/arribos?mes=AAAA-MM  —  scope web:read (sesión opcional)

Arribos del mes agrupados por día, listos para pintar un calendario. Réplica de /dashboard_calendario_proximos_arribos.

{ "ok": true, "mes": "2026-07", "desde": "2026-06-24", "hasta": "2026-08-07", "dias": { "2026-07-25": [ { "id_carpeta": "2026.04.94629", "legajo": 94629, "fecha": "2026-07-25", "estimada": false, "tipo": "Impo", "via": "Maritima", "vapor": "MAERSK ...", "canal": "Verde", "id_canal": 1, "terminal": "TERMINAL 1, 2 Y 3", "bl": "MAEU2601...", "referencia": "5148PT", "mercaderia": "...", "hora": "10:14", "etapa": "finalizada", "abierta": false, "detalle": { "tipo_operacion": "DESPACHO", "unidad_negocio": "...", "tipo_mercaderia": "...", "despacho": "26001IC04132920M", "fecha_oficializacion": "2026-07-13", "fecha_salida": "2026-07-05", "fecha_llegada": "2026-07-25", "fecha_esperada": null, "contenedores": "MSKU1234567", "contenedores_cant": 1, "kilos": 2.88, "bultos": 1, "control_documentacion": "2026-07-22 09:00:00", "verificacion": null, "coordinacion_carga": "2026-07-23 08:00:00", "aviso_carga": null, "inicio_carga": "2026-07-23 08:00:00", "finalizacion_carga": "2026-07-23 10:14:00", "responsable_documental": "...", "responsable_carga": "..." } } ] }, "total": 94, "resumen": { "abiertas": 46, "estimadas": 0, "demoradas": 3 }, "filtros_aplicados": { "solo_importacion": true, "solo_despachante": "04" } }
CampoSignificado
fechaCOALESCE(fecha_llegada, fecha_esperada_buque)
estimadatrue si no hay llegada real y se usó la esperada del buque
abiertacarga sin finalizar
horahora del hito de carga más avanzado que ya tenga fecha: finalización → inicio → coordinación. Misma prioridad que el sistema viejo
etapafinalizada | demorada | programada | sin_coordinar. Ver abajo
detalletodo lo que el viejo mostraba sólo al hacer clic (popover)
EtapaCondición
finalizadahay fecha_finalizacion_carga
demoradasin finalizar y la fecha prevista de carga (inicio → coordinación → aviso) ya pasó. Es la «sirena» del sistema viejo
programadasin finalizar, con fecha prevista futura
sin_coordinarsin ninguna fecha de carga cargada
El detalle no usa view_consulta_caratula_resumen. El viejo arma su popover con esa vista, que ejecuta GetEstadoCliente() por fila. Acá los mismos campos se leen de las tablas base (carpetas, carpetas_embarques, carpetas_cargas, carpetas_clientes) sin ese costo.
Restricciones heredadas del sistema viejo. El calendario original muestra solo importaciones (operacion = 1) y solo del despachante 04, ambas fijas en el código. Se conservaron para no cambiar lo que el usuario ve, pero quedan explícitas en filtros_aplicados por si se decide ampliarlas.

El rango incluye una semana antes y después del mes, porque la grilla del calendario muestra semanas completas. El resultado se cachea 5 minutos; la clave lleva un v con la versión del payload, que hay que subir cada vez que se le agregan campos.

El «0» del sistema viejo no era un número. Cada tarjeta del calendario original termina con (<b>responsable</b>), donde responsable es carpetas_cargas.id_empleado_carga2. Cuando no hay segundo responsable asignado —lo habitual— la línea queda en (), que al tamaño de la tarjeta se lee como un cero. No es una cantidad ni un cálculo: son paréntesis vacíos.

Rendimiento: el viejo tarda 1,92 s en cargar la página y después pide los datos por AJAX aparte; acá la pantalla completa sale en 0,07 s.

9d. Tiempo de liberación (importación)

GET/v1/web/liberacion?desde=&hasta=&por=llegada|oficializacion  —  scope web:read (sesión opcional)

Cuántos días pasan entre que llega la mercadería y que se libera de aduana, contra el tope esperado. Réplica de /dashboard_impo_detalle.

ConceptoCómo se calcula
Iniciofecha_llegada, o fecha_esperada_buque si no hay real
Finfecha_finalizacion_carga, o fecha_inicio_carga, o HOY (en curso)
Tope10 días hábiles si la carga es suelta, 7 en contenedor
Días hábilesdel intervalo, descontando lo que figure en freewaynet.feriados

Devuelve también un resumen con promedios y cumplimiento:

"resumen": { "operaciones": 100, "finalizadas": 84, "en_curso": 16, "promedio_corridos": 7.0, "promedio_habiles": 4.5, "dentro_del_tope": 75, "porcentaje_cumplimiento": 89 }

Tres correcciones respecto del original

1. El default de fechas. Si no se le pasa filtro, el sistema viejo usa un rango fijo escrito en el código: 01/11/2021 al 07/11/2021. Quien entra sin filtrar ve una semana de noviembre de 2021. Acá el default son los últimos 90 días.
2. Los días hábiles nunca descontaban feriados. El viejo los tiene hardcodeados (case '2020-01-01': …, solo de 2020) y además compara esos textos contra un timestamp, asi que la condición nunca da verdadera: no se descuenta ningún feriado, ni los de 2020. Acá se usa la tabla freewaynet.feriados, que ya existía, está mantenida hasta 2026 e incluye fines de semana y feriados.
3. Se informan los dos números (corridos y hábiles) junto al tope aplicado, en lugar de un color sin contexto.

9e. Cronograma operativo

GET/v1/web/cronograma?mes=AAAA-MM  —  scope web:read (sesión opcional)

Los hitos de trabajo del mes agrupados por día. Réplica de /dashboard_cronograma (que se alimenta de calendario_actualizar_dashboard).

No confundir con /v1/web/arribos. Aquel ubica la carpeta por la fecha en que llega la mercadería; éste la ubica por las fechas de trabajo, y por eso una misma carpeta puede aparecer hasta tres veces en el mes. Además, el de arribos muestra sólo importaciones del despachante 04; éste no filtra: trae impo y expo, todos los despachantes.
HitoetiquetaFecha en la que se ubica
control_documentacionC.Doc.fecha_control_documentacion
verificacionVerif.fecha_verificacion
cargaCargaCOALESCE(finalizacion, inicio, coordinacion)

Son exactamente los tres list_data.push() del template viejo. El COALESCE original tiene siete fechas, pero el viejo sólo dibuja la tarjeta de carga cuando hay finalización, inicio o coordinación (su variable $tiene_carga), asi que las otras cuatro nunca llegan a usarse: las tres de acá son equivalentes.

{ "ok": true, "mes": "2026-07", "dias": { "2026-07-23": [ { "id_carpeta": "2026.04.94424", "legajo": 94424, "hito": "carga", "etiqueta": "Carga", "fecha": "2026-07-23 10:14:00", "dia": "2026-07-23", "hora": "10:14", "etapa": "finalizada", "tipo": "Impo", "via": "Maritima", "canal": "Verde", "terminal": "TERMINAL 1, 2 Y 3", "referencia": "E-3024", "mercaderia": "...", "despacho": "26001IC04...", "peligrosa": false, "detalle": { "...": "igual que en /v1/web/arribos, más destinacion e ingreso_deposito" } } ] }, "total": 12, "total_rango": 13, "resumen": { "carpetas": 11, "carga": 11, "verificacion": 0, "control_documentacion": 1, "demoradas": 0 }, "filtros_aplicados": { "solo_importacion": false, "solo_despachante": null } }
CampoSignificado
etapaen el hito carga: finalizada / demorada / programada / sin_coordinar. En los otros dos hitos siempre cumplido: el registro de la fecha ya es el hecho
peligrosala carpeta tiene el evento 207 — CARGA PELIGROSA (IMO)
totalhitos del mes pedido (lo que muestra el encabezado)
total_rangoincluye la semana extra a cada lado que la grilla usa para completar las semanas

Diferencia de fondo: datos vivos

El viejo lee del espejo, no de la base. Esta pantalla consulta carpetas_cache, carpetas_cargas_cache, usuarios_cache, clientes_cache y view_consulta_caratula_resumen_cache: el espejo que se refresca por cron y que existe sólo para tapar la latencia contra Canadá. Acá se lee la base viva. Además, cuando una carpeta no está en el espejo, el viejo cae a la vista real fila por fila — una consulta a Argentina por cada una, que es de dónde sale buena parte de sus 2,2 s.
Fragilidad del original (no replicada). En el template viejo los tres list_data.push() están dentro de if(isset($cliente)), donde $cliente sale de clientes_cache. Si un cliente todavía no está en el espejo, su calendario sale vacío sin ningún mensaje. Un par de líneas más arriba, $terminal = $resumen->getTerminal() queda fuera de su if(isset($resumen)): una carpeta sin fila en la vista resumen tumba la pantalla entera.
ViejoNuevo
Ver el calendario2,18 s la página + 0,71 s el AJAX = 2,89 s 0,086 s
Origenespejo portas_cache (+ fallback fila por fila) base viva

Verificado: con ANDINA EMPAQUES en julio 2026, los dos sistemas devuelven los mismos 13 eventos — mismos legajos, mismos hitos, mismas horas.

Personal de Portas

El cronograma es de las pocas pantallas que el viejo muestra en las dos ramas del menú: la del cliente y la del empleado interno. Para el empleado no hay filtro de cliente, y cada tarjeta agrega la razón social (el campo cliente, que a un cliente se le manda vacío porque sería siempre el mismo).

Dos diferencias deliberadas con el viejo en la vista interna. (1) El viejo trae having fecha >= HOY - 10 dias: en julio 2026 eso son 86 cargas desde el 18, y navegar a un mes anterior no muestra nada. Acá se trae el mes completo (212 operaciones). (2) El viejo dibuja sólo el hito Carga para el interno — los push de Verif. y C.Doc. están detrás de hasCredential('Cliente'). Acá las tres personas ven los tres hitos (346 en total). Verificado: filtrando por carga y desde el 18, el nuevo da exactamente las mismas 86.

La página del interno son ~1,3 MB de HTML (386 tarjetas con su panel de detalle). Con mod_deflate viajan 57 KB y carga en 0,13 s.

9f. Comprobantes adeudados

GET/v1/web/comprobantes?page=&limit=&estado=todos|vencidos|a_vencer  —  scope web:read (sesión opcional)

Réplica de /comp_adeudados, que el sistema viejo pagina de a 10 por AJAX.

La tabla no se llama como el modelo. Los datos salen de la vista freewaynet.view_cli_comprobantes_adeudados. El modelo Symfony se llama CliComprobantesAdeudados y esa tabla no existe: el nombre real está en BaseCliComprobantesAdeudadosPeer::TABLE_NAME. Vale como regla general para todo el Symfony viejo.
Los importes son saldos, no totales de comprobante (desde 2026-08-10). total trae lo que queda por cobrar, ya descontadas las imputaciones; el valor original del comprobante viaja aparte en total_comprobante. Hasta esa fecha la vista devolvía el total del comprobante y los pagos parciales no se descontaban: la deuda salía 39 % más alta ($3.782 M contra $2.721 M reales, 548 de 2.509 comprobantes inflados). Se agregó la columna saldo a la vista, calculada igual que FreeWayNet (total_comprobante − ABS(SUM(importe)) de cli_comprobantes_imputaciones donde el comprobante figura como id_comprobante_afectado, excluyendo imputaciones de comprobantes anulados).

Consecuencia al comparar: contra el sistema viejo de portas.com.ar los montos no van a coincidir cuando hay cobros parciales — el viejo muestra el total. La referencia correcta es la consulta de cuentas corrientes de FreeWayNet.
Campo del itemSignificado
total saldo pendiente del comprobante (N/C en negativo). Es el que cuadra con FreeWayNet y el que suma resumen.monto
total_comprobante total original del comprobante, sin descontar imputaciones. Sirve para mostrar «facturado vs adeudado»; es lo que informaba el sistema viejo
vencido / dias_vencido calculados contra CURDATE(); el viejo los deja a ojo del lector
clientesolo se manda al personal de Portas
"resumen": { "comprobantes": 113, "monto": 124380345.99, "vencidos": 27, "monto_vencido": 23531589.27, "mas_antiguo": "2026-07-22" }

El resumen es del conjunto completo, no de la página: el viejo lista los comprobantes pero no dice cuánto suman ni cuáles vencieron. monto y monto_vencido suman saldos.

Verificado: Escandinavia, 22 comprobantes en los dos sistemas; los saldos contra FreeWayNet, 2.513 comprobantes sin una sola diferencia (2026-08-10). Rendimiento: viejo ~2 s la página + 7,7 s el AJAX; nuevo 0,99 s en frío y 0,08 s cacheado (se cachean por separado los totales y cada página).

9g. Tracking marítimo

GET/v1/web/tracking?estado=en_viaje|todos  —  scope web:read (sesión opcional)

Contenedores del cliente en seguimiento. Réplica de /tracking_maritimo. Sale de eta_contenedores + eta_contenedores_carpetas, la misma fuente que /v1/legajos/{id}/eta, y comparte con ese endpoint el parseo del json_completo (EtaGet::armarParaWeb()): posición, locaciones, eventos y trayectoria salen idénticos, sin duplicar la lógica.

Hoy no hay coordenadas. La posición GPS y la ruta las alimenta Searates vía el proceso Actualizar_Contenedores_ETA de FreeWayNet, y esa API key está vencida desde 2025-12. Las 52 filas de la tabla tienen json_completo vacío y ultima_pos_latitud/longitud en NULL. Lo que está actualizado es el eta (del 16-07 al 07-09 de 2026).

El endpoint está escrito para los dos escenarios: hoy devuelve la lista con ETA y posicion: null, y el día que vuelva la key empieza a devolver posición y trayectoria sin tocar una línea. tracking_disponible le dice a la pantalla si puede dibujar el mapa o si tiene que explicar por qué no hay.
CampoSignificado
legajos[]un contenedor puede venir en más de una carpeta; se agrupa por contenedor y se acumulan los legajos, igual que el viejo, que indexa su lista por número de contenedor
posicion{lat, lng} o null
trayectoriapares [lat, lng] del viaje completo
tracking_disponibletrue si al menos un contenedor tiene posición

Verificado: Ferrero, 52 contenedores — los 52 de la tabla, sin repetir (el JOIN por carpeta devolvía 64 filas).

9h. Reportes (Excel)

GET/v1/web/reportes/operaciones?formato=json|xlsx&…  —  scope web:read (sesión opcional)

Primer reporte del submenú Reportes. Una fila por ítem de despacho, con los datos declarados en María. Réplica de default/reportesOperaciones + generar_reporte_operaciones.

ParámetroValores
clienteuno o varios id_cliente por comas (o cliente[]= repetido). Vale para los ocho reportes. Se suma al alcance de la sesión, no lo reemplaza
tipoimpo | expo
destinacioncódigo exacto (IC04, EC01…)
of_desde / of_hastafecha de oficialización, AAAA-MM-DD
carga_desde / carga_hastafinalización de carga, AAAA-MM-DD
vialista por comas: Aerea, Maritima, Terrestre, Ferrocarril
canallista por comas: verde, naranja, rojo (rojo agrupa 3, 4 y 6)
incluir_anuladas1 para traerlas; por defecto se excluyen
formatojson (vista previa, 200 filas) o xlsx (hasta 20.000)

El Excel del sistema viejo no es un Excel

Lo que baja el viejo es un .xls que en realidad es MHTML: una página web con cabecera multipart/related y un armazón de «Excel single file web page» pegado en el template. Excel lo abre avisando que el formato no coincide con la extensión, y otras herramientas lo leen mal o no lo leen. Acá se genera OOXML de verdad con src/Xlsx.php: un ZIP con las piezas mínimas, sin dependencias, con encabezado en negrita y panel congelado. Validado con openpyxl sin advertencias.

Dos diferencias con el viejo

1. Las anuladas. El reporte viejo incluye las operaciones anuladas sin distinguirlas: entran en los totales sin avisar. Acá se excluyen por defecto, hay filtro para traerlas y una columna Anulada que las marca.

2. Datos vivos vs espejo. El viejo lee maria_caratula_cache y compañía. Comparando el mismo rango (Ferrero, julio 2026): con incluir_anuladas=1 el nuevo devuelve 77 despachos y el viejo 74, y no falta ninguno — los 3 de diferencia son los que el espejo todavía no tiene. Dos se oficializaron ese mismo día, pero uno es del 1 de julio: el espejo lo seguía sin tener 27 días después.

La pantalla, además, muestra el resultado antes de bajarlo: la del viejo es solo un formulario con un botón «Bajar Excel», sin forma de ver si el filtro quedó bien sin abrir el archivo.

Los otros reportes

EndpointPantalla viejaFuenteColumnas
/v1/web/reportes/status?tipo=impo|expo Operaciones Status Impo / Expo view_operaciones_status56
/v1/web/reportes/liquidaciones Liquidaciones view_consulta_liquidaciones27
/v1/web/reportes/facturacion Facturacion x Items cli_comprobantes + cli_comprobantes_carpetas14
/v1/web/reportes/bcra BCRA comunicacion A7466 view_items_categoria_bcra44
/v1/web/reportes/desvios Reporte Desvios Maritimos (Ferrero) view_cargas_maritimas_costos
/v1/web/reportes/comercial Reporte Comercial (Ferrero) view_consulta_items_comercial
/v1/web/reportes/items Items de Exp/Impo (Ferrero) view_consulta_items_expo43

«Liquidaciones x Area» (Ferrero) es el mismo endpoint de liquidaciones con area=1: agrega la columna Doc Transporte, única diferencia entre los dos reportes del viejo. El reporte de Desvíos del viejo no filtra por cliente (Where true); el nuevo filtra siempre por la sesión.

En el viejo Status Impo y Status Expo son dos pantallas con dos acciones; acá es un endpoint con un parámetro, porque la consulta es la misma.

El filtro por cliente

GET/v1/web/clientes  —  scope web:read + sesión

Es uno de los pocos que sigue exigiendo sesión, porque decide qué devolver según quién pregunta. Un integrador la obtiene con /v1/web/sesion-delegada y recibe la lista completa, que es lo que necesita para poblar su propio selector de clientes.

Devuelve id_cliente y razon_social para poblar el selector Cliente de los reportes, que acepta varios a la vez. Es el equivalente del cambiar_cliente del escritorio viejo, que la migración no había trasladado: hasta ahora el personal de Portas veía todas las operaciones sin forma de acotar el reporte a un cliente.

Solo el personal de Portas recibe la lista completa. A un usuario externo se le devuelve únicamente su propio cliente — la nómina de clientes de Portas no es información que le corresponda, y para él el filtro no tiene nada que elegir.
El filtro se suma al alcance, no lo reemplaza. El viejo concatena los ids crudos al SQL, sin placeholders y sin contrastarlos contra el alcance del usuario. Acá los ids que no son enteros se descartan, van como parámetros, y la condición de alcance sigue en el WHERE: un usuario cliente que mande el id de otro sigue sin ver nada.
La vista de Status no se puede filtrar desde afuera. view_operaciones_status termina en GROUP BY c.id_carpeta, y una vista con GROUP BY no se fusiona con la consulta que la usa: MySQL la materializa entera (95.000 filas, 27 LEFT JOIN y una subconsulta correlacionada) y recién después aplica el WHERE. Medido: 37 s filtrando por cliente y mes, y 43 s incluso pasándole la lista exacta de 59 id_carpeta — el truco de dos pasos que sirve en oficios acá no hace nada.

La solución es inyectar el filtro adentro, antes del GROUP BY: los mismos datos salen en 0,15 s. El cuerpo de la vista se lee en caliente con SHOW CREATE VIEW (en vez de copiar 6,6 KB de SQL que quedaría desactualizado) y si la vista cambiara de forma se cae a consultarla normalmente.

El sistema viejo convive con el problema: su template pone max_execution_time = 18000. Medido de punta a punta: 42,4 s contra 0,29 s.
«Illegal mix of collations». Filtrar por tipo en la vista de Status fallaba: no es una columna sino una expresión (IF(operacion = 1,'Impo','Expo')), asi que su collation es COERCIBLE igual que la del parámetro — ninguna gana y MySQL corta. Por el camino rápido el filtro pasa a ser operacion = 1|2 (un entero) y el problema desaparece; en el camino de respaldo se resuelve con un COLLATE explícito.

Verificado contra el sistema viejo

ReporteViejoNuevoResultado
Operaciones74 despachos77 ninguno falta; los 3 extra no están en el espejo
Status Impo59 legajos / 42,4 s59 / 0,29 s idéntico
Liquidaciones895 filas / 73 despachos928 / 75 ninguno falta; los 2 extra son los mismos que faltan en el espejo
Facturacion x Items94 filas / 47 legajos94 / 47 idéntico
BCRA A7466126 filas (Ferrero, jun 2026)126 idéntico: 5.292 celdas comparadas una a una, 0 diferencias

BCRA comunicación A7466

Misma enfermedad que Status, otra causa. view_items_categoria_bcra no tiene GROUP BY, pero lleva una subconsulta correlacionada en el SELECT (nro_lna) y eso también impide fusionarla: filtrarla desde afuera materializa las ~670.000 filas (16 LEFT JOIN más un join por LEFT(POSICION_ARANCELARIA,14) sin índice). Medido: 40 s hasta pasándole la lista exacta de id_carpeta. Se aplica el mismo remedio — el cuerpo se lee con SHOW CREATE VIEW y las condiciones se agregan a su WHERE (acá van al final: no hay GROUP BY) —: un mes de Ferrero pasa de 40 s a 0,72 s; su historial completo (9.546 filas), 4,2 s.
Correcciones sobre el Excel viejo. (1) El viejo hacía dos consultas SQL por fila (fecha factura proveedor y fecha conocimiento de embarque); ahora son subconsultas del SELECT, una sola pasada. (2) Tenía las columnas geográficas corridas: bajo «Origen» iba la procedencia, bajo «Procedencia/Destino» el país destino y bajo «Pais Destino»… la razón social del destinatario. Cada dato va ahora bajo su rótulo y el destinatario tiene columna propia. (3) Anuladas excluidas por defecto, con filtro y columna, como en Operaciones. Al alcance global del interno se le impone of_desde (último año y medio) para no materializar el universo entero. Solo importaciones: la vista fija operacion = 1.

9i. Otras pantallas del cliente

EndpointPantallaParámetrosNotas
GET /v1/web/rutasRutas rutas terrestres del cliente y sus hitos
GET /v1/web/temporalesOperaciones Temporales (Ferrero y Andina) estado, temporal, con_saldo base portasco_moa_tagui (mismo servidor MySQL, otro esquema). El proceso que la alimenta está congelado desde marzo 2025: la pantalla lo avisa con ultima_actualizacion; el viejo lo oculta.
GET /v1/web/comercialOperaciones de Comercial (Ferrero) tipo, legajo, referencia, proveedor, producto, codigo, page, limit view_consulta_items_comercial, paginado por carpeta (12,3 s → 1,1 s)

9j. Menú interno (personal de Portas)

Pantallas de la persona empleado: sin filtro por cliente (WebAlcance global). Un cliente que pruebe estas URLs ve sus propios datos o nada, según la pantalla.

EndpointPantallaParámetrosNotas
GET /v1/web/arribos-internosProximos Arribos / Sin Fecha Arribos vista=por_llegar|sin_fecha, dias la del viejo estaba muerta desde 2022 (rango 2020–2022 fijo en el código)
GET /v1/web/checklistCheck List Impo / Expo tipo=impo|expo, legajo, responsable, dias panel operativo completo; marca FC/BL/PL faltantes (el viejo listaba sin decir cuáles faltan y filtraba fijo 2022). SIMI/SIRA no se replicaron: sin vigencia.
GET /v1/web/oficializacionesOficializaciones x Cliente / Diarias / Mensuales vista=cliente|diaria|mensual, desde, hasta agrega directo contra carpetas (las vistas del viejo materializan por su GROUP BY interno); «x Cliente» excluye IDA4, las otras dos no — igual que el viejo, y ahora lo dice en pantalla
GET /v1/web/rendicionesRendiciones desde, hasta, evento, solo_excedidas corte por evento agregado: hoy el 62% excede su tope (topes desactualizados). Bug del viejo corregido: comparaba todo contra el tope de exportación.
GET /v1/web/vacacionesCronograma Vacaciones mes grilla persona × día (el viejo traía las 1.227 solicitudes por un HAVING mal puesto)
GET /v1/web/carpetasOperaciones en Curso (interno) tipo, via, page, limit NO es /v1/web/oficios: no filtra por cliente y agrega filtro POR cliente

9k. Personas externas: proveedor y transportista

GET/v1/web/oficios-externos  —  scope web:read + sesión

El listado de operaciones de las dos personas externas. El criterio lo decide la persona de la sesión, nunca el request; un cliente recibe 403:

PersonaPantalla viejaCriterio
proveedor/clientes_oficios («Operaciones del Clientes») carpetas_clientes.id_proveedor_cliente de la sesión
transportista/transp_oficios (su única pantalla) carpetas con remitos: cli_comprobantes tipo N/E + cli_comprobantes_remitos.id_transportista

Mismos filtros y misma fila que /v1/web/oficios (comparte WebOficios::detalle()). Fiel al viejo: no oculta anuladas y excluye despachante 40. Sin descargas de documentos: WebAlcance no les da acceso, igual que el sistema anterior. La escritura del viejo (fotos de remitos, alta de viajes) no se migra hasta definir la política de escrituras.

Verificado: Scania (proveedor 5906) 119 = 119 operaciones; transportista 240: 1.876 = 1.876, con carpetas del mismo día.

10. Errores

HTTPerrorCuándo
401missing_authFalta el header Authorization
401invalid_keyAPI key inexistente
401credenciales_invalidasUsuario o clave incorrectos
401usuario_inactivoEl usuario existe pero está dado de baja
401sesion_invalidaToken inexistente, vencido o cerrado
403inactive_keyAPI key dada de baja
403missing_scopeLa key no tiene el scope necesario
422parametro_faltanteFalta usuario o clave
502db_unreachableNo responde la MySQL de FreeWayNet (red Portas)

11. Notas de seguridad

AspectoSistema viejo (Symfony)Esta API
Token "recordarme"md5(usuario . password) — derivable de las credenciales 32 bytes aleatorios, sin relación con la clave
Guardado del tokenEn claroSolo el hash SHA-256
Cierre remotoNo existeSí: cerrada = 1 en web_sesiones
Log de ingresos fallidosNo se registranSí, en web_ingresos
Comparación de claveIgualdad simplehash_equals (tiempo constante)
Pendiente conocido: las contraseñas siguen guardadas en texto plano en web_clientes_usuarios.clave, porque el sistema Symfony viejo las compara así y sigue en producción. Mientras convivan los dos sistemas no se puede cambiar el formato. El paso a bcrypt/argon2 está previsto para cuando se apague el viejo. La clave nunca viaja en una respuesta de esta API.

12. Endpoints previstos

Se agregan por fase, en orden de uso real medido sobre los accesos del sistema viejo. Esta página se actualiza con cada fase.

FaseEndpointReemplaza aEstado
1POST /v1/web/login/ingresar✔ disponible
1GET /v1/web/sesionsesión de Symfony✔ disponible
1POST /v1/web/logout/salir✔ disponible
2GET /v1/web/oficios/oficios + oficio_traer_pag✔ disponible
2GET /v1/web/oficios?historial=1/historial✔ disponible
3GET /v1/web/legajos/{id}/documentoslistado de PDF✔ disponible
3GET /v1/web/legajos/{id}/documentos/{familia}.{tipo}bajar_pdf, bajar_factura, bajar_zip✔ disponible
4GET /v1/web/notificaciones + POST .../visto/default/notificaciones✔ disponible
4GET /v1/web/movimientos/ultimos_mov✔ disponible
5GET /v1/web/dashboardhome del sistema viejo✔ disponible
6POST /v1/web/no-migrada— (registra pantallas pedidas y ausentes)✔ disponible
7indicadores, cronogramas, comprobantes, tracking, reportes (9b–9h)menú del cliente✔ disponible
8menú interno completo (9j)pantallas del empleado✔ disponible
9GET /v1/web/oficios-externos (9k)/clientes_oficios + /transp_oficios✔ disponible
Mi Perfil/perfilpospuesto: define la política de escrituras
Tracking Terrestre (Ferrero)/tracking_terrestrebloqueado: requiere Remote MySQL en WnPower
Cash Flow / Lista Cash Flow/cashflow + /lista_cashflowno se migra: sin uso desde 2024-04, módulo de escritura
Tablero Materia Prima (Ferrero)/dashboard_kpi_diariono se migra: el del viejo es una maqueta con datos fijos de 2021
Proyectos / Trámites/proyectos, /tramitesno se migra: base local de WnPower, 5 filas de prueba, cero accesos