# ENVIEX · Sistema de gestión de guías — Etapas 0 a 5 (cimientos, cobertura, guías, planeación, viajes, app del operador, ventas y CFDI 4.0, cotizaciones, portal de clientes y sitio web)

Base del sistema nuevo: núcleo PHP compartido, API v1, base de datos `enviex`, login, roles y permisos, bitácora y back office con el tema ENVIEX sobre Material Dashboard 2 PRO.

**Criterio de cierre de la etapa:** un usuario entra, ve solo su menú y el servidor rechaza lo que su rol no permite. Se verifica con `php tests/criterio-etapa0.php <url>` (24 pruebas).

**Criterio de cierre de la etapa 1:** cualquier C.P. devuelve colonia, ruta y tarifa desde la base. Se verifica con `php tests/criterio-etapa1.php <url>` (49 pruebas: cobertura, cotización, tarifas por zona, reglas de catálogos, clientes y permisos).

**Criterio de cierre de la etapa 2:** una guía capturada o importada de Excel queda con folio, ruta y precio de la base, su etiqueta se imprime y su rastreo muestra el historial. Se verifica con `php tests/criterio-etapa2.php <url>` (44 pruebas: captura, importación del manifiesto del aliado y de la plantilla, estados en lote, edición, rastreo público, etiqueta y permisos).

**Criterio de cierre de la etapa 3:** la operación completa (alta de guía → planeación → viaje → entrega) queda funcionando. Se verifica con `php tests/criterio-etapa3.php <url>` (42 pruebas: pool por ruta, viajes todo o nada, orden de paradas, app del operador con inicio, GPS, entrega con foto y firma, intento fallido, cierre, reenvío sin duplicados, evidencias y permisos). Necesita la extensión GD de PHP para generar fotos de prueba.

**Criterio de cierre de la etapa 4:** una venta jala guías del cliente, se timbra su CFDI 4.0, se cobra con complemento de pago y una factura se cancela o se sustituye. Se verifica con `php tests/criterio-etapa4.php <url> [carpeta cfd del SAT]` (60 pruebas: CSD, reglas del receptor, PUE con cobro automático, PPD con dos complementos y parcialidades, cancelación de pagos y facturas, sustitución 01/04, factura global, PDF, XML y permisos). Si se le da la carpeta de los XSLT del SAT, además compara la cadena original y verifica los sellos. `php tests/cfdi-cadena.php <carpeta cfd>` prueba el motor CFDI sin servidor.

**Criterio de cierre de la etapa 5:** el sitio y el portal leen el mismo sistema: cobertura con etiqueta de entrega, cotización pública que cae en la bandeja y se convierte en guía, y portal del cliente con envíos, facturas, estado de cuenta, solicitud de factura y cotizaciones. Se verifica con `php tests/criterio-etapa5.php <url>` (73 pruebas, 9 de ellas contra los archivos del sitio; corre después de la etapa 4 y con `COTIZACIONES_POR_HORA=0` y `SITIO_SIN_LIMITES=1` en pruebas).

## Requisitos

- PHP **7.2 o superior** con `pdo_mysql`, `json` y `mbstring` (recomendado). Probado sin errores de compatibilidad con PHPCompatibility para 7.2+.
- MySQL 5.7+ o MariaDB 10.3+ (InnoDB, `utf8mb4`).
- Apache con `mod_rewrite` (o Nginx, ver abajo). Composer es opcional: hay autoload propio si no existe `vendor/`.

## Estructura

```
enviex/
  bootstrap.php        arranque común de todos los canales
  core/src/            núcleo: App, Db, Http, Auth (sesión, tokens, CSRF, Gate), Audit, Repositories, Services, Api
  api/index.php        API v1 → /api/v1/...
  backoffice/          pantallas del back office (Material Dashboard + tema ENVIEX)
  database/migrations  SQL versionado (001_identidad … 010_ventas)
  database/seeds       catálogo de permisos y roles base
  bin/enviex           consola: migrate, seed, cargar-cp, crear-admin, instalar, demo, csd-prueba
  core/src/Cfdi/       motor CFDI 4.0: comprobante y cadena original, CSD, PAC (Finkok o simulado), PDF
  core/lib/            FPDF, generador de QR y PHPMailer (sin Composer)
  core/src/Documentos/ PDF de cotizaciones
  portal/              portal de clientes (HTML + JS sin compilación; entra con token)
  pwa/                 app del operador
  web/sitio/           archivos para el sitio aprobado (app/www/web_new): mismos nombres, mismo diseño, conectados al sistema
  web/extras/          enviex-web.js, cliente JS de la API pública (para sitios en otro servidor)
  config/.env          configuración por ambiente (no se versiona)
  storage/             logs, evidencias, CSD (storage/csd) y PDF de los CFDI (storage/cfdi); no público
  tests/               prueba del criterio de cierre y router de desarrollo
```

