Amadeus — Auditoría forense de viaje por usuario
Contexto
Sergio detectó que el técnico Manuel Pérez Morales (usuarios.id=9) en el viaje 191 (folio BJZ20260519, 19-22 mayo 2026, sitio “Benito Juárez”) estuvo regresando a Cd. Juárez todas las noches sin avisar, cuando la indicación era pernoctar cerca del sitio. Quiere validar todos sus consumos: que sean en horario laboral (8-18hrs según el ticket, no según el EXIF de la foto), que sean conceptos válidos, que no esté inflando.
El feature #239 (validación IA de fotos) ya cubre el lado individual: cada consumo se analiza al subirse. Pero falta la vista agregada por viaje × usuario que detecte patrones cross-consumo: ráfagas de tiempo, geografías inconsistentes, conceptos sintéticos repetitivos.
Investigación read-only (2026-05-25)
SSH + tinker directos sobre prod amadeus. Hallazgos sin tocar IA, solo con metadatos:
Setup
- Viaje 191: folio BJZ20260519, 3 días (19-22 mayo).
- Sitio: Benito Juárez (id 45). Sin GPS en
sitios.latitud/longitud(null). - Manuel =
usuarios.id=9, emailmperez@e-electrosystems.com.
Consumos de Manuel en viaje 191
- 31 consumos, total $6,306.60 MXN.
- 31/31 con foto.
- 31/31 con
fecha_ticket(EXIF de la foto). - 0/31 con
coordenadas_ticket(el dispositivo no captura GPS en EXIF). - 31/31 con
coordenadas(GPS del browser al registrar).
Patrones forenses detectados sin IA
-
Geolocalización de registro homogénea: los 31 consumos se registraron desde
31.6139, -106.355(Cd. Juárez, no el sitio del viaje). Variación ≤3m entre los 31. Es decir: Manuel registró todos sus consumos desde el mismo edificio físico en Cd. Juárez. -
Backloading nocturno masivo: 24 de los 31 tickets fueron fotografiados entre 22:30 y 23:02 del 21 mayo — ventana de 32 minutos, intervalos seriales de ~1 minuto entre fotos consecutivas. Todos los registros en la app caen entre 04:30 y 05:02 del 22 mayo (la madrugada antes de regresar del viaje).
-
Rotación sintética de conceptos: los 24 nocturnos siguen secuencia mecánica
Comida → Víveres → Sueros → Comida → Víveres → Sueros → Cena → .... Patrón no-natural; sugiere conceptos escritos a la hora de la foto, no a la hora del gasto. -
Volumen atípico: 31 consumos en 3 días = ~10/día. Una persona en viaje real genera ~3 comidas/día y 1 ronda de víveres por viaje.
-
Conceptos rechazables triviales: 2
Cargo por retirode $34.80 cada uno = comisiones bancarias, no son gastos. El feature #239 los marcaríaparece_viatico_legitimo=false. -
Contraste con los del 25 mayo: 7 consumos restantes tienen
fecha_ticketEXIF en horario laboral (09:56-10:01) y registro el mismo día por la tarde (15:56-16:01). Comportamiento natural — sirve de comparación.
Lo que la IA aún tiene que dar
Los metadatos no dicen:
- Hora real impresa en el ticket (el EXIF es cuándo se tomó la foto, no cuándo fue el gasto).
- Ciudad del comercio según el ticket (no tenemos GPS de la foto).
- Detección de tickets duplicados/serializados por contenido.
Eso lo da #239 con la ampliación pre-merge (hora_ticket_ocr, fecha_ticket_ocr, comercio_nombre, comercio_ciudad), commit 166e055 en branch feature/validacion-fotos.
Diseño del feature (decisiones tomadas)
| Decisión | Elegida |
|---|---|
| ¿Ampliar #239 antes de mergear? | Sí — campos OCR temporales y de comercio agregados (commit ya hecho) |
| Fuente de ubicación esperada | GPS de sitios + fallback bbox por ciudad (bbox cubre el caso actual, donde Benito Juárez no tiene GPS) |
| Trigger | Botón “Auditar” bajo demanda por viaje × usuario (no automático) |
| Resumen ejecutivo IA | Sí, llamada extra a Antigravity con flags determinísticos como contexto (~$0.03/reporte) |
Plan de implementación
Schema nuevo
auditorias_viaje
- id
- viaje_id (FK)
- usuario_id (FK)
- generado_por_id (FK usuarios, admin que pidió la auditoría)
- estado: pendiente | analizando | listo | error
- resumen_ejecutivo (text, narrativa generada por IA)
- flags_agregados (JSON con counts y arrays):
{
consumos_total: 31,
consumos_fuera_horario_laboral: 24,
consumos_lejos_del_sitio: 0 o N si calculamos por bbox,
conceptos_rechazados_por_ia: 2,
ratio_diario_consumos: 10.3,
rafagas_temporales: [
{ventana: "2026-05-21 22:30-23:02", N: 24, intervalo_medio_seg: 45}
],
ciudades_inferidas: { "Cd. Juárez": 24, "Villa Ahumada": 5, "desconocido": 2 }
}
- costo_usd
- generado_at
- timestamps
Service nuevo
app/Services/Vision/AuditorViajeService.php:
public function auditar(Viaje $viaje, Usuario $usuario): AuditoriaViaje
Pasos:
- Carga consumos del usuario en el viaje (sin global scopes) + análisis IA + categoría.
- Para consumos sin análisis o con datos incompletos: dispatcha
AnalizarFotoConsumoJobSYNC y espera (esto es un comando admin, no UI flow — sync OK). - Calcula flags determinísticos en PHP (rápido, sin IA):
- Fuera de horario:
hora_ticket_ocrno entre 08:00-18:00, o si null,fecha_ticketEXIF idem. - Lejos del sitio: distancia
coordenadas_ticketvs sitio del viaje. Fallback:comercio_ciudadvs ciudad esperada del sitio. - Ráfagas temporales: agrupa fechas de fotografía en ventanas de ≤30 min con ≥5 consumos.
- Conceptos rechazados: count de
consumos_analisis.parece_viatico_legitimo=false. - Ratio diario:
count(consumos) / (fecha_fin - fecha_inicio + 1). Flag si >5/día. - Duplicados por OCR: agrupa por
comercio_nombre + monto_total; flag si hay duplicados confecha_ticket_ocrdistintas.
- Fuera de horario:
- Llama Antigravity UNA vez con todo el JSON de flags + resumen de consumos, pide narrativa de 3-5 oraciones describiendo patrones.
- Persiste
AuditoriaViaje.
Controller + UI
- Ruta:
GET /viajes/{viaje}/auditoria/{usuario}(vista del reporte).POSTmismo path para disparar generación. - Page Vue
Admin/AuditoriaViaje.vue:- Header con nombre del usuario, viaje, fechas, sitio.
- Tarjeta de resumen ejecutivo (texto IA).
- KPIs: total consumos, monto, % nocturno, % fuera de sitio, conceptos rechazados.
- Cronología (línea de tiempo) por consumo: fecha del ticket vs fecha de registro, color por flag.
- Tabla detallada por consumo con flags por columna.
- Sección “Hallazgos accionables” (lista de flags relevantes con counts).
- Botón “Auditar este usuario” en
/viajes/{id}para admin congestionar_viaticos.
Comando artisan
php artisan analisis:auditar-viaje {viaje} {usuario} — útil para correr desde la línea de comandos sin pasar por la UI.
Costo
Para el viaje 191 / Manuel (31 consumos):
- Si re-analizamos todos los consumos del viaje para tener los campos nuevos: 31 × ~$0.01 = ~$0.31.
- Resumen ejecutivo: ~$0.03.
- Total: ~$0.35 USD por auditoría.
Cap diario global (ANALISIS_COSTO_MAX_DIARIO_USD) sigue aplicando.
Tareas pendientes
- 📅 sin-fecha — B.1 Migración
auditorias_viaje+ modelo + relaciones. - 📅 sin-fecha — B.2
AuditorViajeServicecon flags determinísticos. - 📅 sin-fecha — B.3 Llamada extra a Antigravity para resumen ejecutivo (helper en mismo service o
ResumidorAuditoriaService). - 📅 sin-fecha — B.4
AuditoriaViajeController+ ruta + policy. - 📅 sin-fecha — B.5 Page Vue
Admin/AuditoriaViaje.vuecon secciones del diseño. - 📅 sin-fecha — B.6 Botón “Auditar” en
/viajes/{id}show (visible si admin). - 📅 sin-fecha — B.7 Comando artisan
analisis:auditar-viaje. - 📅 sin-fecha — B.8 Tests Pest end-to-end.
- 📅 sin-fecha — B.9 Smoke test contra viaje 191 / Manuel para validar que la IA captura los patrones que ya vemos en metadatos.
Bitácora
2026-05-25 — investigación + diseño + ampliación de #239
- SSH read-only sobre prod amadeus, queries vía tinker.
- Identificados los 5 patrones forenses del viaje 191 sin necesidad de IA (geo homogénea, ráfaga nocturna, rotación sintética, volumen, cargos bancarios).
- Sergio confirmó: ampliar #239 antes de mergear con
hora_ticket_ocr,fecha_ticket_ocr,comercio_nombre,comercio_ciudad. Commit166e055en branchfeature/validacion-fotos. - Diseño completo del feature de auditoría documentado en este archivo.
- Próximo: implementación B.1 → B.9 en el mismo branch
feature/validacion-fotos. Al mergear, todo va junto.