# AGENTS.md — laravel_secopio

> **¿Acabas de cambiar de modelo o de sesión?** Lee primero **`HANDOVER.md`**: resume el estado del
> trabajo visual del 2026-10-01, las reglas de medición y el rediseño de paneles que quedó
> pendiente. Este archivo es la memoria histórica completa.

Migración de la app SECOPIO (alertas de licitaciones SECOP II) desde el proyecto PHP plano
`C:\laragon\www\alertas_licitaciones` (producción: `secopio.gssoftwareysoluciones.com`, ver su AGENTS.md
para toda la historia y lógica de negocio del proyecto origen) hacia **Laravel 10 + Breeze**,
manteniendo la BD nueva multi-tenant `secopio` como definitiva: empresa → usuarios → perfiles_busqueda.

**Estado: FUNCIONALIDAD + PIPELINE TERMINADOS (2026-09-28).** Registro multitenant, configuración/
edición de filtros UNSPSC, panel de empresa, admin (activar/bloquear + vigencias) y motor de alertas
como comando artisan probado (48.507 procesos descargados de SECOP II). Pendiente: migración del
resto de clientes reales del origen y la decisión de borrar vistas Breeze huérfanas.

---

## Entorno / cómo correr comandos

- Laragon, Windows, Apache + MySQL. URL del proyecto: **`http://laravel_secopio.test`**.
- `.env`: `APP_NAME=SECOPIO`, `APP_URL=http://laravel_secopio.test`, MAIL **ZeptoMail**
  (`smtp.zeptomail.com:587`, usuario `emailapikey`, tls, FROM `noreply@gslrsoftwareysoluciones.com`,
  nombre `SECOPIO`), `APP_LOCALE=es`.
- **`php` NO está en PATH.** Usar siempre el binario completo:
  `C:\laragon\bin\php\php-8.1.10-Win32-vs16-x64\php.exe` (ej. `& $php artisan route:list`, `view:cache`).
- MySQL: base `secopio`, host `127.0.0.1`, root sin password. BD origen: `alertas_licitaciones`.
- Composer: Laravel 10 + Breeze (blade) + Spatie Permission + laravel/tinker.
- **Sin caché activa**: `bootstrap/cache` sin `config/routes`; `APP_ENV=local`, `APP_DEBUG=true`. Los
  blades compilados por `view:cache` se recompilan solos por mtime — no bloquean.
- El navegador cachea los CSS por `?v=` manual. Si un cambio visual "no se ve": subir `?v=` o Ctrl+F5.
- **PowerShell rompe backslashes/comillas en `-e`/`--execute`**: para operaciones de BD usar un script
  PHP temporal (`<?php require vendor/autoload.php; $app=require bootstrap/app.php; ...`) y borrarlo
  después.

## Modelo / base de datos

- **Multitenant**: `empresa` (tenant) → `usuarios` (relación `tenant_id`) → `perfiles_busqueda`.
  `empresa` tiene `max_perfiles`=3, `max_usuarios`=5, `plan` ('basico'/'prueba'/'anual'), `activo`,
  `aviso_vencimiento_enviado`. Vigencias en `plan` + `tenant_has_plan` + `pagos`
  (fecha_vencimiento). Catálogos: `cat_departamentos` (33, codigo_divipola '01'-'33'),
  `cat_modalidades` (17), `cat_tipos_contrato` (23).
- Spatie `roles`: 1=super-admin, 2=administrador, 3=Administrador Cliente, 4=Cliente. Los
  `model_has_roles` son las únicas asignaciones usadas (no roles por empresa). El rol 3
  (Administrador Cliente) = **admin de empresa**: gestiona SOLO su empresa (usuarios incluidos);
  los roles 1-2 = **super admin**: todas las empresas + vigencias + asignación de roles.
- **Procesos/envíos**: `procesos` (caché SECOP II, upsert por `id_del_proceso`, sin timestamps
  Eloquent — usa `primera_vez_visto`/`ultima_sync`; `precio_base` es `double(20,2)`), `envios`
  (por perfil, estado pendiente/enviado/fallo, **`token`** de 64 hex) y `envio_detalle`
  (envio_id, proceso_id, posicion, score; sin timestamps). Reemplazan alertas_procesos del origen.
  `envios` cumple el papel de `lotes_envio` (un envío = un lote) y `envio_proceso` es la **copia
  congelada** del proceso que se mandó (ver "Lote de envío").
- **Usuarios actuales**: id 1 = `gerolugo2007@gmail.com` (roles super-admin+administrador, hash de
  password COPIADO del origen = entra con su clave de producción), id 2 = `prueba@test.com` rol
  Administrador Cliente (clave desconocida). Empresa 1 = GSLR (dueño GSLR SOFTWARE).
- Admin: `/admin/*` exige `auth` + `role:administrador|super-admin|Administrador Cliente`
  (Spatie). El login bloquea usuarios con `activo=false` y, desde 2026-10-08, también a los que
  NO tienen rol de administración (los `Cliente` no inician sesión: ven el aviso en `/login` —
  ver "Filtros compartidos por empresa").

## Vistas / diseño

- **Layout base:** `resources/views/layouts/secopio.blade.php` — `@stack('estilos')`,
  `@stack('scripts')`, `@yield('titulo')`, `@yield('clase_body')`, `@yield('contenido')` + meta csrf.
- CSS y assets copiados del origen a `public/` (`css/estilos.css`, `css/configurar-filtros.css`,
  `assets/*.png`, `vendor/tom-select/*`). `css/panel-secopio.css` quedó sin uso (se borró el panel).
- Vistas funcionales: `publico/{index,configurar-filtros,registro-exitoso}.blade.php`,
  `admin/{dashboard,clientes/index,clientes/show}.blade.php`, Breeze `auth/*`.
  **No existe panel de usuario**: se borraron `PanelController`, `publico/panel.blade.php`,
  `publico/panel-secopio.blade.php` y las rutas `/panel` y `/panel-secopio` (2026-09-29, pedido
  del dueño). El cliente no tiene dashboard: tras registrarse/configurar filtros aterriza en
  `/registro-exitoso` ("cuenta esperando validación") y al iniciar sesión entra a la portada `/`.
- **Entrada del administrador**: el login es `/login` (Breeze, `routes/auth.php`).
  `RedirectIfAuthenticated` solo rebota a `/admin/dashboard` si la sesión es de administrador; un
  `Cliente` con sesión abierta ve el formulario de login (antes lo mandaba a `/`).
  Ruta corta `GET /admin` (name `admin`): sesión de administrador → `/admin/dashboard`,
  sin ella → `/login`.
- JS de filtros en `public/js/secopio/`: `configurar-filtros.js` (adaptado, consume las rutas
  Laravel con CSRF), `unspsc-buscables/segmentos/familias/clases/productos.js`,
  `filtros-adicionales.js` (consume `/admin/catalogos-filtros`). Ya NO existen `public/clientes/*.js`
  (quitaron las etiquetas que los referenciaban).
- Vistas Breeze viejas `resources/views/{dashboard,welcome}.blade.php` huérfanas (sin ruta).

## Rutas clave (`routes/web.php` + `routes/auth.php`)