`core/`, `config/`, `database/`, `storage/`, `bin/`, `tests/` y los archivos `backoffice/_*` están bloqueados por `.htaccess`.

## Instalación

```bash
# 1. Base de datos y usuario propio de la app (no usar root ni el usuario de Dolibarr)
mysql -u root -p -e "CREATE DATABASE enviex CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
  CREATE USER 'enviex_app'@'localhost' IDENTIFIED BY '<contraseña nueva>';
  GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER, INDEX, REFERENCES ON enviex.* TO 'enviex_app'@'localhost';"

# 2. Configuración
cp config/.env.example config/.env      # llenar DB_*, URL_* y SESSION_SECURE=true en HTTPS

# 3. Tablas, catálogo y primer administrador
php bin/enviex instalar                 # migrate + seed + cargar-cp + etiquetar-cp + crea el administrador (pide datos)

# 4. (Solo ambiente de prueba, APP_DEBUG=true) usuarios demo por rol con contraseña Enviex2026
php bin/enviex demo

# 5. Verificar el criterio de cierre
php tests/criterio-etapa0.php https://admin.enviex.com.mx
php tests/criterio-etapa1.php https://admin.enviex.com.mx
php tests/criterio-etapa2.php https://admin.enviex.com.mx
php tests/criterio-etapa3.php https://admin.enviex.com.mx
php tests/criterio-etapa4.php https://admin.enviex.com.mx   # solo con FINKOK_MODO=simulado o demo
php tests/criterio-etapa5.php https://admin.enviex.com.mx   # después de la 4; COTIZACIONES_POR_HORA=0
```

En una instalación que ya existía, después de `migrate` corre una vez `php bin/enviex etiquetar-cp` para cargar las listas de entrega diaria y de cotización especial.

`php bin/enviex migrate` y `php bin/enviex seed` se pueden correr en cada despliegue: solo aplican lo pendiente y no pisan los permisos que el administrador ya ajustó.

### Códigos postales

`seed` crea zonas (ZMG, Regional Jalisco, Servicio dedicado), las 19 rutas y sus localidades del mapa, y los servicios, tarifas y cargos de arranque. `cargar-cp` carga el catálogo incluido (`database/seeds/datos/codigos_postales.csv`: 8,547 colonias y 1,797 C.P. con su ruta, tomadas del sistema anterior). Para ampliar cobertura:

```bash
# Archivo de SEPOMEX (Correos de México, "CPdescarga.txt", formato con |)
php bin/enviex cargar-cp CPdescarga.txt --estado=Jalisco --por-municipio
```

`--por-municipio` da a las colonias nuevas la ruta más común de su municipio. Lo mismo se hace desde *Cobertura y rutas › Códigos postales › Importar*. Las colonias existentes nunca pierden su ruta al reimportar.

## Despliegue

**Apache:** el `DocumentRoot` del subdominio de administración apunta a la carpeta `enviex/`. La raíz redirige a `/backoffice/`; la API queda en `/api/v1/`. Si el hosting no tiene `mod_rewrite`, la API también responde en `/api/index.php?r=/v1/salud`.

**Nginx** (equivalente):

```nginx
root /var/www/enviex;
location ~ ^/(core|config|database|storage|bin|tests|vendor)/ { deny all; }
location ~ /_ { deny all; }
location ~ /\.(env|git) { deny all; }
location /api/ { try_files $uri /api/index.php?$query_string; }
location ~ \.php$ { include fastcgi_params; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; fastcgi_pass unix:/run/php/php7.2-fpm.sock; }
```

**Desarrollo local:** `php -S 127.0.0.1:8080 tests/dev-router.php` y abrir `http://127.0.0.1:8080/backoffice/`.

## Roles y permisos

Permisos con formato `modulo.accion` (36 en el catálogo). Roles base:

| Rol | Entra a | Alcance |
| --- | --- | --- |
| Administrador | Back office | Acceso total; su matriz no se edita |
| Supervisor de sucursal | Back office | Guías, planeación, viajes, clientes de sus sucursales |
| Capturista | Back office | Alta e importación de guías, cotizaciones |
| Facturación | Back office | Ventas, CFDI, pagos |
| Operador | App del operador | Solo `pwa.operar` |
| Cliente | Portal web | Solo `portal.consultar` |

El menú muestra cada opción solo si el rol tiene su permiso. La API valida el permiso en cada ruta aunque se llame directo. El rol *Cliente* solo entra al portal.

## Seguridad incluida

- PDO con sentencias preparadas; ningún SQL fuera de `core/`.
- Contraseñas con `password_hash` (bcrypt), rehash automático, mínimo 8 caracteres con letras y números; contraseña temporal obliga a cambiarla en el primer acceso.
- Bloqueo de 15 minutos tras 5 intentos fallidos; mensaje de error genérico.
- Sesión con cookie `HttpOnly` + `SameSite=Lax`, cierre por inactividad (60 min), regeneración del id al entrar; CSRF en todo formulario y llamada AJAX; cerrar sesión solo por POST.
- Tokens de la PWA/portal guardados como hash SHA-256, con caducidad y revocación (al desactivar un usuario o cambiar su rol).
- Filtro por sucursal aplicado en los repositorios.
- Bitácora de accesos, altas, cambios (antes/después) y permisos; las contraseñas nunca se registran.

## API v1

| Método y ruta | Permiso |
| --- | --- |
| `GET /salud` | público |
| `POST /auth/login` · `POST /auth/logout` · `GET /auth/me` | público / autenticado |
| `PUT /perfil/password` | autenticado |
| `GET /dashboard/resumen` | `dashboard.ver` |
| `GET·POST /usuarios` · `GET·PUT /usuarios/{id}` · `POST /usuarios/{id}/estatus` · `POST /usuarios/{id}/password` | `usuarios.*` |
| `GET·POST /roles` · `PUT·DELETE /roles/{id}` · `GET·PUT /roles/{id}/permisos` · `GET /permisos` | `roles.*` |
| `GET·POST /sucursales` · `PUT·DELETE /sucursales/{id}` | `sucursales.*` |
| `GET·PUT /empresa` | `empresa.gestionar` |
| `GET /bitacora` · `GET /bitacora/opciones` | `bitacora.ver` |
| `GET /notificaciones` · `GET /notificaciones/conteo` · `POST /notificaciones/{id}/leer` · `POST /notificaciones/leer-todas` | autenticado |
| `GET /publico/cobertura/{cp}` · `POST /publico/cotizar` | **público** (para el sitio web) |
| `GET /cobertura/consulta/{cp}` | `cobertura.ver`, `clientes.ver`, `guias.ver` o `cotizaciones.ver` |
| `POST /cotizar` | `catalogos.ver`, `cotizaciones.ver` o `guias.crear` |
| `GET /cobertura/resumen` · `/municipios` · `/mapa` | `cobertura.ver` |
| `POST /cobertura/asignar-ruta` · `POST /cobertura/importar` (multipart) | `cobertura.gestionar` |
| Catálogos genéricos `GET·POST /{recurso}` · `GET·PUT·DELETE /{recurso}/{id}`: `zonas`, `rutas`, `codigos-postales`, `servicios`, `tarifas`, `cargos`, `unidades`, `operadores`, `clientes` | `cobertura.*`, `catalogos.*` o `clientes.*` |
| Hijos `/rutas/{id}/localidades` · `/clientes/{id}/direcciones` · `/contactos` · `/datos-fiscales` | los del padre |

Los catálogos se declaran en `core/src/Catalogos/Recursos.php` (columnas, búsqueda, filtros, reglas, únicos, llaves foráneas, dependencias, alcance por sucursal); agregar uno nuevo es una entrada ahí más su migración.

### Guías (etapa 2)

| Método y ruta | Permiso |
| --- | --- |
| `GET /publico/rastreo/{folio o guía del aliado}` | **público** (sin teléfonos ni domicilio; nombre abreviado) |
| `GET /guias` (filtros `q`, `estatus`, `ruta_id`, `tipo`, `origen`, `desde`, `hasta`, `importacion_id`) · `GET /guias/conteos` · `GET /guias/opciones` | `guias.ver` |
| `GET /guias/{id}` · `GET /guias/buscar/{código}` | `guias.ver` |
| `POST /guias` | `guias.crear` |
| `PUT /guias/{id}` (solo documentada o en sucursal; recotiza) · `POST /guias/{id}/notas` | `guias.editar` |
| `POST /guias/estatus` (ids[], estatus, motivo, recibio; todo o nada) | `guias.editar`; cancelar pide `guias.cancelar` |
| `GET·POST /importaciones` · `GET /importaciones/{id}` · `/filas` · `POST /importaciones/{id}/aplicar` · `/descartar` | `guias.importar` |

