# API del portal de clientes · versión 1

Base en producción: `https://clientes.hormimax.cl/api/v1`

Todas las respuestas son JSON en UTF-8. Las fechas van como `AAAA-MM-DD HH:MM:SS`, hora de Chile.

## Cómo identificarse

| Forma | Para quién | Cómo |
|---|---|---|
| Cookie `pc_sesion` | El portal web | La pone `POST /sesion`. HttpOnly, SameSite=Strict, solo HTTPS en producción. |
| `Authorization: Bearer <token>` | Sistemas del cliente | El token viene en la respuesta de `POST /sesion`. |

Toda operación que no sea `GET` debe llevar la cabecera `X-Portal: 1`.

La sesión dura como máximo 12 horas y se cierra sola tras 2 horas sin uso (configurable).

## Identificadores

Todo `id` que entrega o recibe esta API es un **código público de 22 caracteres** (letras, números, `-` y `_`),
por ejemplo `k3Jx9Qm2VbT7cYh4LpW0aA`. Vale para documentos, obras, clientes y también para los filtros
`?obra=` y `?oc=`.

- **No es el número de SGO** ni se puede deducir de él. Dos documentos seguidos tienen códigos sin relación,
  así que el código no dice cuántos documentos hay ni se puede recorrer.
- **Es estable:** el mismo documento tiene siempre el mismo código. Los enlaces guardados siguen sirviendo.
- **Se trata como un texto cerrado:** se guarda y se devuelve tal cual, distinguiendo mayúsculas de minúsculas.
- Un código inventado, alterado o de otra clase de documento responde `404 no_existe`. Un número de SGO también.
- En un filtro (`?obra=`), un código que no sirve no encuentra nada; no se comporta como "sin filtro".

La administración (`/admin`, solo SGO) usa los números internos.

## Errores

```json
{ "error": "sin_sesion", "mensaje": "Tu sesión terminó. Vuelve a ingresar." }
```

### Cuando la dirección se abre en el navegador

Los documentos (la guía, la factura, la orden de compra) se abren en una pestaña. Si quien pide es un
navegador abriendo una dirección (cabecera `Accept` con `text/html` y sin `application/json`) y la
respuesta es un error, la API entrega una **página** con la imagen del portal en vez del JSON:

| Resultado | Página | Código que recibe el navegador |
|---|---|---|
| Sin sesión | "Ingresa al portal", con botón para ingresar | 401 |
| En pausa o demasiadas consultas | "Demasiadas consultas" | 429 |
| Falla del servidor | "No pudimos completar la solicitud" | 500, 502 |
| **Todo lo demás** | "No encontramos lo que buscas" | **404** |

"Todo lo demás" es a propósito: no existe, no es del usuario, no tiene esa sección o la dirección está
mal escrita se ven exactamente igual y con el mismo código. La página no explica el motivo; el motivo
real queda en el registro de accesos. El portal y los sistemas que piden JSON lo siguen recibiendo igual.

| Código | Significa |
|---|---|
| 400 | Solicitud mal armada (falta `X-Portal`, JSON inválido) |
| 401 | Sin sesión, o correo/clave incorrectos |
| 403 | El usuario o su cliente no están habilitados, o no tiene acceso al tema |
| 404 | El recurso no existe **o no pertenece al usuario** (no se distingue, a propósito) |
| 409 | `elige_cliente`: la persona tiene varios clientes y todavía no elige con cuál trabajar |
| 410 | El enlace de invitación ya no sirve |
| 422 | Datos que no pasan la validación (el `mensaje` explica cuál) |
| 423 | Cuenta bloqueada: por claves equivocadas (5 intentos, 15 minutos) o por probar códigos (ver abajo) |
| 429 | Demasiadas consultas o intentos. Con `error: "rastreo"`, el acceso está en pausa (cabecera `Retry-After` con los segundos que faltan) |

### Códigos que no existen: freno al rastreo