- `GET /` → inicio · `POST /register` → registro multitenant (create empresa + usuario rol Cliente).
  Está FUERA del middleware `guest` a propósito: un admin con sesión abierta también puede registrar
  clientes. **Todo registro nuevo va SIEMPRE a `cliente.filtros`** con `?token=` (2026-09-30, pedido
  del dueño): antes, si quien registraba era un admin con la sesión abierta, el flujo saltaba directo a
  `registro.exitoso` y el cliente nunca alcanzaba a configurar nada. **EXCEPCIÓN (2026-10-08)**: si el
  nombre coincide con una empresa que **ya tiene filtros** (perfil de referencia), no hay nada que
  configurar: el usuario nuevo recibe su copia y va directo a `registro.exitoso` (ver "Filtros
  compartidos por empresa"). Al guardar los filtros sí cae en
  `registro.exitoso` (vía `volver=`), que **ya no tiene botón de configurar filtros**: solo el aviso
  de cuenta esperando validación y "Volver al inicio". El admin configura los filtros del cliente
  nuevo desde `/admin/clientes/{id}`. Por eso se borraron los flashes `registro_es_admin`/`cliente_nuevo`,
  que quedaban sin usar.
- **Token de registro** (`usuarios.token_registro`, migración `2026_09_30_000001`, 64 hex único):
  hace falta porque el registro puede hacerlo un admin con su sesión abierta, y esa sesión **no se roba**
  (el admin sigue en el panel). El token viaja en la URL (`?token=…`) y es la credencial que deja al
  cliente entrar a configurar sus filtros sin iniciar sesión: `usuarios::usuarioPorTokenRegistro()`
  (valida el formato 64 hex), `generarTokenRegistro()` y `consumirTokenRegistro()` (lo anula al guardar,
  así que es **de un solo uso**). Por eso `/configurar-filtros` (GET, POST y `/datos`) quedaron **fuera
  del middleware `auth`**: `FiltrosController@index/@guardar/@datosPerfil` repone la exigencia de sesión
  cuando no hay token válido (`redirect()->route('login')` y 401 respectivamente). Con token, la empresa
  y el `user_id` del perfil son los del **cliente** dueño del token, nunca los del admin que quizá tenga
  la sesión abierta, y el `redirect` final se calcula con `$admin && !$usuarioToken`. El JS ya lo
  manda en el POST (`datosFiltros.token`, `TOKEN_CONFIGURACION` en `configurar-filtros.js`); también
  se arregló `validarTokenConfiguracion()`, que llamaba a `clientes/validar-token-filtros.php` (no
  existe en Laravel) y hacía un 404 en cada carga: ahora valida contra la propia ruta.
- Auth: `GET/POST /configurar-filtros` → `FiltrosController@index/@guardar` (name `cliente.filtros`,
  `cliente.filtros.guardar`) · `GET /configurar-filtros/datos?perfil=ID` → `@datosPerfil`.
  No hay más ruta de panel: `/panel` y `/panel-secopio` se eliminaron.
- Admin (`role:administrador|super-admin|Administrador Cliente`): `/admin/dashboard`,
  `/admin/clientes`, `/admin/clientes/{id}`, `GET/PUT /admin/clientes/{id}/filtros` (edit/update),
  `/admin/verificar-sesion`. Scoping por rol: el **super admin** (role super-admin/administrador) ve
  TODAS las empresas, activa/bloquea + vigencia (`POST /admin/clientes/{id}/estado`) y da/quita el
  rol de empresa (`POST /admin/usuarios/{userId}/rol`); el **Administrador Cliente** ve SOLO su
  empresa (`index`/`dashboard`/`show` scoped, 403 en id ajeno), agrega usuarios a su empresa
  (`POST /admin/clientes/{id}/usuarios`, forzado a rol Cliente) y activa/bloquea cuentas
  (`POST /admin/clientes/{id}/usuarios/{userId}/estado`), pero NO toca vigencia ni roles (403).
- Sin role (solo auth): `GET /admin/catalogos-filtros` (lo usan clientes y admin).
- Breeze: login/logout/register/password.*/verification.*/perfil.
- **Correos (2026-09-29)**: el diseño se copió del origen (`motor/enviar-alertas.php` líneas
  104-150 y 339-392 + `admin/solicitar-recuperacion.php` 201-230): tarjeta blanca `max-width:650px`
  con `border-radius:12px`, encabezado blanco con `border-bottom:4px solid #123c69`, tagline "Alertas
  de licitaciones públicas", párrafos `#606975` 15px, botón rojo `#c62828` (padding 15px 45px),
  enlace `#1d5d91`, pie gris `#f4f6f9` con "Este correo fue generado automáticamente por SECOPIO".
  Colores: navy `#123c69`, rojo `#c62828`, texto `#606975`, pie `#9aa3ad`, fondo `#f4f6f9`, borde `#d9dee5`.
  Vistas: `emails/{alertas-encontradas,aviso-vencimiento,recuperar-clave}.blade.php` (100% estilos
  inline, sin `<style>`). El logo `assets/secoadapta.png` (idéntico al del origen) se carga por
  **URL pública de producción** desde `config('secopio.url_logo')` (= `SECOPIO_URL_LOGO`), nunca
  `asset()`: Gmail/Outlook no pueden abrir `localhost` y saldría roto. En el origen el `cid:`/base64
  se probó y ZeptoMail lo mostraba como adjunto; por eso URL.
  El correo de "recuperar contraseña" ya no es el azul de Laravel: `App\Notifications\ResetPasswordSecopio`
  renderiza `emails/recuperar-clave` con la misma tarjeta y saluda por el nombre de la empresa.
  **OJO (Laravel 10.50)**: la opción `config/auth.php → passwords.users.notify` NO se lee (el
  `PasswordBrokerManager` ignora esa clave y `PasswordBroker::sendResetLink` llama a
  `$user->sendPasswordResetNotification($token)`). Por eso `app/Models/usuarios.php` **sobrescribe**
  ese método y envía la notificación de SECOPIO; si se quita, vuelve el correo azul de Laravel.
  Para depurar qué se manda realmente: poner `MAIL_MAILER=log`, pedir el correo y leer el MIME en
  `storage/logs/laravel.log` (el `view:render` anterior solo comprobaba la vista, no el broker).
  Contacto de renovación: `config('secopio.correo_contacto')` (= `SECOPIO_CORREO_CONTACTO`).
- **Recuperar contraseña** (2026-09-29, arreglado): el modelo `usuarios`_extiende
  `Illuminate\Foundation\Auth\User` y **usa el trait `Notifiable`** (sin él
  `usuarios::notify()` no existía y el envío del correo daba error 500), tiene el mutador
  `setPasswordAttribute()` que redirige la escritura de Laravel (`password`) a la columna real
  `password_hash` sin volver a hashear, y la tabla ganó la columna `remember_token`
  (migración `2026_09_29_000004`) porque el broker la rellena con forceFill.
  Probado de punta a punta: pedir enlace → abrir `/reset-password/{token}` → guardar clave nueva →
  login con la nueva (ok) y con la vieja (rechazada).
- **Idioma es**: `config/app.php` leía `env('APP_LOCALE','es')`; `.env` tiene `APP_LOCALE=es` y
  `APP_FAKER_LOCALE=es`; `lang/es/{passwords,auth,validation}.php` traducen los mensajes que ve el
  usuario (fallback `en` para lo que falte).

## Límite de usuarios por empresa (2026-09-30)

- Regla en **una sola fuente de verdad**, dentro de `App\Models\empresa`: `limiteUsuarios()`
  (normaliza a >= 0), `quedanUsuarios()` y `alcanzoLimiteUsuarios()`. Antes el número se comparaba a
  mano en el controller y con otro criterio en la vista, así que era fácil que dejaran de coincidir.
  Las dos rutas que dan de alta a alguien lo usan:
  1. **Panel admin** (`ClienteController@crearUsuario`): si se alcanzó el tope hace
     `redirect()->route('admin.clientes.show')->with('error', ...)` — **ya no `abort(422)`**, que
     pintaba una pantalla de error cruda. El conteo va contra la BD
     (`$empresa->usuarios()->count()`), no sobre la colección ya cargada, para que dos altas seguidas
     no se pasen el límite. La vista además oculta el formulario y avisa cuando `quedanUsuarios()`
     es 0, y muestra "Quedan N de los M que permite el plan" mientras hay sitio.
  2. **Registro público** (`RegisteredUserController@store`): el alta pública **siempre crea una
     empresa nueva**, así que va 1 de 5 y no hay forma de pasarse. La comprobación se puso igual (y
     borra la empresa recién creada si se disparara) para que la regla no viva en dos sitios y para
     que, si algún día el registro deja unirse a una empresa existente, ya respete el tope.
- **El registro público SÍ se une a una empresa existente si el nombre coincide** (2026-09-30, decisión
  del dueño, tras ver que se había creado una 5ª empresa "JL DESARROLLO" por el mismo problema):
  `empresa::buscarPorNombre()` + `empresa::normalizarNombre()` (sin tildes, sin espacios de más, en
  mayúsculas, vía `Str::ascii/squish/upper`). Así "JL DESARROLLO", "jl desarrollo" y "JL   DESARROLLO "
  caen en la misma empresa y el compañero ve el panel del mismo administrador. **El nombre de la empresa
  es obligatorio**: sin él no se puede decidir a cuál summingirse y además quedaba una empresa en blanco
  en el panel, así que `RegisteredUserController@store` lo rechaza con mensaje.
  El usuario nuevo entra SIEMPRE con rol `Cliente` (nunca administrador), respeta `max_usuarios` de la
  empresa encontrada (`empresa::alcanzoLimiteUsuarios()`, el mismo helper del alta del panel: si está
  llena se rechaza con "ya tiene sus N usuarios") y recibe un flash que dice a qué empresa se sumó.
  Si el nombre no coincide con ninguna, se crea la empresa nueva como antes. **Riesgo aceptado**: con un
  tope de 5, alguien podría ocupar un cupo de otra empresa si conoce el nombre; si algún día molesta, la
  forma segura sigue siendo un enlace de invitación con token de un solo uso generado por el
  administrador, no el agrupamiento por nombre.
- Empresas homónimas fusionadas (2026-09-30): había **4 empresas "JL DESARROLLO"** (#16, #18, #24, #35),
  cada una con un solo usuario, porque el registro siempre creaba una empresa nueva. Como el rol
  "Administrador Cliente" da permisos **por `tenant_id`, no por nombre**, el administrador de la #16
  no veía a los otros 3. Se fusionaron en la **#16** (la que tenía la vigencia): los `tenant_id` de
  los 3 usuarios y del perfil #9 se movieron a la 16 y se descartó el `tenant_has_plan`/`pagos`
  duplicado de la #35 (vencían el mismo día: 30/09/2027). Quedó 1 pago, 1 vigencia, 4 de 5 usuarios
  y 2 de 3 perfiles. El merge va **en transacción** y con respaldo previo
  (`%TEMP%\opencode\respaldos\antes_unir_empresas_*.sql`).
- **Una 5ª "JL DESARROLLO" volvió a aparecer (#43, `geueuw@gmail.com`) y se fusionó en la #16** el mismo
  día, ya con el agrupamiento por nombre en el registro. Respaldo:
  `antes_unir_43_20260930_143504.sql`. La #43 estaba `pendiente` (nunca tuvo plan), así que solo hubo
  que mover el `tenant_id` del usuario y borrar la empresa; la vigencia de la #16 (30/09/2027) nunca se
  tocó. Estado tras todo: 4 empresas, 6 usuarios, 4 perfiles, 1 envío histórico, 51.454 procesos.
- `max_perfiles` (3) **NO tiene control en el alta de perfiles**: la cascada UNSPSC limita a 3
  *segmentos por perfil*, que es otra cosa. Pendiente de decidir si el plan también debe topar el
  número de perfiles por empresa.

## Errores: páginas propias y middleware `rol.admin` (2026-09-30)

- **Los 403 feos en inglés** ("403 - User does not have the right roles.") salían del middleware
  `role:administrador|super-admin|Administrador Cliente` de Spatie: el usuario entraba a `/admin`,
  no tenía el rol y Laravel pintaba su página de error cruda. Había **4 cuentas con rol `Cliente`**
  que sufferían eso (p. ej. `gerolugo2007@gmail`, que es el mismo dueño que `gerolugo2007@gmail.com`
  pero una empresa aparte, la #12). Dos arreglos:
  1. **`app/Http/Middleware/RolAdmin.php`** (registrado como `rol.admin` en `app/Http/Kernel.php`, y
     las rutas de `routes/web.php` lo usan en vez del `role:` de Spatie): sin sesión → login; **con
     sesión pero sin el rol → `redirect(RouteServiceProvider::homeAutenticado())` con flash `error`**
     ("El panel es solo para administradores..."). Solo para `GET`: un `POST/PUT/DELETE` sigue
     haciendo `abort(403)`, porque ahí sí podría ser un intento de acción no permitida. Los roles
     admitidos viven en `RolAdmin::ROLES` (**una sola lista**, que hay que mantener si se agrega un
     rol nuevo de administración).
  2. **`resources/views/errors/{403,404,419,500}.blade.php`**, que antes **no existían** (Laravel
     caía a su plantilla genérica). Ahora respetan el layout de SECOPIO, están en español y ofrecen
     salidas: inicio, "cambiar de cuenta" y, en el 419, recargar (el 419 salía por dejar un
     formulario abierto demasiado tiempo). Clases nuevas en `css/estilos.css`: `.pagina-error`,
     `.error-codigo`, `.error-texto`, `.error-acciones`, `.btn-secundario`.
- **`partials/flash.blade.php`, incluido en `layouts/secopio.blade.php`**: antes las vistas admin
  solo pintaban `session('estado')` (y solo el dashboard), así que **todos los `with('error', ...)`
  se perdían en silencio** - entre ellos el de "empresa alcanzó su máximo de usuarios" y el de
  "Activa o desbloquea la empresa primero". El parcial muestra `estado` y `error` en **todas** las
  páginas, y se quitó el bloque duplicado del dashboard. Clases `.mensaje-flash`,
  `.mensaje-flash-ok`, `.mensaje-flash-error`.
- **`layouts/secopio.blade.php` NO carga CSS por sí mismo** (solo tiene `@stack('estilos')`), así que
  cada vista tiene que hacer `@push('estilos')` con su `<link>` a `css/estilos.css`. Se olvidó en las 4
  vistas de error y salían sin estilo (texto pelado sobre fondo blanco); ya llevan el push con
  `?v=20260930b`. Si se crea otra vista, no olvidar el push o se verá sin diseñar.
- Ojo al probar flashes por HTTP: se validó que el mensaje NO aparecía porque la vista ya traía un
  texto estático igual ("alcanzó su máximo de 5"), y el `match` daba un falso positivo.

## Botón "Volver" (2026-09-30, pedido del dueño)

- **El detalle del cliente y la configuración de filtros son pantallas cerradas**: se entra desde un
  botón y no había forma de volver arriba. En `admin/clientes/show` existía un "Volver al panel"
  pero **estaba al final**, y con 763 líneas había que recorrer la página entera con scroll para verlo.
- **Dos contextos distintos, a propósito** (el mismo botón cambia de aspecto según el fondo):
  - `.volver-header` (`estilos.css`): pill **blanco translúcido** en la esquina superior derecha del
    `header.admin-header`, que es un degradado navy. Va `absolute` con `z-index:2` para quedar por
    encima del círculo decorativo `::after`. En `max-width:700px` pasa a `static` (se cruzaba con el
    logo) y se coloca en su propia línea.
  - `.cfg-volver` (`configurar-filtros.css`): pill con **contorno navy y fondo blanco**, dentro de
    `.cfg-encabezado` y **justo encima del `<h1>`**. No se puso en la esquina de `.cfg-contenido` a
    propósito: esa columna es `flex:1` y su contenido está centrado con `max-width:860px`, así que
    un botón al borde derecho quedaba desligado del título. Los dos usan `←` + "Volver" (en el panel
    el texto es "Volver a los clientes" o "Volver al panel" según el rol) y son discretos para no
    competir con la acción principal ("Guardar filtros").
- **El texto y el destino dependen del rol** (`show.blade.php`): el super admin (`$admin`, es decir
  rol `administrador`/`super-admin`) vuelve al **listado** `route('admin.clientes')` — OJO, el nombre
  de la ruta es `admin.clientes`, **no** `admin.clientes.index`; con el nombre equivocado la vista
  da 500 — y el Administrador Cliente a su `admin.dashboard`.
- **`?volver=` en la configuración de filtros**: la rama `?editar=` de `FiltrosController@index`
  **no pasaba `volver` a la vista** (las otras dos sí), así que el botón no tenía a dónde ir.
  Ahora las tres ramas usan `destinoVolver()`: respeta el `?volver=` si viene (validado por
  `urlSeguraInterna()`, que ya descarta URLs externas), y si no, el detalle de la empresa para el
  admin o `route('inicio')` para el cliente. Es el mismo criterio que `destinoGuardado()`, pero
  tolerante a que no haya empresa (configuración inicial de una empresa recién registrada).
- CSS actualizado con `?v=20260930d` (estilos.css) y `?v=20260930b` (configurar-filtros.css).
  Probado por HTTP con curl: detalle 200, listado 200, `?editar=` 200, rama del Administrador
  Cliente 200, `?volver=/admin/clientes/16` respetado y `?volver=https://sitio-malo.com` ignorado.

## 419 "Tu sesión se venció" al iniciar sesión (2026-10-08, arreglado)

- **El síntoma**: el dueño ponía correo y contraseña en `/login` (o en el registro de la portada) y
  aterrizaba en la página propia **419**. La causa NO era el login: era el **token CSRF de un
  formulario viejo**. Dos caminos reales, ambos comprobados en el `access.log` de Apache
  (`C:\laragon\bin\apache\httpd-2.4.54-win64-VS16\logs\access.log`, con rafagas de
  `POST /login 419` tras `GET /login 200` y también `POST /login 419` **estando ya en el
  dashboard**):
  1. La pestaña del formulario quedó abierta más de `SESSION_LIFETIME` (120 min) o el navegador
     la restauró desde la memoria al reabrir; el archivo de sesión ya no existe y el token del HTML
     ya no corresponde a nada.
  2. Se inició sesión en **otra pestaña**: `AuthenticatedSessionController@store` llama a
     `$request->session()->regenerate()`, y en Laravel 10 `Store::regenerate()` **también llama a
     `regenerateToken()`** (verificado en `vendor/.../Illuminate/Session/Store.php`), así que el
     formulario de la pestaña vieja queda con un token muerto.
- **Arreglo 1 (el importante)**: `app/Http/Middleware/VerifyCsrfToken.php` **ya no lanza 419 en
  cuanto hay un desajuste**. Si el request es de un formulario de navegador (`Accept: text/html`,
  trae `_token`, viene del **mismo sitio** y todavía no se reintentó), rota el token y devuelve al
  usuario a la página que envió con flash "Tu sesión había expirado…" y `withInput()` (sin la
  contraseña), marcando `csrf_reintento=1` en la URL. **La acción rechazada NUNCA se ejecuta**
  (el POST forzado desde otro sitio tampoco se ejecutaría), o sea la protección CSRF sigue
  intacta; solo se evita el callejón sin salida. El reintento es **UNA sola vez** (`Referer` con
  `csrf_reintento=1` ⇒ 419 de verdad): si no, un navegador sin cookies se comería un bucle
  infinito. Sin `Referer`, con `Referer` de otro dominio, o con `Accept` que no sea `text/html`
  (el `fetch` de `configurar-filtros.js` manda `*/*`), sigue saliendo **419 igual que antes**.
  Probado con curl: token viejo+referer → 302 a `/login?csrf_reintento=1`, ese GET ya sirve el
  aviso y el formulario repoblado, el POST siguiente pasa el CSRF (302), segundo token viejo →
  419, sin/externo/JSON → 419, y los flujos normales (`/`, `/login`, `/admin/*`, registro con
  campos vacíos) no cambiaron.
- **Arreglo 2**: `resources/views/errors/419.blade.php` usaba
  `onclick="window.location.reload()"`, que sobre una página que llegó por **POST** vuelve a
  **reenviar el formulario** (Chrome pregunta "¿Reenviar?") y el 419 se repetía en bucle. Ahora
  `secopioReintentar()` hace un **GET** de `document.referrer` si es del mismo origen (así
  `POST /logout` no cae en un 404 al irse a `/logout`) o, si no, de `pathname+search`. Se añadió
  un segundo botón "Ir al inicio" (`.btn-secundario`, ya existía en `estilos.css`) y una línea
  del texto pidiendo cerrar las pestañas viejas o borrar las cookies si el mensaje insiste —
  que es justo el diagnóstico pendiente si algún día vuelve: **si el 419 persiste, el navegador
  no está guardando la cookie `secopio_session`** (mirar en DevTools → Aplicación → Cookies si
  hay dos `secopio_session` o si están bloqueadas). No se tocó ningún CSS, así que no hizo falta
  subir el `?v=` de `estilos.css`.

## Lote de envío (el enlace "Accede aquí" del correo)

- **Qué es**: el registro de las licitaciones que SECOPIO le descubrió a un cliente en UN correo.
  Es un **archivo histórico**: no se actualiza con las corridas siguientes, no muestra licitaciones
  nuevas y **nunca se borra**, así que el cliente puede abrirlo meses o años después aunque su
  cuenta ya no esté activa. NO es el panel que se cargaba en producción (ese se borró con `/panel`).
- **Cada fila de `envios` ES un lote**: por eso no hizo falta crear `lotes_envio`. El token se
  genera al crear el envío (`bin2hex(random_bytes(32))`, 64 hex) y **nunca se cambia**:
  migración `2026_09_29_000005` (columna `envios.token`, índice único, con backfill).
- **Ruta pública sin login**: `GET /envio/{token}` → `EnvioController@lote`, vista
  `envio/lote.blade.php` (reutiliza `public/css/panel-secopio.css`, que es el MISMO del origen
  byte a byte y llevaba meses sin uso). El token es la credencial, igual que en producción
  (`panel-secopio.html?lote=`); no se exige cuenta activa ni vigencia, porque el punto es que el
  registro sobreviva. Token con formato inválido (no 64 hex) o inexistente → 404 con
  `envio/token-invalido.blade.php`, y ambos casos se registran con `Log::warning` en el log.
- **Copia congelada**: `procesos` es una caché que el pipeline **sobrescribe en cada descarga**, así
  que el registro NO puede armarse con esa tabla (el precio/estado mostrado cambiaría). Por eso el
  pipeline copia cada proceso a `envio_proceso` al crear el envío (migración
  `2026_09_29_000006`, modelo `App\Models\envio_proceso`, método `guardarCopiaProceso()`), y tanto
  el correo como el registro leen esa copia a través del accessor
  `envio_detalle::datos_congelados` (con respaldo en `procesos` solo si la línea no tuviera copia,
  por envíos anteriores a la copia). **Probado**: se cambió el precio en `procesos` y el registro
  siguió mostrando el original.
- El botón "Accede aquí" y el texto bajo él usan `AlertasEncontradas::urlLote()` (pasa `urlLote` a
  la vista con `Content::with`); si un envío no tuviera token, cae a la portada en vez de generar un
  enlace roto.

## Pipeline de alertas

- Comando **`php artisan secopio:pipeline`** (`app/Console/Commands/SecopioPipeline.php`) y
  programado cada 30 min con `withoutOverlapping` en `app/Console/Kernel.php`. Fases:
  1. Empresas con vigencia vencida (pago más reciente) → `activo=false`.
  2. Descarga única SECOP II (`datos.gov.co/resource/p6dx-8zbt`, ventana 14 días, página 5000,
     $select acotado, reintentos×3) → upsert en `procesos`.
  3. Cruce por perfil (los mismos criterios del motor origen): SOLO clases y productos (clase →
     su código de 8 dígitos + productos hijos del catálogo `public/data/unspsc.json`); filtros
     adicionales en AND solo si el perfil los configuró (departamento/modalidad/tipo_contrato
     normalizados sin tildes, precio con `>0` obligatorio si hay rango); descripciones NO filtran;
     máx **22** procesos nuevos por perfil por corrida (`$limitePorEnvio`). Crea `envios` (con su
     token de lote) + `envio_detalle` + `envio_proceso` (copia congelada, ver "Lote de envío"),
     con dedup contra envíos anteriores.
  4. Envía correos con Mailable `AlertasEncontradas` (vista `emails/alertas-encontradas.blade.php`)
     a los correos de la empresa; marca el envío enviado/fallo. Aviso de vencimiento 3 días antes
     (`AvisoVencimiento`, una sola vez vía `aviso_vencimiento_enviado`).
- `--perfil=ID` limita el cruce a un perfil (pruebas). La descarga completa toma ~4 min (48.5k procesos).

## Flujo de registro / configuración

- `/` (form público) manda `empresa`+`correo`+`password`; el form Breeze manda `nombre`+`email`+
  `password`. `RegisteredUserController@store` valida (`correo required_without:email`), crea
  **empresa** nueva (plan basico, `activo=false` = esperando validación del admin, 3 perfiles,
  5 usuarios) + **usuario** (rol `Cliente`). Si el registrante tiene rol administrador/super-admin
  NO se roba su sesión y redirige a `/registro-exitoso` (flash `registro_es_admin` + `cliente_nuevo`
  → botón "Configurar filtros del nuevo cliente"); si es anónimo o tiene
  sesión de cliente, hace login con el cliente nuevo y va directo a `/configurar-filtros`
  (configuración inicial con `volver=/registro-exitoso`; al guardar aterriza en el aviso
  de cuenta esperando validación, NO en un panel; redirect si ya tiene perfil → `?editar=perfilId`).
  **EXCEPCIÓN (2026-10-08)**: si se unió a una empresa que ya tiene filtros, va directo a
  `/registro-exitoso` con su copia — ver "Filtros compartidos por empresa (2026-10-08)".
- Formulario de filtros: GET `?editar=` carga via `/configurar-filtros/datos`; POST JSON con
  `X-CSRF-TOKEN`. `FiltrosController@guardar` exige ≥1 clase o producto; departamentos llegan por
  NOMBRE y se resuelven a `codigo_divipola`; modalidades/tipos en CSV; descripciones →
  `perfil_keyword` (tipo 'incluye', campo 'descripcion', peso 1); respuesta JSON con `redirect`
  (admin → detalle de cliente, cliente → `volver` o la portada `/`).
- **Volver tras guardar filtros** (2026-09-29): los botones "Editar filtros" (detalle de cliente y
  registro-exitoso) pasan `?volver=<url de la página donde se pulsó>`; `FiltrosController`
  lo reenvía en sus redirects, el JS lo manda en el POST (`datosFiltros.volver`) y `guardar()` lo
  devuelve como `redirect`, así el usuario regresa a donde estaba en vez de caer en la portada.
  `urlSeguraInterna()` solo acepta la URL base exacta (o base + `/`) y rutas que empiezan por `/`
  sin `//`: un `volver` externo o con prefijo engañoso se ignora y cae al destino por rol.
- **Listados por usuario, no por empresa** (2026-09-29): dashboard y `/admin/clientes` muestran
  **una fila por usuario** (`FilasPorUsuario` trait: aplana empresas → filas `{empresa, usuario}`),
  porque cada usuario tiene sus propios perfiles de búsqueda, envíos y cuenta. Los datos de la
  empresa (plan, vigencia, vence) se repiten en cada fila; el estado del `td` es el de la **cuenta**
  del usuario y el de la empresa va como pastilla junto al nombre (solo para el super admin).
- **Estados del panel: `Pendiente` ≠ `Bloqueada`** (2026-09-30, pedido del dueño). El problema real
  era que `empresa.activo = 0` significa **dos cosas distintas** a la vez (recién registrada esperando
  validación vs. bloqueada a mano) y ambas se pintaban como "Bloqueada"; además el usuario nace con
   `usuarios.activo = 1` (históricamente para que `LoginRequest` lo dejara entrar a configurar
   sus filtros; desde 2026-10-08 los `Cliente` ya no inician sesión por login en absoluto), así que
  la columna de cuenta decía "Activo" desde el primer segundo. **Se separó SIN tocar la BD**, porque
  al activar (`ClienteController@cambiarEstado`) se crea el `tenant_has_plan` + su `pagos`, y una
  empresa que solo se registró nunca tuvo ninguno:
  - `empresa::estadoActual()` → `administrativa` | `activa` | **`pendiente`** (activo=0 **sin**
    `tenant_has_plan`) | **`vencida`** (activo=0, ya tuvo plan y el `fecha_vencimiento` del último pago
    pasó: la apagó el pipeline sola, fase 1) | **`bloqueada`** (activo=0 con vigencia todavía viva =
    el admin la apagó a mano). Cada estado tiene su etiqueta y clase en `empresa::ESTADOS_EMPRESA`.
  - `usuarios::estadoActual(?empresa $empresa = null)` deriva la CUENTA de la empresa: pendiente →
    `pendiente`, vencida → `vencido`, bloqueada → `bloqueado`; y solo si la empresa está activa manda
    el `usuarios.activo` (bloqueo manual de la cuenta). Se le puede pasar la empresa ya cargada para
    no hacer N+1 en el listado.
  - `empresa` recibió el cast que le faltaba: `activo => boolean`.
  - Se añadió `.estado-vencido` al CSS (ya existía la clase pero **no la usaba ninguna vista**) y se
    subió el `?v=` de `css/estilos.css` a `20260930a` en dashboard/clientes-index/clientes-show.
  - La columna Vigencia dice "Sin asignar" en vez de `basico` cuando la empresa está pendiente, y en
    el detalle el estado lleva su explicación ("Esperando que actives la empresa" / "Se desactivó
    sola: la vigencia venció el dd/mm/aaaa" / "Bloqueada a mano por el administrador").
  - Los botones Activar/Bloquear de `show.blade.php` siguen usando el `activo` real, porque bloquear
    una cuenta **siempre** es una acción manual del admin.
- **El botón de la cuenta debe coincidir con la pastilla** (2026-09-30, observación del dueño): si la
  pastilla dice Pendiente/Bloqueado el botón dice **Activar**, y si dice Activo dice **Bloquear**. Antes
  el botón usaba `usuarios.activo` (crudo) y la pastilla `estadoCuenta()` (derivado), así que una
  cuenta **pendiente** —que nace con `usuarios.activo=1` para poder entrar a configurar sus filtros—
  pintaba "Pendiente" y al lado ofrecía "Bloquear", que es absurdo: no hay nada activo que bloquear.
  Ahora ambos leen `$estadoUsuario = $usuarioEmpresa->estadoCuenta($cliente)` y el botón se deriva de
  `$cuentaActiva = $estadoUsuario['etiqueta'] === 'Activo'` (show.blade.php, una sola fuente de verdad).
- **Ese "Activar" de una cuenta pendiente es un clic muerto, y se deshabilita** (mismo cambio). La
  cuenta ya tiene `activo=1`: lo que la tiene pendiente es la **empresa** (que no tiene
  `tenant_has_plan`). `usuarios::estadoActual()` hace que la empresa mande, así que pulsar Activar no
  cambiaría nada. Por eso el botón lleva `disabled` + `title` explicativo cuando
  `!in_array($cliente->estadoActual(), ['activa','administrativa'])` (pendiente, vencida o bloqueada):
  la palanca real es el **Activar/Bloquear de la empresa**, arriba en la misma vista. El backend
  `ClienteController@cambiarEstadoUsuario` repite la comprobación y devuelve flash `error`
  ("Activa o desbloquea la empresa primero") en vez de un "éxito" falso, por si alguien manda el POST
  a mano. Verificado en los 6 estados: pendiente→Activar(deshabilitado), activa+activo→Bloquear,
  activa+bloqueada→Activar, vencida→Activar(deshabilitado), bloqueada→Activar(deshabilitado).

## Filtros compartidos por empresa (2026-10-08, decisión del dueño)

- **Regla**: los filtros de búsqueda son de la **EMPRESA**, no de cada usuario, y **solo los edita un
  administrador** (el `Administrador Cliente` de esa empresa o el super admin). Comparten TODOS los
  bloques: UNSPSC, precio, departamento, modalidad, tipo de contrato y descripciones.
- **Modelo: copia por usuario sincronizada** (NO una fila única por empresa): cada usuario sigue con
  su propia fila en `perfiles_busqueda` para no romper el pipeline (un envío por perfil) ni el correo
  (1 correo por perfil), pero las copias son espejo de la referencia.
- **Referencia**: `empresa::perfilReferencia()` = primer perfil (menor `user_id`) del usuario con rol
  `Administrador Cliente`; si no lo hay, el primer perfil de la empresa por id. `show.blade.php` y la
  edición (`?editar=`) apuntan SIEMPRE a la referencia, nunca al perfil de un sub-usuario.
- **Propagación**: `perfiles_busqueda::copiarDesde()`, `crearCopiaPara($empresa, $user)` y
  `sincronizarPerfilesDeEmpresa($empresa, $perfil)` — esta última corre dentro de la transacción de
  `FiltrosController@guardar`, así que **al guardar, todos los usuarios de la empresa quedan con la
  copia idéntica** (pivots UNSPSC/keywords/departamentos incluidos). Altas desde el panel
  (`ClienteController@crearUsuario`) también reciben su copia.
- **Puertas en `FiltrosController`** (`puedeEditarFiltros($user, $empresa)`: super admin/administrador
  → siempre; `Administrador Cliente` → solo su `tenant_id`). Un usuario con rol `Cliente`:
  - GET `/configurar-filtros` (con o sin `?editar=`) → **302 a la portada `/`** con flash `error`
    (no 403, para no enseñarle pantalla de error);
  - GET `/configurar-filtros/datos?perfil=` → **403**;
  - POST `guardar` → **403** `{"ok":false,"mensaje":"No autorizado."}` (el `?editar=` y el perfil de
    referencia se comprueban ANTES de tocar nada).
- **Los clientes NO inician sesión en la plataforma (2026-10-08, pedido del dueño)**:
  `LoginRequest::authenticate()` valida la contraseña y, si el usuario no tiene ninguno de los
  roles de `RolAdmin::ROLES` (los mismos del panel), cierra la sesión y devuelve
  `ValidationException` sobre `email` — `auth/login.blade.php` lo pinta en su `.mensaje-admin` —
  con el texto **"Eres cliente de búsqueda: en la plataforma solo inician sesión los
  administradores. Contacta a tu administrador de empresa."**: el usuario queda en `/login`,
  sin sesión y sin redirigirlo a ningún lado. Matices: con clave **incorrecta** sale el fallo
  normal `auth.failed` (el rol no se filtra a desconocidos: el chequeo va DESPUÉS de validar la
  clave); roles sin administración (hoy no existe ninguno) ven "Tu cuenta no tiene permisos de
  administración…"; un admin `inactivo` sigue viendo el mensaje de cuenta inactiva. Verificado
  con usuarios temporales de prueba (Cliente correcto → bloqueado con sesión sin iniciar;
  Cliente con clave mala → `auth.failed`; `administrador` → `/admin/dashboard` 200) y borrados.
  **Siguen existiendo dos caminos con sesión de Cliente**: (1) el auto-login que hace
  `RegisteredUserController@store` al registrarse (el flujo de configuración con `?token=` en
  realidad no lo necesita, funciona sin sesión) y (2) sesiones abiertas ANTES de este cambio;
  contra ambas siguen valiendo las puertas de `FiltrosController` de arriba. Si el dueño quiere
  eliminar también el auto-login del registro, es un cambio aparte y de una línea.
- **Token**: si hay token válido y la empresa YA tiene referencia, `index()` redirige a
  `/registro-exitoso` (no hay nada que configurar: lo hace el admin). El token solo abre la
  **primera** configuración de una empresa sin filtros (el resto del flujo de token no cambió).
- **Registro**: si el nombre coincide con una empresa que ya tiene referencia → usuario nuevo con su
  copia al instante y a `/registro-exitoso` (esto **rompe la regla "todo registro va a
  `cliente.filtros`" del 2026-09-30**, solo para este caso); si no tiene referencia → igual que
  antes (token + `/configurar-filtros`). El admin que registre con sesión abierta conserva su sesión.
- **Aviso en el detalle del cliente**: párrafo `.nota-acciones` dentro del `admin-footer` de
  `show.blade.php` ("Los filtros aplican a toda la empresa…"). Reutiliza la clase existente:
  **no hubo cambio de CSS ni de `?v=`**.
- **Pipeline y correos SIN cambios** (decisión explícita): sigue habiendo 1 envío por perfil y 1
  correo por perfil. Se aceptó a sabiendas que, con las copias, un mismo usuario pueda recibir
  varios correos en una misma corrida (uno por cada perfil-copia suyo).
- **Verificado por HTTP** (PHP curl con cookie jars, `loginUsingId` vía rutas temporales ya
  borradas): invitado → 302 `/login`; super admin y Administrador Cliente → 200 en la edición;
  Cliente → 302 `/` + 403 en `datos` y en `guardar` (la referencia quedó intacta tras el intento);
  guardado del admin → copias idénticas creadas (empresa 3: usuarios 14 y 72, antes sin perfil);
  registro uniéndose a empresa CON filtros → `/registro-exitoso` + copia; registro de empresa nueva
  → `?token=` y el enlace abre el formulario. Datos de prueba borrados después (estado final:
  4 empresas, 7 usuarios, 4 perfiles, 0 temporales).
- **Pendiente de datos**: los sub-usuarios que ya existían **sin perfil** (empresa 3: usuarios 14 y
  72) reciben su copia en el **próximo guardado** de su admin; no se crearon perfiles retroactivos
  con un script (si el dueño los quiere ya, hay que correr `sincronizarPerfilesDeEmpresa` por cada
  empresa).
- Huelga: `model_has_roles` tiene **1 fila huérfana** (`model_id=24`) de los merges viejos de
  "JL DESARROLLO" (usuario ya borrado). Es inofensiva (Spatie consulta por `model_id`+`model_type`)
  y **no se tocó**; si algún día molesta, borrarla a mano con respaldo.
- **Destino tras guardar/Volver del Administrador Cliente (2026-10-08, bug corregido)**: el dueño
  guardó con `cliente.prueba` (rol `Administrador Cliente`, empresa 3) en `/configurar-filtros?editar=1`
  **sin `?volver=`** y aterrizó en la portada `/` en vez de `/admin/clientes/3`. Causa: en
  `FiltrosController` el flag de destino se calculaba con `esAdministradorGlobal()`, que **no**
  incluye `Administrador Cliente`, así que `destinoGuardado()`/`destinoVolver()` caían al `url('/')`.
  Se añadió **`esAdminPanel()`** (rol de `RolAdmin::ROLES` + `Administrador`) y se usa **solo** para
  destinos: `volver` de la vista (`index`) y el `redirect` del POST (`guardar`,
  `$adminDestino = !$usuarioToken && esAdminPanel(...)`). **`esAdministradorGlobal()` se mantiene**
  para los permisos (puede editar CUALQUIER empresa) porque ampliarlo dejaría a un `Administrador
  Cliente` guardar en otra empresa por `?empresa=`/`?editar=`. Verificado por HTTP con `loginUsingId`
  temporal (borrado): user 4 → botón Volver y guardado SIN `volver` = `/admin/clientes/3`, con
  `?volver=` se respeta, super admin sin cambios, y los filtros de la empresa 3 quedaron idénticos
  (round-trip con `datos?perfil=1`).
- **Se quitó el botón "Editar filtros" del detalle (2026-10-08, pedido del dueño)**: en
  `admin/clientes/show.blade.php` ya **no está** el `<a>` que iba a `admin.filtros.edit`; el
  `admin-footer` queda con la nota `.nota-acciones` + "Volver al panel". La edición sigue disponible
  entrando a `/configurar-filtros` (el `Administrador Cliente` cae en el `?editar=` de su perfil de
  referencia) o a `/configurar-filtros?empresa=ID` (super admin). La ruta `admin.filtros.edit`
  (`GET /admin/clientes/{id}/filtros`) **se mantiene viva** aunque ya no la enlace ninguna vista.


## Cascada UNSPSC (segmento → familia → clase → producto)

- **Al borrar un nivel se borra EN CASCADA solo lo que cuelga de él** (2026-09-29, bug corregido):
  `eliminarSegmento` usaba una variable `segmentoEliminado` que no existía → `ReferenceError` a media
  función, así que la limpieza nunca terminaba y los selects de clase/producto quedaban **habilitados
  con opciones y selección de un segmento que ya no estaba** (justo lo que permite meter códigos de
  segmentos que no se habían elegido). Además, antes de fallar, vaciaba **todas** las familias/clases/
  productos, borrando lo de los otros segmentos.
- Ahora los tres eliminadores (`segmentos.js`, `familias.js`, `clases.js`) usan helpers de
  `configurar-filtros.js`: `codigosNivel(nivel, padres)` (hijos en el catálogo), `quitarSeleccionados()`,
  `sincronizarCascada()` (repinta las 4 listas, recarga las opciones y deja los selects limpios) y
  `actualizarBotonAgregarSegmento()` (bloquea "Agregar" al llegar a 3 y avisa en el `title`).
- **El servidor también valida** (`FiltrosController::sanearCascadaUnSpsc`, llamado al principio de
  `guardar()`, o sea también en el PUT del admin): máximo **3 segmentos** por perfil (si no, 422 con
  "Solo puedes elegir hasta 3 segmentos por colegio."), códigos de 8 dígitos, sin duplicados, y que
  cada familia pertenezca a un segmento elegido, cada clase a una familia elegida y cada producto a
  una clase elegida. Lo que no encaja **se descarta y se avisa** (`mensaje` en la respuesta, el JS lo
  muestra con `alert`), no se guarda → un perfil nunca queda con códigos de un segmento que no eligió.
  La pertenencia se comprueba **por prefijo** (2 dígitos familia⊂segmento, 4 clase⊂familia, 6
  producto⊂clase), regla verificada en las 49.022 filas de `public/data/unspsc.json`; así no se carga
  el JSON de 15 MB en cada guardado. Datos revisados en BD: 4 perfiles, ninguno huérfano.
- **"aparece en blanco" al borrar un segmento** (2026-09-29, era culpa mía): `limpiarSelectBuscable()`
  (en `unspsc-buscables.js`) hacía `tom.clearOptions()` + `addOption({value:""})`, o sea **borraba las
  opciones del desplegable**, no solo la selección. Como `sincronizarCascada()` la llamaba sobre los
  cuatro selects (incluso los que acababa de repoblar con `llenar*`), y también sobre el de segmentos,
  el buscador quedaba con una sola opción vacía: no se veía nada y **no había forma de elegir otro
  segmento** (justo el paso siguiente del usuario). Ahora `limpiarSelectBuscable()` solo hace
  `select.value = ""` + `tom.clear(true)` + `syncSelectBuscable()` (recarga la lista desde el select
  nativo), y `sincronizarCascada()` solo lo llama sobre el **buscador de segmentos**.
  Aquí importa el stub de `window[id]`: los JS usan `btnAgregarSegmento`/`listaSegmentos` como
  globales que el navegador crea desde el `id` del HTML; no están declarados en ningún archivo.
- Prueba de la cascada: arnés en Node (`vm` + stubs de DOM) que carga los 5 JS, simula la eliminación y
  comprueba 4 escenarios (borrar segmento/familia/clase y quedarse sin segmentos). Vive fuera del
  proyecto, en `%TEMP%\opencode`.
  El segundo arnés (`cascada2.js`) mete además un **stub de Tom Select** que lleva la cuenta de sus
  opciones: es el único que detecta el "desplegable en blanco" (el primero lo testea todo ciego).

## Redirecciones (2026-09-23)

- `RouteServiceProvider::homeAutenticado()`: roles admin → `/admin/dashboard`; `Cliente` y resto →
  `/` (no hay panel de usuario; antes `Cliente` → `/panel`).
- Login/logout/registro/verificación usan `homeAutenticado()`; registrarse → `cliente.filtros`.

## Convenciones

- Todo el código, comentarios y textos de UI en **español**.
- Modelos con namespace NetBeans en minúsculas (`App\Models\empresa`, `App\Models\usuarios`,
  `perfiles_busqueda`, `envios`, `envio_detalle`, `procesos`, `cat_*`, `tenant_has_plan`, `pagos`).
- Verificación rápida: `& $php artisan route:list --except-vendor` y `& $php artisan secopio:pipeline`.
- Para cambios de BD usar scripts PHP temporales (nunca `mysql -e` con escapes complejos).

## Pendiente

1. **Precio: solo máximo (diferencia REAL con producción, 2026-09-29)**. El origen filtra por
   precio siempre que el cliente tenga `precio` guardado, aunque sea solo el máximo (min = 0:
   exige `>0` y `<= max`). Laravel usa `$tieneRangoPrecio = $precioMin !== null`, así que un
   perfil con SOLO `precio_max` **no filtra nada** y le llegan alertas por encima del máximo. El
   formulario lo permite (`validarRangoPrecio` en `filtros-adicionales.js` solo compara min vs max
   si hay ambos). Corrección propuesta: `$tieneRangoPrecio = $precioMin !== null || $precioMax !== null;`
   en `cruzarUnPerfil()`. **No aplicado: el usuario pidió no tocar reglas de producción sin confirmarlas.**
2. **Agrupación del correo**: producción mandaba 1 correo por cliente con todas sus alertas
   pendientes de cualquier corrida (máx 22); Laravel manda 1 correo por **perfil y por corrida**,
   así que con 3 perfiles activos pueden llegar 3 correos en la misma pasada. Es coherente con el
   modelo nuevo (cada usuario tiene sus filtros), pero es un cambio real de comportamiento.
3. **Migrar clientes reales del origen** (ids 14,19,23,44, etc.) → empresa+usuario+perfil con sus
   filtros y vigencias (se hizo solo el admin id 7: hash de password copiado a usuario 1).
4. Decidir si eliminar vistas Breeze huérfanas (`dashboard`, `welcome`).
5. Poner el cron de Windows (Task Scheduler o Laragon) para `php artisan schedule:run` cada N minutos.
6. `css/panel-secopio.css` **ya no está sin uso**: lo usa `envio/lote.blade.php`.

## Archivos tocados el 2026-09-28 (funcionalidad + pipeline)

Controllers: `FiltrosController` (reescrito), `Admin\ClienteController`
(+cambiarEstado), `Auth\RegisteredUserController` (multitenant + registro con sesión de admin
conservada y `activo=false`). `routes/web.php` reescrito + `routes/auth.php` (`POST /register`
fuera de `guest`).
Vistas: `publico/{index,configurar-filtros}.blade.php`,
`admin/clientes/{index,show}.blade.php`, `emails/{alertas-encontradas,aviso-vencimiento}.blade.php`.
JS: `public/js/secopio/configurar-filtros.js` (endpoints Laravel + CSRF). Modelos: `procesos`
(timestamps false), `envios`/`pagos` (casts), `empresa` (fillable). Pipeline:
`app/Console/Commands/SecopioPipeline.php` + `app/Mail/{AlertasEncontradas,AvisoVencimiento}.php` +
`app/Console/Kernel.php` (schedule). Seeders: `CatalogosSeeder`, `PlanSeeder`. Migraciones:
`2026_09_28_000001 cat_modalidades_tipos_contrato` · `000002 aviso_vencimiento_enviado` ·
`000003 precio_base double(20,2)`.


## Geometría de "Clasificación UNSPSC" (2026-09-30, aplicado y MEDIDO en Chrome)

- **Lo que pidió el dueño**: solo la alineación. Título, selector y fila agregada deben empezar en la
  misma vertical, y los botones "Agregar" y "Eliminar" en la misma. Nada de JS, IDs, clases, HTML,
  colores ni diseño. Ejemplo que señaló: `11000000 — Material Mineral, Textil y Vegetal y Animal No
  Comestible` + `Eliminar`.
- **Estructura real** (`resources/views/publico/configurar-filtros.blade.php:130-233`): dentro de
  `.seccion-unspsc` se alternan 4 `<div>` **sin id** (label + select + "Agregar") y 4
  `<div id="listaSegmentos|Familias|Clases|Productos">` (las filas).
- **Las 3 reglas queCauses del desalineo** (esta era la pregunta del dueño: NO era el `width: 94%`
  solo, sino esto):
  1. **Los contenedores de lista NO tenían padding lateral.** El bloque de arriba sí:
     `.seccion-unspsc > div:not([id]) { padding: 14px }` (estilos.css L1738). Las filas, al ser
     hijas de un contenedor sin padding, se pegaban al borde de la sección: **28px más anchas** y
     corrida 10px hacia la derecha. **Esta era la causa raíz.**
  2. **`width: 94%` + `margin: 6px auto 0`** en `.elemento-agregado` (L339-341): un parche que
     encogía y centraba la fila 3% por lado, o sea la dejaba más corta que el bloque de arriba.
  3. **`width: calc(100% - 105px)`** en `.seccion-unspsc select` (L145) y `.ts-wrapper` (L167): el
     ancho del botón "restado a mano" era 105px pero el botón real mide **81px** (Chrome lo midió:
     `L=763.88 R=845`), así que quedaban **~20px muertos** a la derecha y el borde derecho de
     "Agregar" nunca era el borde del bloque. Por eso el "Eliminar" no se podía alinear con ningún
     valor estático.
- **Lo que se cambió** (solo `public/css/estilos.css`, 4 sitios):
  1. `#listaSegmentos, #listaFamilias, #listaClases, #listaProductos { padding: 0 14px; }` → los
     cuatro niveles reciben el mismo inset que la tarjeta (14px). Con esto la fila arranca donde
     arranca el selector y termina donde termina el grupo selector+botón.
  2. `.elemento-agregado`: fuera `width: 94%` y `margin: 6px auto 0`; queda `margin-top: 6px` y
     `padding: 7px 11px 7px 12px`. El **11px** a la derecha es `12px − 1px`: la fila tiene borde de
     1px y la tarjeta de arriba NO, así que con 12px el "Eliminar" caía 1px a la izquierda.
  3. El bloque de buscador pasa a flex (`.seccion-unspsc > div:not([id])`): `display: flex;
     flex-wrap: wrap; align-items: center; gap: 8px`, el `label` con `flex: 0 0 100%`, el select y el
     `.ts-wrapper` con `flex: 1 1 auto; width: auto; min-width: 0` y el botón con `flex: 0 0 auto;
     margin-right: 12px`. **Elimina el `105px` a mano**: el selector toma lo que deja el botón, sin
     depender del tamaño de la fuente. El `margin-right: 12px` es el mismo valor que el padding
     lateral de la fila, y por eso los dos botones caen en la misma vertical.
  4. `@media (max-width: 700px)` nuevo: `gap: 0` y `margin-right: 0` en el botón, porque ahí los
     selectores y el botón se apilan al 100% (si no, el margen empujaba la página de lado).
- **Cómo se verificó (sin suposiciones)**: Chrome headless con `--dump-dom` sobre una copia del
  HTML real de los 4 bloques + el CSS real + el vendor real de Tom Select, midiendo con
  `getBoundingClientRect()`. Resultado (idéntico en Segmento, Familia, Clase y Producto):
  `titulo.L − combo.L = 0.00` · `combo.L − fila.L = 0.00` (antes −10.06) ·
  `agregar.R − eliminar.R = 0.00` (antes 3.58). Repitido a 1400/1000/760px. En móvil (600 y 420px)
  da 0.00 en los tres primeros y el botón "Agregar" sigue al 100% por el diseño apilado de
  siempre; `scrollWidth == clientWidth`, o sea **sin desbordamiento horizontal**.
- **Por qué el `<select>` nativo no estorba al flex**: Tom Select le pone
  `class="tomselected ts-hidden-accessible"`, y ese `.ts-hidden-accessible` del vendor lleva
  `position: absolute !important` (vendor L401-412) → sale del flujo y **no** es flex-item. Si algún
  día se toca ese vendor, hay que volver a medir.
- **OJO, Tom Select reescribe el `<label>`**: al inicializarse cambia `for="segmentoUnspsc"` por
  `for="segmentoUnspsc-ts-control"` y añade `id="segmentoUnspsc-ts-label"`. Por eso
  `document.querySelector('label[for="segmentoUnspsc"]')` devuelve `null` en la página real (no es
  un bug del HTML). Para medir hay que subir por `getElementById(id).parentNode`.
- **Cache-buster**: `estilos.css?v=20261001b` en las 11 vistas (el valor sube con cada tanda; el de
  esta sección quedó desactualizado después por la de "Descripcion del proceso", más abajo).
  `configurar-filtros.css` sigue en `?v=20260930b` (no se tocó).
- **Lo que NO se tocó**: HTML, IDs, clases, los 5 JS de `public/js/secopio/` (siguen en
  `?v=20260929c`, sin `ajustarTextosDeListas`), `#listaDescripciones` (tiene su propia regla
  `.elemento-agregado { padding: 10px 14px }` que gana por ID), colores, tipografías, sombras, el
  `vendor/tom-select/` (412 líneas, sin modificar) y las reglas de botón "Agregar"/"Eliminar" que ya
  se Likhad puesto iguales en la tanda anterior.
- **Regla del proyecto (muy importante)**: NO volver a inventar márgenes para disimular un
  desalineo. Primero medir en Chrome headless, luego corregir la regla que lo causa. Un `-replace`
  con `(?s)` y `.*?` sobre CSS se ha comido reglas dos veces; editar con bloques completos.

## Geometría de "Descripcion del proceso" (2026-10-01, aplicado y MEDIDO en Chrome)

- **Lo que pidió el dueño**: que el botón **"Eliminar" quede exactamente alineado con el "Agregar"**,
  que la fila tenga **el mismo borde izquierdo que el campo** y **el mismo borde derecho que el
  botón "Agregar"**, y que la fila ocupe todo el ancho del bloque. Prohibido: tocar HTML, JS, IDs,
  clases, colores, tipografías, el botón "Guardar filtros", UNSPSC, usar `width: 94%`, `max-width` o
  márgenes negativos. Referencia visual: `public/assets/Captura de pantalla 2026-10-01 093949.png`.
- **Medición inicial (Chrome headless, CSS real + markup real)**: la fila y el grupo de arriba ya
  medían lo mismo (329→1053), así que el problema NO era el ancho de la fila sino dos reglas:
  - `agregar.R − eliminar.R = 15.00` (el "Eliminar" 15px a la izquierda del "Agregar")
  - `campo.H − agregar.H = −2.00` (input 42px vs botón 44px)
- **Causa raíz**: `#listaDescripciones .elemento-agregado` (L518) traía `padding: 10px 14px` + `border: 1px`,
  o sea su borde derecho de **contenido** quedaba 15px dentro del borde de la fila, mientras que
  `.grupo-descripcion` es `display:flex` **sin padding** y su "Agregar" quedaba pegado al borde.
- **Lo que se cambió (solo `public/css/estilos.css`, 2 sitios)**:
  1. `#listaDescripciones .elemento-agregado { padding: 10px 0 10px 14px; }` — el **padding derecho
     pasa a 0**, así el "Eliminar" queda pegado al extremo derecho de la fila. El izquierdo (14px) NO
     se toca: es el respiro del texto frente a la franja roja de 4px, no un desalineo. Es una regla
     **con ID**, o sea no afecta `.elemento-agregado` de UNSPSC.
  2. `.grupo-descripcion input { height: 44px; }` (bloque tardío) — el botón ya medía 44px por su
     `min-height: 44px`, así que el campo se veía 2px más bajo. Solo el alto.
- **Medición final (Chrome headless, 1400 / 1000 / 760 px, idéntico en las tres)**:
  - `campo.L − fila.L = 0.00` (borde izquierdo igual) ✔
  - `agregar.R − fila.R = 0.00` (borde derecho de la fila = borde derecho del "Agregar") ✔
  - `campo.H − agregar.H = 0.00` ✔ · `campo.R → agregar.L = 10` (el `gap` intacto) ✔
  - `scrollWidth == clientWidth` (sin desbordamiento) ✔
  - `agregar.R − eliminar.R = 1.00` ← **el único 1px que queda**: es el `border-right: 1px` de la
    propia fila, que ocupa 1px **dentro** de su caja. Ponerlo en `0.00` exigiría borrar ese borde
    (`border-right: 0`, cambio de diseño) o `margin-right: -1px` (compensación prohibida). Se dejó
    el borde: el rojo del "Eliminar" termina justo donde empieza la línea gris del borde, que a su
    vez termina exactamente donde termina el "Agregar". **Si el dueño lo quiere en 0.00 exacto,
    quitar `border-right` de la fila es el único camino.**
- **Móvil (600 / 420 px)**: los bloques se apilan y dan los mismos valores; sin overflow. Se corrigió
  de paso un bug **preexistente**: `.grupo-descripcion input { flex: 1 }` en columna se convierte en
  `flex-basis: 0` y **aplastaba el campo a 18px de alto** (comprobado revirtiendo los cambios con un
  `<style>` de override). En `@media (max-width: 700px)` se le pone `flex: none` y vuelve a sus 44px.
- **Botones "Agregar" y "Eliminar" como hermanos** (2026-10-01, misma tanda): el dueño pidió que el
  "Agregar" de esta sección se vea **exactamente igual** al "Eliminar" y que **lo único que
  diferente sea el color**. Se resolvió **partiendo el bloque en dos**, no copiando valores a mano:
  - **Bloque de FORMA** (L585) con tres selectores: `.seccion-unspsc .btn-eliminar`,
    `#listaDescripciones .btn-eliminar` y **`#btnAgregarDescripcion`**. SIN `background`. Ahí viven
    `padding: 0 12px`, `min-height: 44px`, `border-radius: 10px`, `color: #fff`, `font-size: 14px`,
    `font-weight: bold`, `line-height: 1`, `white-space: nowrap`, `box-shadow: 0 5px 12px …` y el
    `flex: 0 0 auto`.
  - **Bloque de COLOR** justo debajo: `.seccion-unspsc .btn-eliminar, #listaDescripciones
    .btn-eliminar { background: var(--rojo) }` y `#btnAgregarDescripcion { background:
    linear-gradient(135deg, var(--secopio-navy), var(--secopio-blue)) }`.
  - Un `#btnAgregarDescripcion:hover { background: #0d315b }` propio, **obligatorio**: el azul base
    es un ID (1,0,0) y le gana a `.grupo-descripcion button:hover` (0,2,1), así que sin esa regla el
    hover se habría perdido. El "Eliminar" entra en el bloque `:hover` compartido (`translateY(-2px)`,
    sombra y `saturate`).
  - Se seleccionó por **ID**, sin tocar el HTML: `btnAgregarDescripcion` no tiene clase, y agregar
    `class="btn-agregar"` habría sido modificar la plantilla.
- **Diferencias reales que se corrigieron** (medidas con `getComputedStyle`, no a ojo): el "Agregar"
  traía `padding: 0 22px` (vs `0 12px`), `font-size: 13.3333px` —el default de Chrome para
  controles de formulario, porque ninguna regla le fijaba tamaño— (vs `14px`), `font-weight: 600`
  (vs `700`) y `line-height: normal` (vs `14px`). El alto (44), el radio (10), la sombra, la fuente
  (Arial) y el color del texto (`#fff`) ya coincidían.
- **Ancho**: con la forma compartida quedan en **77.69px ("Agregar") vs 79.25px ("Eliminar")**, o sea
  **1.56px**, que es solo la diferencia de una letra entre "Agregar" y "Eliminar". **NO se puso
  `min-width`**: sería inventar un número mágico para tapar la longitud del texto, y el mismo
  criterio del proyecto de no inventar valores. Los bordes derechos siguen alineados.
- **Efecto secundario inevitable**: el campo mide ahora 636.31px (antes 618.88px), porque el
  `.grupo-descripcion` es flex y el campo es `flex: 1`: al angostarse el botón, el campo crece lo
  mismo. Los bordes **no** se mueven: `campo.L = fila.L = 329` y `agregar.R = fila.R = 1053`, y el
  `gap: 10px` sigue intacto. La alineación aprobada en la tanda anterior no se tocó.
- **Medición final (1400 / 1000 / 760 px)**: alto 44 = 44 · `agregar.R − eliminar.R = 1.00` (el
  `border-right` de la fila, sin cambios) · `campo.L − fila.L = 0.00` · `agregar.R − fila.R = 0.00` ·
  `campo.H − agregar.H = 0.00` · `scrollWidth == clientWidth`. En 600/420 px el "Agregar" ocupa el
  100% (los bloques se apilan), igual que antes.
- **UNSPSC intacto**: sus botones "Agregar" los sigue pintando `.seccion-unspsc button`
  (padding `11px 15px`, `font-size 13.33px`, 81.13px de ancho) y sus "Eliminar" el bloque de forma;
  no se metieron en el bloque compartido. Medido antes y después: **idénticos**.
- **Cache-buster**: `estilos.css?v=20261001b` en las 11 vistas.
- **Lo que NO se tocó**: HTML, IDs, clases, `.btn-eliminar` (mismos valores computados), los botones
  "Agregar" de UNSPSC, el campo, la fila, `.cfg-acciones`, "Guardar filtros", los 5 JS de
  `public/js/secopio/` (`?v=20260929c`), `configurar-filtros.css` y el vendor de Tom Select.