Estados: documentada → en sucursal → (asignada a viaje, etapa 3) → en ruta → entregada | intento fallido → en ruta / en sucursal / devuelta; cancelada desde documentada o en sucursal. Cada cambio queda en `guia_eventos` (lo público alimenta el rastreo) y en la bitácora.

**Importación:** el navegador lee .xlsx, .xls y .csv con SheetJS y manda las filas; el servidor valida y cotiza cada una como si la fuera a crear y guarda la vista previa con errores por fila. Nada se documenta hasta *Crear guías*, que corre en una sola transacción. Formatos:
- *Manifiesto del aliado* (el Excel que llega hoy, 18 columnas). La celda *Direccion* se separa en colonia, C.P., calle, número y municipio; el número al inicio de *Cliente Destino* pasa a la referencia; *Fecha P.C.* es la fecha promesa. El manifiesto no trae peso: se toma 1 kg por pieza. Una guía del aliado ya documentada sale como duplicada.
- *Plantilla ENVIEX* (botón *Plantilla ENVIEX* en la pantalla), una fila por envío.

**Etiqueta** (`backoffice/etiqueta.php?ids=…&formato=4x6|carta`): una por pieza, térmica 4×6 in o 4 por hoja carta; Code 128 con el folio, QR con `URL_WEB/rastreo/rastreo.php?folio=FOLIO` (la página de rastreo del sitio) y la guía del aliado en código de barras si existe. Se imprime desde el navegador (o se guarda como PDF). El listado tiene un campo para lector de código de barras que abre la guía o la recibe en sucursal.

### Planeación, viajes y app del operador (etapa 3)

Flujo: guía **en sucursal** (o con intento fallido) → aparece en *Planeación* agrupada por ruta → se arma o completa un viaje (operador, unidad, fecha) → el operador lo ve en su app, lo inicia (km y foto del tablero), entrega cada parada (nombre, foto y firma) o registra el intento fallido (motivo y foto) → cierra (km y foto). Al cerrar, lo no visitado regresa a sucursal y al pool; lo fallido queda en el pool para reintentar.

- Asignar es todo o nada: si una guía no está en sucursal o ya va en otro viaje, no entra ninguna. Una guía en viaje abierto no se mueve desde *Guías*.
- *Ordenar automáticamente*: vecino más cercano desde la sucursal (captura sus coordenadas en *Sucursales*). Usa la ubicación del domicilio si existe (Google Geocoding con `GOOGLE_MAPS_KEY`, o el GPS de una entrega anterior); si no, la localidad de la ruta del municipio. Siempre se puede ajustar a mano.
- Evidencias en `storage/evidencias/AAAA/MM/` (fuera del web), servidas por `GET /api/v1/evidencias/{id}` solo a quien tiene permiso o al operador del viaje. Respáldala junto con la base.
- Hoja de ruta imprimible por viaje (`viaje-hoja.php`), respaldo en papel de la app.

**App del operador** (`pwa/`): se instala desde el navegador del teléfono (Agregar a pantalla de inicio). Si vive en otro dominio (p. ej. `app.enviex.com.mx`), edita `pwa/config.js` con la dirección de la API y agrega ese dominio a `CORS_ORIGINS`. Requiere HTTPS (cámara, GPS y service worker). Cada operador necesita un usuario con rol *Operador* ligado a su ficha en *Catálogos › Operadores*.
- Sin señal, todo lo que registra se guarda en el teléfono y se manda solo al volver la conexión; cada registro lleva un identificador único (`uid`) y el servidor nunca lo aplica dos veces.
- Mientras un viaje está en curso y la app está abierta, manda su posición cada 2 minutos. Los navegadores pausan el GPS de una app web en segundo plano: el recorrido tiene huecos si el operador cambia de app.