Aunque los códigos no se pueden adivinar (ver *Identificadores*), alguien podría probar códigos al azar o
números de SGO uno por uno, así que:

1. **No se averigua nada probando.** Un código que no existe y uno que es de otro cliente (o de una obra
   que el usuario no tiene) responden exactamente lo mismo: `404 no_existe`, con el mismo texto.
   Sin sesión la respuesta es siempre `401`.
2. **Se corta el intento.** Cada `404 no_existe` se cuenta. Con 10 en 15 minutos el usuario queda en
   pausa: todo responde `429 rastreo` hasta que termina esa ventana, incluso sus propios documentos.
3. **Si insiste, se bloquea la cuenta.** Con 30 en un día se cierran todas sus sesiones y la cuenta queda
   bloqueada 24 horas (`423` al ingresar). Se levanta antes desde SGO (Desbloquear) o cambiando la
   clave con el enlace que llega al correo.
4. **Sin sesión se cuenta por dirección IP:** 40 consultas a documentos en 15 minutos dejan esa
   dirección en pausa para ese tipo de consulta. Ingresar al portal sigue funcionando.
5. **Queda registrado y se avisa.** La pausa y el bloqueo aparecen como `ALERTA` en el registro de
   accesos del cliente y se envía un correo a la casilla `alertas_correo` de la configuración.

Los topes se cambian en la configuración (`rastreo_*`). Quien usa el portal llega a cada documento
desde una lista, así que en el uso normal casi nunca se pide un código que no existe.

## Acceso (sin sesión)

### `POST /sesion`
```json
{ "email": "nombre@empresa.cl", "clave": "..." }
```
Responde `{ "ok": true, "nombre": "...", "token": "...", "vence": "...", "elegir_cliente": false }` y deja la cookie.

`elegir_cliente: true` significa que la persona tiene acceso a varios clientes y debe elegir uno
antes de consultar (ver "Varios clientes").

### `DELETE /sesion`
Cierra la sesión en curso.

## Varios clientes

Una persona puede tener acceso a varios clientes: una contadora externa, un inspector de obras.
En cada cliente tiene su propio rol, sus obras y sus temas.

**La sesión trabaja con un cliente a la vez.** Todas las consultas responden solo con datos de ese
cliente; los de los otros no existen para la sesión (404), aunque la persona tenga acceso a ellos.

| Situación al ingresar | Con qué cliente parte |
|---|---|
| Tiene un solo cliente | Con ese |
| Tiene varios y ya trabajó con alguno | Con el último que usó |
| Tiene varios y es su primera vez | Con ninguno: debe elegir |

### `PUT /sesion/cliente`
`{ "id": "k3Jx9Qm2VbT7cYh4LpW0aA" }` → elige o cambia el cliente de la sesión. Los `id` válidos vienen en `clientes` de `GET /yo`.
Responde 404 si la persona no tiene acceso vigente a ese cliente. Cambiar en una sesión no afecta a
las otras sesiones de la misma persona.

Mientras no hay cliente elegido solo funcionan `GET /yo`, `PUT /sesion/cliente`, `POST /clave/cambiar`
y `DELETE /sesion`. Todo lo demás responde 409 `elige_cliente`.

Si el cliente de la sesión se suspende, o a la persona se le quita ese acceso, esa sesión se cierra.
Al volver a ingresar parte con otro de sus clientes.

### `POST /invitacion/validar`
`{ "token": "..." }` → `{ "ok": true, "tipo": "ALTA" | "RECUPERAR", "nombre", "email", "cliente" }`

### `POST /invitacion/aceptar`
`{ "token": "...", "clave": "...", "acepta_condiciones": true }` → `{ "ok": true, "email" }`

La clave exige al menos 10 caracteres, con letras y números. En un alta es obligatorio aceptar las
condiciones de uso; queda guardada la versión aceptada y la fecha. El enlace sirve una sola vez.

### `POST /clave/recuperar`
`{ "email": "..." }` → responde siempre lo mismo, exista o no el correo.

### `GET /condiciones`
`{ "version": "2026-09", "html": "..." }`

## Con sesión

### `GET /yo`
```json
{
  "usuario": { "nombre": "", "email": "", "cargo": "", "rol": "USUARIO", "ultimo_acceso": "" },
  "cliente": { "id": "k3Jx9Qm2VbT7cYh4LpW0aA", "nombre": "", "rut": [ { "rut": "", "razon_social": "" } ] },
  "clientes": [ { "id": "k3Jx9Qm2VbT7cYh4LpW0aA", "nombre": "", "rol": "USUARIO", "actual": true } ],
  "elegir_cliente": false,
  "temas": ["despachos", "programacion"],
  "todas_las_obras": false,
  "obras": 2,
  "version_api": "1.0.0"
}
```

`cliente` es el cliente con que está trabajando (`null` si debe elegir) y `clientes` son todos a los
que puede entrar. `rol`, `temas`, `todas_las_obras` y `obras` son los que tiene en ese cliente.

Temas posibles: `despachos`, `programacion`, `ordenes_compra`, `facturacion`, `cobranza`.
Roles: `USUARIO` y `SUPERADMIN_CLIENTE` (ve todas las obras y todos los temas de ese cliente).

### `GET /obras`
Solo devuelve las obras que el usuario puede ver y que están **activas** en SGO. Una obra sin guías en 60 días se cierra
sola (cron de SGO) y desaparece del portal **con todo lo suyo**: sus guías, facturas, programación y estado de cuenta dejan
de aparecer en todas las secciones. Si el cliente necesita verla, la reactiva el equipo de ventas en SGO.
```json
{
  "total": 2,
  "obras": [
    { "id": "k3Jx9Qm2VbT7cYh4LpW0aA", "nombre": "", "direccion": "", "comuna": "", "activa": true,
      "lat": null, "lng": null, "empresa": { "rut": "", "razon_social": "" } }
  ]
}
```

### `POST /clave/cambiar`
`{ "actual": "...", "nueva": "..." }` → cierra las demás sesiones del usuario; la actual sigue.

## Listas: filtros y páginas

Las listas aceptan estos parámetros. Un valor mal escrito se ignora, no da error.

| Parámetro | Qué hace |
|---|---|
| `obra` | Id de una obra. Si no es del usuario, la lista viene vacía |
| `desde`, `hasta` | Fechas `AAAA-MM-DD` |
| `q` | Texto a buscar |
| `pagina`, `por_pagina` | Página (parte en 1) y tamaño (50 por defecto, máximo 100) |

Todas responden con `total`, `pagina` y `por_pagina`, ordenadas de lo más nuevo a lo más antiguo.

## Órdenes de compra · tema `ordenes_compra`

### `GET /ordenes-compra`
Además de los filtros comunes: `estado=con_saldo` o `estado=completa`. `q` busca por número de orden o producto.
No incluye las órdenes anuladas.

```json
{
  "total": 71, "pagina": 1, "por_pagina": 50, "estado": "",
  "ordenes": [
    { "id": "k3Jx9Qm2VbT7cYh4LpW0aA", "folio": "83823", "fecha": "2026-09-25", "estado": "VIGENTE",
      "obra": { "id": "k3Jx9Qm2VbT7cYh4LpW0aA", "nombre": "" }, "empresa": { "rut": "", "razon_social": "" },
      "pedido": 32.0, "despachado": 0.0, "saldo": 32.0, "avance": 0, "completa": false, "tiene_documento": true,
      "lineas": [
        { "id": "k3Jx9Qm2VbT7cYh4LpW0aA", "producto": "HORMIGON G20-10-40-10", "unidad": "M3",
          "cantidad": 6.0, "despachado": 0.0, "saldo": 6.0,
          "precio": 2.12, "moneda": "UF", "es_cargo": false }
      ] }
  ]
}
```