| Método y ruta | Permiso |
| --- | --- |
| `GET /planeacion/pool` · `GET /planeacion/opciones` | `planeacion.ver` |
| `GET /viajes` · `GET /viajes/activos` · `GET /viajes/{id}` · `GET /viajes/{id}/recorrido` | `viajes.ver` o `planeacion.ver` |
| `POST /viajes` (con `guias[]`) · `POST /viajes/{id}/guias` · `DELETE /viajes/{id}/guias/{guia}` | `planeacion.asignar` |
| `PUT /viajes/{id}` · `PUT /viajes/{id}/orden` · `POST /viajes/{id}/auto-orden` | `planeacion.asignar` o `viajes.gestionar` |
| `POST /viajes/{id}/iniciar` · `/cancelar` | `viajes.gestionar` |
| `POST /viajes/{id}/cerrar` | `viajes.cerrar` |
| `GET /guias/{id}/evidencias` · `GET /evidencias/{id}` | `guias.ver` / `viajes.ver` / operador del viaje |
| `GET /pwa/viajes` · `GET /pwa/viajes/{id}` · `POST /pwa/viajes/{id}/iniciar` · `/posiciones` · `/paradas/{guia}/entrega` · `/paradas/{guia}/fallida` · `/cerrar` | `pwa.operar` (solo sus viajes) |

### Ventas, CFDI 4.0 y cobranza (etapa 4)

Flujo: *Ventas › Nueva venta* → se elige el cliente y su razón social → se marcan sus guías por vender (cada guía es un concepto 78102203 · E48 con su folio como *NoIdentificacion*) y se agregan conceptos manuales → **Guardar y facturar** timbra el CFDI 4.0. Una guía solo puede estar en una venta vigente.

- **Contado (PUE):** al timbrar, la venta queda pagada con un cobro automático por la forma de pago elegida.
- **Crédito (PPD, forma 99):** la venta conserva su saldo y vence según los días de crédito del cliente. Cada pago en *Cobranza* timbra su recibo electrónico de pago (Pagos 2.0) con parcialidad, saldo anterior e insoluto. Un pago puede cubrir varias ventas del mismo RFC.
- **Público en general:** receptor XAXX010101000, régimen 616, uso S01 y *InformacionGlobal* (periodicidad, mes y año).
- **Cancelación:** motivos 02, 03 y 04 desde la venta; el 01 se hace con **Sustituir**, que timbra la factura nueva relacionada (tipo 04) y cancela la anterior apuntando a ella. Una factura con complementos vigentes no se cancela hasta cancelar esos pagos (que cancelan su complemento con motivo 02). Si el SAT deja la cancelación «en proceso», *Consultar SAT* la actualiza.
- La venta se edita o cancela (liberando sus guías) solo sin factura vigente ni pagos.
- Se valida el receptor: régimen según persona física (13) o moral (12), usos D solo para físicas, 616 solo con S01.
- PDF propio (logo, QR de verificación del SAT, sellos, cadena del timbre, importe con letra) en `storage/cfdi/AAAA/MM/`; el XML timbrado se guarda en la base. Ambos se envían por correo con `SMTP_*`; sin SMTP el envío queda anotado en `storage/logs/correo-AAAA-MM.log`.

**Configuración (`config/.env`):** `APP_SECRETO` (32+ caracteres aleatorios; cifra la contraseña del CSD), `FINKOK_MODO` = `simulado` | `demo` | `produccion`, `FINKOK_USUARIO`, `FINKOK_PASSWORD` y `SMTP_*`. En *Administración › Empresa* se capturan RFC, razón social sin régimen societario, régimen y C.P. fiscal, y se sube el CSD (.cer, .key y contraseña). Con `simulado` el timbre es falso y el PDF dice SIN VALIDEZ FISCAL. Para pruebas, `php bin/enviex csd-prueba` carga el CSD público del SAT (EKU9003173C9) como emisor; nunca uses el CSD real en ambientes de prueba.