- `estado`: `VIGENTE` o `EN REVISION` (recibida, todavía sin validar).
- `pedido`, `despachado`, `saldo` y `avance` (0 a 100) se calculan sobre el hormigón. Las líneas con
  `es_cargo: true` (hormigón no transportado, devuelto, sobreestadía, bombeo) no cuentan.
- `precio` es el precio de venta pactado en la orden. Es el único precio que entrega la API.

### `GET /ordenes-compra/exportar`
Entrega una planilla Excel (`.xlsx`) con las órdenes del filtro (`obra`, `estado`, `q`, `desde`, `hasta`), una fila
por producto de cada orden: fecha, orden, obra, empresa, estado de la orden, producto, tipo (hormigón o cargo),
pedido, despachado, saldo, unidad, precio y moneda. Máximo 5.000 órdenes por planilla.

### `GET /ordenes-compra/{id}/documento`
Entrega el documento que el cliente envió con su orden de compra: PDF o imagen, no JSON.

Solo existe para las órdenes que tienen **número del cliente** (`tiene_documento: true`). En SGO toda orden
lleva un adjunto, pero cuando la orden se ingresó sin documento del cliente (folios `TRA ...`, `CORREO ...`)
el adjunto es un correo, muchas veces interno de Hormimax. Esos no se entregan: responden 404.

### `GET /ordenes-compra/{id}`
`{ "orden": { ... } }` con la misma forma, más `despachos`: las guías que consumieron la orden.
`despachos` viene en `null` si el usuario no tiene el tema `despachos`.

## Programación · tema `programacion`

### `GET /programacion`
Cargas programadas (una por camión), agrupadas por día y por obra. Parámetros: `desde`, `hasta` y `obra`.
Sin fechas entrega desde hoy hasta 14 días más. El rango máximo es de 92 días; si se pide más se
recorta y `rango_recortado` viene en `true`. No se pagina. No incluye las cargas anuladas.

```json
{
  "desde": "2026-09-28", "hasta": "2026-10-12", "rango_recortado": false,
  "total": 106, "cantidad_total": 668.5, "cantidad_pendiente": 668.5,
  "dias": [
    { "fecha": "2026-09-28", "cargas": 13, "cantidad": 76.5, "pendiente": 76.5,
      "obras": [
        { "obra": { "id": "k3Jx9Qm2VbT7cYh4LpW0aA", "nombre": "" }, "cargas": 1, "cantidad": 4.5, "pendiente": 4.5,
          "primera": "10:00", "ultima": "10:00",
          "detalle": [
            { "id": "k3Jx9Qm2VbT7cYh4LpW0aA", "hora": "10:00", "cantidad": 4.5, "producto": "HORMIGON HF5-90-40-06",
              "planta": "PLANTA DE HORMIGON TALCA 1", "estado": "PROGRAMADO",
              "motivo": null, "orden_compra": { "id": "k3Jx9Qm2VbT7cYh4LpW0aA", "folio": "82555" }, "despacho": null }
          ] }
      ] }
  ]
}
```

- `estado`: `PROGRAMADO` (todavía no sale), `DESPACHADO` (ya tiene guía; `despacho` trae su id, folio y `tiene_documento`, que dice si `GET /despachos/{id}/documento` va a entregar el PDF) o `ANULADA`.
- Una carga cancelada en SGO viene con estado `ANULADA` y su `motivo` cuando la cancelación es de verdad: el motivo registrado es responsabilidad del cliente (falta cancha, suspende por lluvia, cliente modifica día...) o de Hormimax (planta en panne, falta camión...). Es el mismo criterio del informe "Programado vs cancelado" de SGO. Las cancelaciones por reprogramación (cambio de hormigón, modificación de m³) y las sin motivo no vienen. Una anulada no suma en `cargas`, `cantidad`, `pendiente` ni `total`; cada obra y cada día traen `anuladas` con cuántas son.
- `pendiente` y `cantidad_pendiente` son los m³ programados que todavía no se despachan.
- `cantidad` es lo programado. Lo efectivamente entregado está en la guía (`GET /despachos/{id}`).
- No trae precios.

El portal muestra esta misma respuesta de dos formas: como lista por día y como calendario mensual.
Para el calendario pide las semanas completas que muestra el mes (hasta 42 días).

### `GET /programacion/exportar`
Entrega una planilla Excel (`.xlsx`) con las cargas del filtro (`desde`, `hasta`, `obra`), una fila por carga.
Columnas: fecha, día, hora, obra, producto, cantidad, unidad, planta, orden de compra, estado y guía.
Sin precios y sin las cargas anuladas. Máximo 10 exportaciones por minuto por usuario.

## Facturación · tema `facturacion`

Documentos de venta del cliente: facturas, notas de crédito y notas de débito.

**Los montos son siempre los oficiales**, los del Registro de Ventas del SII. No se calculan desde las
guías, porque cantidad por precio no reproduce el monto del documento. Un documento sin registro del SII
viene con `monto_oficial: false` y sus montos en `null`: el monto está en su PDF.

### `GET /facturas`
Además de los filtros comunes: `tipo=factura` o `tipo=nota`. `q` busca por folio, orden de compra o
factura de referencia.

```json
{
  "total": 75, "monto_total": 196356039, "sin_monto": 0, "pagina": 1, "por_pagina": 50, "tipo": "",
  "documentos": [
    { "id": "k3Jx9Qm2VbT7cYh4LpW0aA", "tipo": "FACTURA", "folio": "3511", "fecha": "2026-09-25",
      "vencimiento": "2026-12-24", "condicion_pago": "90 DIAS",
      "obra": { "id": "k3Jx9Qm2VbT7cYh4LpW0aA", "nombre": "" }, "empresa": { "rut": "", "razon_social": "" },
      "monto_oficial": true, "neto": 4351756, "exento": 0, "iva": 826834, "total": 5178590,
      "guias": 7, "cantidad": 45.0, "ordenes_compra": ["79519"],
      "referencia": null, "tiene_documento": true }
  ]
}
```

- `id` empieza con `f` si el documento existe en SGO y con `s` si solo está en el registro del SII
  (las notas de crédito y de débito, y las facturas emitidas fuera de SGO).
- `tipo`: `FACTURA`, `NOTA DE CREDITO` o `NOTA DE DEBITO`. En una nota, `referencia` trae la factura a la que corresponde.
- `monto_total` suma los totales con IVA del filtro completo; las notas de crédito restan. `sin_monto`
  cuenta los documentos sin monto oficial, que no entran en esa suma.
- Un documento sin obra (una nota cuya factura no está en SGO) solo lo ve quien tiene acceso a todas las obras.

### `GET /facturas/{id}`
`{ "documento": { ... } }` con la misma forma, más:
- `guias_detalle`: guía, fecha, producto, cantidad, unidad y precio unitario de venta. `id_despacho` viene
  solo si el usuario también tiene el tema `despachos`.
- `notas`: notas de crédito y débito que hacen referencia a la factura.

### `GET /facturas/{id}/documento`
Entrega el PDF de la factura. Responde 404 si el documento no tiene PDF en el portal (hoy, las notas de crédito).

### `GET /facturas/exportar`
Planilla Excel con los documentos del filtro: fecha, documento, folio, RUT, empresa, obra, orden de compra,
guías, cantidad, neto, IVA, total, observación, condición de pago, vencimiento y referencia. Las notas de
crédito van en negativo, para que las columnas de montos se puedan sumar.

## Estado de cuenta · tema `cobranza`

Los documentos pendientes de pago del cliente. Vienen de KAME, pero **el portal no consulta a KAME**:
lee una copia que SGO actualiza 3 veces al día (`cron/cron_portal_cxc.php` en SGO). La respuesta trae
siempre cuándo se hizo esa copia.