| Método y ruta | Permiso |
| --- | --- |
| `GET /ventas` · `GET /ventas/{id}` · `GET /ventas/resumen` · `GET /cfdi` · `GET /cfdi/{id}` · `/pdf` · `/xml` · `POST /cfdi/{id}/estatus` · `/enviar` | `ventas.ver` |
| `GET /ventas/por-facturar` · `/clientes-por-facturar` · `POST /ventas` · `PUT /ventas/{id}` · `POST /ventas/{id}/cancelar` | `ventas.crear` |
| `POST /ventas/{id}/timbrar` · `POST /pagos/{id}/rep` | `cfdi.timbrar` |
| `POST /ventas/{id}/sustituir` | `cfdi.timbrar` y `cfdi.cancelar` |
| `POST /cfdi/{id}/cancelar` | `cfdi.cancelar` |
| `GET /pagos` · `GET /pagos/{id}` · `GET /clientes/{id}/estado-cuenta` | `ventas.ver` o `pagos.registrar` |
| `GET /pagos/pendientes` · `POST /pagos` · `POST /pagos/{id}/cancelar` | `pagos.registrar` |
| `GET/POST /empresa/csd` · `POST /empresa/csd/{id}/activar` · `DELETE /empresa/csd/{id}` | `empresa.gestionar` |

### Cotizaciones, portal de clientes y sitio web (etapa 5)

**Etiqueta de entrega por C.P.** Cada colonia puede ser *entrega diaria* (lista EAD: Valles, Ciénega y Los Altos) o *solo cotización especial* (lista de especiales: ZMG, Lagos, San Juan, Colima, Morelia, Zamora, Cd. Guzmán, Querétaro, SLP, Zacatecas, León, Aguascalientes, Vallarta y Tepic). Las listas vienen en `database/seeds/cp-modalidad.csv` y se aplican con `php bin/enviex etiquetar-cp` (acepta otro CSV con columnas `cp, asentamiento, municipio, modalidad` o `--modalidad=`). En *Cobertura › Códigos postales* se filtra por etiqueta y se cambia en bloque con «Ruta o etiqueta».
- A un destino *especial* no se captura ni se importa una guía directa (422 con el motivo); el cotizador del sitio responde `requiere_cotizacion` sin precio y ofrece pedir cotización. El personal sí ve el precio de lista como referencia.
- La guía a un destino especial nace al convertir una cotización enviada o aceptada; lleva el precio cotizado y lo conserva aunque se edite.
- Sin etiqueta, una colonia se comporta como antes.

**Cotizaciones** (*Comercial › Cotizaciones*): llegan del sitio (`POST /publico/cotizaciones`, con campo trampa y límite por IP `COTIZACIONES_POR_HORA`), del portal o las arma el equipo. Se cotizan con partidas libres («Sugerir con la tarifa» da el precio de lista de punto de partida), se envían en PDF por correo, se registra si el cliente aceptó o rechazó y «Convertir en guía» abre la captura con destino, paquetes y precio. Vigencia de 15 días por defecto; vencida no se convierte.

**Portal de clientes** (`portal/`): en la ficha del cliente, pestaña *Portal*, se da acceso con nombre y correo; la contraseña temporal se muestra una sola vez y se cambia al entrar. El cliente ve sus envíos con historial y evidencia de entrega, descarga PDF y XML de sus facturas y complementos, consulta saldo y vencimientos, pide cotizaciones y las acepta, y solicita factura por folio de guía. Todo se filtra por el cliente del usuario; nunca por un `cliente_id` de la petición. Las solicitudes de factura llegan a *Ventas › Solicitudes del portal* y «Crear venta» la arma con su razón social y guías. Si el portal vive en otro dominio, edita `portal/config.js` y agrega el dominio a `CORS_ORIGINS`.

**Sitio web** (`web/sitio/`, instrucciones en `web/sitio/LEEME.md`): el sitio aprobado (`app/www/web_new`) conserva su diseño; se reemplazan solo los PHP que leían la base anterior con contraseñas escritas, con los mismos nombres y la misma respuesta JSON, y ahora leen y escriben en el sistema:
- **Rastreo** (`rastreo/rastreo_ajax.php`): las guías del sistema (folio ENX, guía o rastreo del aliado) con sus eventos públicos; nombres abreviados, sin domicilio ni teléfonos.
- **Cotización** (`guardar_cotizacion.php`, `colonias.php`): el precio sale de las tarifas (el desglose base / urgencia / seguro / IVA de la página se arma con el flete estándar, la diferencia del servicio elegido y los cargos) y la cotización queda en *Cotizaciones* como «Sitio web», enviada, con su folio, que es el que lleva el PDF de la página. A un destino de cotización especial no se le da precio: si el visitante dejó nombre y correo o teléfono, queda como solicitud nueva y avisa al equipo. El formulario *Cotización especial* de cobertura (`contacto.php`) también cae en la bandeja, además del correo.
- **Cobertura** (`cp.php`, `php/marcadores.php`): colonias con ruta activa; el chip de región muestra la etiqueta «Entrega diaria» o «Entrega con cotización especial». El mapa de `cobertura.php` pinta las localidades de las rutas.
- **Descargar factura** (`facturacion/buscar.php`, `descargar.php`, `enviar.php`): ticket (folio de venta, de guía o de factura) + monto → factura vigente (si se sustituyó, la nueva), XML y PDF por liga firmada de 2 horas y envío por correo; la factura global no se entrega. Límite por IP contra búsquedas a fuerza bruta.
- `index.html` y `cotizacion.html` solo cambian en su JavaScript: la facturación y el cotizador dejaron de usar datos de demostración.
- Los PDF del sistema usan los diseños aprobados: la factura, la plantilla de `facturacion/pdf_fact_enviex.php` (banda navy, logo, recuadros de serie/folio, receptor en naranja); la cotización, la de `cotizacion.html` / `desktop/ajax/cotizacion_pdf.php` (banda navy con «ENVIEX / TU MEJOR ALIADO EN LOGÍSTICA», desglose y total en navy). El correo de facturas usa la plantilla `facturacion/envio/enviex-cfdi.html` (copiada en `core/recursos/correo-cfdi.html`).