### `GET /cobranza`
Parámetros: `obra`, `estado=vencido` o `estado=por_vencer`, y `q` (folio). No se pagina.

```json
{
  "actualizado": "2026-09-29 12:06:52", "atrasado": false,
  "total": 914743697, "vencido": 242477037, "por_vencer": 672266660, "cantidad": 357, "estado": "",
  "tramos": { "vencido_mas_90": 0, "vencido_61_90": 0, "vencido_31_60": 0, "vencido_1_30": 242477037,
              "por_vencer_0_30": 298808654, "por_vencer_31_60": 159920741, "por_vencer_mas_60": 213537265 },
  "documentos": [
    { "documento": "Factura", "folio": "2045", "fecha": "2026-06-15", "vencimiento": "2026-09-13",
      "condicion_pago": "Crédito", "obra": { "id": "k3Jx9Qm2VbT7cYh4LpW0aA", "nombre": "" },
      "total": 2023333, "saldo": 2023333, "vencido": true, "dias_mora": 16, "dias_para_vencer": 0,
      "marca": null, "cesionario": null, "factura": "k3Jx9Qm2VbT7cYh4LpW0aA" }
  ]
}
```

- `atrasado` es `true` si la última copia buena tiene más de 26 horas: la información puede no reflejar los últimos pagos.
- `marca`: `FACTORING` (con `cesionario`, a quien hay que pagarle) o `DISPUTA`. Las notas internas de cobranza no se entregan.
- `factura` es el código del documento para `GET /facturas/{id}`. Viene solo si el usuario tiene el tema `facturacion`.
- Un documento sin obra (no está en SGO) solo lo ve quien tiene acceso a todas las obras.
- Solo hay estado de cuenta de los clientes habilitados en el portal. Un cliente recién habilitado lo
  verá después de la siguiente copia.

### `GET /cobranza/exportar`
Planilla Excel con los documentos del filtro: documento, folio, obra, emisión, vencimiento, condición de pago,
estado, días de mora, días para vencer, total, saldo y observación (la marca de factoring o disputa).

## Despachos · tema `despachos`

### `GET /despachos`
Además de los filtros comunes: `oc` (id de una orden de compra). `q` busca por guía, producto,
camión u orden de compra. No incluye las guías anuladas.

```json
{
  "total": 563, "cantidad_total": 3744.0, "pagina": 1, "por_pagina": 50,
  "despachos": [
    { "id": "k3Jx9Qm2VbT7cYh4LpW0aA", "folio": "11200", "fecha": "2026-09-26", "hora": "11:20",
      "obra": { "id": "k3Jx9Qm2VbT7cYh4LpW0aA", "nombre": "" },
      "producto": "HORMIGON GB20-10-20-16 R7", "cantidad": 6.5, "unidad": "M3",
      "planta": "PLANTA DE HORMIGON TALCA 1", "camion": "PBRC-98", "chofer": "ANTONIO GONZALEZ",
      "bombeado": true, "estado": "DESCARGADO",
      "horas": { "salida_planta": "11:20", "llegada_obra": "11:40", "inicio_descarga": "11:42",
                 "termino_descarga": "11:55", "salida_obra": "12:00" },
      "orden_compra": { "id": "k3Jx9Qm2VbT7cYh4LpW0aA", "folio": "79519" },
      "facturado": true, "factura": { "folio": "3511", "fecha": "2026-09-25" },
      "tiene_documento": true }
  ]
}
```

- `estado`: `DESPACHADO` (salió de planta, sin registro en obra), `EN OBRA`, `DESCARGANDO`, `DESCARGADO`.
- Las horas sin registro vienen en `null`.
- `factura` trae el folio y la fecha de la factura en que se cobró la guía; viene en `null` si está por facturar.
- `cantidad_total` suma el hormigón de todas las guías del filtro, no solo de la página.
- Los despachos **no traen precios**: quien solo tiene este tema (un jefe de obra) no los ve.

### `GET /despachos/exportar`
Entrega una planilla Excel (`.xlsx`), no JSON. Acepta los mismos filtros que la lista (`obra`, `desde`,
`hasta`, `oc`, `q`) y trae **todas** las guías del filtro, no solo una página, ordenadas de la más
antigua a la más nueva.

- Columnas: fecha, guía, obra, producto, cantidad, unidad, orden de compra, planta, camión, chofer,
  bombeado, las cinco horas del despacho, folio de la factura y su fecha. Sin precios.
  Las guías sin factura dicen "Por facturar".
- Las fechas y las cantidades van como valores de Excel: se pueden sumar, filtrar y ordenar.
- Máximo 20.000 guías por planilla (si hay más responde 422) y 10 exportaciones por minuto por usuario.

### `GET /despachos/en-ruta`

Camiones que van **en este momento** hacia obras que el usuario puede ver. Tema `despachos`.

```json
{
  "ahora": "2026-09-29 11:30:12", "actualizar_cada": 60, "cantidad": 1,
  "camiones": [
    { "despacho": { "id": "k3Jx9Qm2VbT7cYh4LpW0aA", "folio": "11207", "tiene_documento": true },
      "obra": { "id": "k3Jx9Qm2VbT7cYh4LpW0aA", "nombre": "", "comuna": "", "lat": -35.44, "lng": -71.62 },
      "producto": "HORMIGON GB20-10-20-16 R7", "cantidad": 6.5, "unidad": "M3", "bombeado": true,
      "planta": "", "camion": "PLKF-16", "chofer": "",
      "estado": "EN_CAMINO", "salida": "11:02", "minutos_en_viaje": 28,
      "llegada_estimada": "11:40", "minutos_restantes": 10, "atrasado": false, "estimacion": "historia",
      "posicion": { "lat": -35.4128, "lng": -71.6489, "velocidad": 46,
                    "actualizada": "2026-09-29 11:29:32", "hace_segundos": 40, "vigente": true } }
  ]
}
```

- **Cuándo aparece un camión:** tiene una guía de ayer u hoy hacia una obra del usuario, sin ninguna hora
  registrada en obra, y esa guía es la última salida de ese camión.
- **Cuándo deja de verse:** al registrarse la llegada, al entrar a la geocerca de la obra, al acercarse a
  menos de 200 m del centro de una obra sin geocerca, al salir con otra carga, al volver a la planta, o al
  pasar el tope de tiempo (el triple del viaje estimado, mínimo 90 minutos).
- `estado`: `EN_CAMINO` o `EN_PLANTA` (la guía ya se emitió y el camión todavía no sale).
- `llegada_estimada` = hora de salida + promedio de los viajes a esa obra en los últimos 90 días.
  `estimacion: "referencia"` cuando la obra tiene menos de 3 viajes con horas y se usa un tiempo fijo.
  `atrasado: true` cuando ya pasó la hora estimada y el camión no ha llegado.
- `posicion` es `null` si el camión no tiene GPS o su última posición tiene más de 30 minutos.
  `vigente: false` cuando tiene más de 5 minutos: hay que mostrarla como "última posición", no como actual.
- **Nunca se entrega:** la flota, camiones que van a obras de otros, camiones de regreso, el recorrido
  hecho ni el nombre del lugar o de la geocerca en que está el camión.
- `actualizar_cada` son los segundos que el portal espera entre una consulta y otra. El GPS se actualiza
  cada minuto: preguntar más seguido no entrega nada nuevo. En el registro de accesos queda una consulta
  cada 5 minutos por sesión.

### `GET /despachos/{id}`
`{ "despacho": { ... } }` con la misma forma, más `lineas` (producto, unidad, cantidad, `es_cargo`).

### `GET /despachos/{id}/documento`