**Dashboard**: filtro por sucursal y fecha; indicadores del día (documentadas, por planear, en ruta, entregadas, intentos fallidos, ventas), mapa de viajes en curso con su última posición, entregas e intentos fallidos de 30 días y pendientes accionables según el rol (viajes sin cerrar, promesas vencidas, cotizaciones por atender, solicitudes de factura, timbres rechazados, pagos sin complemento, CSD por vencer…).

| Método y ruta | Permiso |
| --- | --- |
| `POST /publico/cotizaciones` | público |
| `GET /cotizaciones` · `GET /cotizaciones/{id}` · `/pdf` | `cotizaciones.ver` |
| `POST /cotizaciones` · `POST /cotizaciones/sugerir` · `PUT /cotizaciones/{id}` · `POST /cotizaciones/{id}/enviar` · `/responder` · `/cancelar` · `GET /cotizaciones/{id}/convertir` | `cotizaciones.gestionar` |
| `POST /guias` con `cotizacion_id` | `guias.crear` |
| `POST /cobertura/asignar-ruta` (con `modalidad`) | `cobertura.gestionar` |
| `GET /portal/resumen` · `/guias` · `/guias/{id}` · `/evidencias/{id}` · `/facturas` · `/facturas/{id}/pdf|xml` · `/estado-cuenta` · `/datos-fiscales` · `GET/POST /portal/solicitudes-factura` · `GET/POST /portal/cotizaciones` · `/portal/cotizaciones/{id}` · `/pdf` · `/responder` | `portal.consultar` (solo su cliente) |
| `GET /clientes/{id}/accesos` | `clientes.ver` |
| `POST /clientes/{id}/accesos` · `/accesos/{usuario}/restablecer` · `/accesos/{usuario}/estatus` | `clientes.gestionar` |
| `GET /solicitudes-factura` · `/solicitudes-factura/{id}` | `ventas.ver` |
| `POST /solicitudes-factura/{id}/rechazar` · `POST /ventas` con `solicitud_id` | `ventas.crear` |
| `GET /dashboard/operacion?fecha=&sucursal_id=` | `dashboard.ver` |

### Cotización (`Tarificador`)

Peso facturable = máximo entre peso real (kg por pieza × cantidad) y volumétrico (L×A×H ÷ divisor × cantidad); las medidas son opcionales. Se usa la tarifa vigente de la zona del destino y, si no hay, la general del servicio. Flete = cargo base + precio por kg × (peso facturable − kg incluidos). Los cargos en porcentaje se calculan sobre el flete y respetan su mínimo. IVA con `IVA_TASA` del `.env` (0.16 por defecto). Devuelve `requiere_cotizacion` si el servicio se cotiza a la medida, no hay tarifa para esa zona o el peso pasa el máximo.
Respuesta: `{"ok": true, "data": …}` o `{"ok": false, "mensaje": "…", "errores": {campo: mensaje}}` con 401, 403, 404, 419 (CSRF), 422 (validación), 423 (bloqueo).

## Pendientes conocidos

- **Finkok:** la conexión (timbrar, recuperar timbrado previo, cancelar, consultar estatus) está escrita según su documentación pero no se pudo probar desde aquí. Antes de producción: credenciales de **demo**, `FINKOK_MODO=demo`, correr `criterio-etapa4.php` y revisar en el portal de Finkok; después `produccion` con el CSD real de la empresa.
- **CSD real:** se sube desde *Empresa*. La e.firma (FIEL) se rechaza. Los archivos de la persona física que estaban en `desktop/cfdi/archs_emisor/` y `fiel/` del sistema anterior deben salir del servidor, y las contraseñas de Finkok escritas en `desktop/cfdi/01_CFDI_factura_*.php` deben cambiarse.
- **Carta Porte:** no se emite. Que el contador confirme si los traslados de ENVIEX la requieren (normalmente la emite quien transporta la mercancía por carretera federal).
- **Retención de IVA del 4 %:** no se aplica; normalmente no corresponde a paquetería, pero que el contador lo confirme para clientes personas morales.
- **Complemento de pago con IVA 16 %:** las facturas a crédito solo admiten conceptos con IVA 16 % (las de frontera al 8 % o exentas van de contado).
- **Etiquetas de C.P.:** con las listas del administrador, la ZMG completa quedó como *solo cotización especial* y solo Valles, Ciénega y Los Altos son de entrega diaria. Si la ZMG debía ser de entrega diaria, se corrige en *Cobertura* con «Ruta o etiqueta» (por ruta ZMG) sin tocar código. Las colonias que no venían en ninguna lista (rutas Jalos y Sureste no tienen C.P.) quedan sin etiqueta y se comportan como antes.
- **Sitio web, archivos que quedan fuera de uso:** el autofacturado por carrito de tickets (`facturacion/facturacion.php`, `generar_cfdi.php`, `ticket_ajax.php`, que timbraba con llaves guardadas en `facturacion/archs_pem/`), `facturacion/envio/` (envío de correos sin control y una contraseña SMTP escrita), `cotizaciones_api.php` y `cotizaciones_admin.php` (sin autenticación; reemplazados por avisos) y los `db_config.php` / `conexion.php`. El `.htaccess` de `web/sitio/` los bloquea; lo correcto es borrarlos del servidor junto con `facturacion/archs_pem/` (llaves `.pem`) y cambiar la contraseña de `cfdi@lonasyetiquetas.mx`. El botón «Comprar» de `cotizacion.html` sigue siendo una demostración (la pasarela `pago_/` no se conectó).
- **Correo saliente:** cotizaciones y facturas se envían con `SMTP_*`; sin configurarlo el envío queda anotado en `storage/logs/correo-AAAA-MM.log`.

- **Tarifas de arranque:** son las del cotizador actual del sitio (base 80, 28/kg, divisor 5000; Express +50 %, Mismo día +120 % solo ZMG; seguro 2.5 % mínimo 35). ENVIEX debe validarlas en *Servicios, tarifas y flota*. Diferencia con la web: el seguro ahora se calcula sobre el flete.
- **Rutas sin colonias:** SURESTE y JALOS no traían C.P. en el sistema anterior; asignarles colonias desde *Códigos postales › Asignar ruta*.
- **Rastreo en el sitio:** el QR de la etiqueta apunta a `URL_WEB/rastreo/rastreo.php?folio=…`, la página de rastreo del sitio.
- **Llaves de Google Maps en el sitio:** `cobertura.php` trae una llave en el HTML (es normal que sea pública), pero debe estar restringida por dominio en la consola de Google.
- **Geocodificación:** con `GOOGLE_MAPS_KEY` en `.env` (llave nueva y restringida; la que estaba escrita en `subirguias/carga_guias.php` debe revocarse) los domicilios se ubican al ordenar un viaje. Sin llave, el orden usa las localidades de la ruta y el GPS de cada entrega va corrigiendo las ubicaciones.
- **Coordenadas de la matriz:** se sembraron aproximadas (Col. La Loma); corrígelas en *Sucursales*.
- **Plantilla de material:** `material-dashboard.min.js` lleva un ajuste de una línea (el efecto ripple ya no falla si el botón cambia).
- **Mapa:** usa teselas de OpenStreetMap (requiere salida a internet desde el navegador). Para tráfico alto conviene un proveedor con llave.
- **PHP 7.2 sin soporte** desde 2020: el código ya corre en PHP 8.x; conviene subir el servidor en cuanto se pueda.
- La fuente de íconos (Material Symbols, 4.5 MB) se sirve local; se descarga una vez y queda en caché.