Entrega la copia recepcionada de la guía (firmada en obra) si SGO la tiene; si no, la guía original tal como
se emitió. `tiene_documento` es `true` en ambos casos.
Entrega el archivo de la guía: PDF o imagen, no JSON. Responde 404 si la guía no tiene documento.
La dirección real del archivo nunca se le entrega al cliente: la API lo trae y lo sirve después de
comprobar el permiso, así que el enlace no funciona sin sesión ni para otro usuario.

## Administración (solo SGO)

Rutas bajo `/admin`. No son para clientes: exigen la llave de administración
(`X-Portal-Admin-Key`), el nombre de quien opera (`X-Portal-Admin-Usuario`) y que la llamada venga
del mismo servidor. La pantalla de SGO las usa a través de `modules/portal_clientes/ajax/ajax_portal.php`,
que es quien pone la llave: **la llave nunca llega al navegador**.

| Método y ruta | Qué hace |
|---|---|
| `GET /admin/clientes` | Clientes habilitados, con sus contadores |
| `POST /admin/clientes` · `PUT /admin/clientes/{id}` | Habilita o modifica un cliente (`nombre`, `contribuyentes[]`, `estado`, `observacion`, `id_grupo_empresa`) |
| `GET /admin/clientes/{id}` | Cliente con sus RUT |
| `GET /admin/clientes/{id}/obras` | Obras del cliente, con indicador de geocerca |
| `GET /admin/clientes/{id}/usuarios` | Usuarios con temas, obras, sesiones abiertas y vigencia del enlace |
| `GET /admin/clientes/{id}/log?limite=` | Registro de accesos (sin las consultas de la propia administración) |
| `GET /admin/contribuyentes?q=` | Busca clientes de SGO por RUT o razón social |
| `GET /admin/grupos` | Grupos de clientes de SGO con sus RUT |
| `GET /admin/personas?q=&cliente=` | Personas que ya usan el portal y todavía no tienen acceso a ese cliente, con sus clientes actuales |
| `POST /admin/usuarios` · `PUT /admin/usuarios/{id}` | Crea un usuario en un cliente, o modifica su acceso a ese cliente (`id_portal_cliente`). Al crear envía la invitación. `estado`: `ACTIVO`, `INACTIVO`, `QUITAR`, `DESBLOQUEAR` |
| `POST /admin/usuarios/{id}/invitar` | Reenvía la invitación o envía enlace de cambio de clave |
| `GET` · `DELETE /admin/usuarios/{id}/sesiones` | Lista o cierra las sesiones del usuario |

### Una persona en varios clientes

Los usuarios se administran siempre **dentro de un cliente**: el rol, las obras y los temas que se
guardan son los de esa persona en ese cliente.

- **Correo que ya es usuario del portal.** `POST /admin/usuarios` no crea otra persona. Responde 409
  con `existe: { nombre, cargo, estado, clientes[] }` para que quien administra vea de quién se trata.
  Repitiendo el pedido con `"vincular": true` se le agrega el acceso a este cliente. Su nombre, cargo
  y clave no cambian. Si ya tiene clave recibe un aviso por correo; si no, recibe de nuevo la invitación.
- **`INACTIVO`** desactiva su acceso a este cliente y conserva sus obras y temas. **`QUITAR`** se lo
  quita. En ambos casos sus accesos a otros clientes no cambian.
- El nombre, el cargo y el correo son de la persona: al modificarlos cambian en todos sus clientes.
- `GET /admin/clientes/{id}/usuarios` trae en `otros_clientes` los demás clientes de cada persona.

Efectos que conviene conocer:

- **Suspender un cliente**, **desactivar** o **quitar** un acceso cierra en el acto las sesiones que
  están trabajando con ese cliente. Las que trabajan con otro cliente siguen.
- **Quitar un RUT** de un cliente quita sus obras de las listas de los usuarios.
- Un RUT solo puede pertenecer a un cliente del portal.
- Cuando el correo no se puede enviar, la respuesta trae el `enlace` para hacerlo llegar por otra
  vía. En producción el enlace **solo** se entrega en ese caso.
