Compare commits

...
107 Commits
Author SHA1 Message Date
Lizandro GuarnizoandClaude Sonnet 4.6 dd894e5aab feat: marcar varios lotes escribiendolos, no de a uno
Cada toque redibuja la lista en un mensaje nuevo: seis lotes eran seis
mensajes. Ahora se puede escribir "1a, 2b 5c" y quedan marcados de una. Suma a
lo ya tocado en vez de reemplazarlo, asi las dos formas conviven.

Compara sin acentos ni mayusculas y acepta el lote solo o con la finca delante.
Si dos fincas tienen un "1A" no adivina: exige el nombre completo. Y lo que no
reconoce lo dice —marcar cinco de seis en silencio registraria un ciclo de
menos sin que nadie se entere.

Se separa por comas y se prueba el segmento entero antes de partirlo por
espacios: al reves, "RETIRO 1A" se rompia en dos y no matcheaba nada.

Descartado "marcar todos": nunca se abren todos los lotes de una finca.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-20 23:11:47 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 52dbd1e203 feat: editar desde el resumen sin rehacer el formulario
Cancelar obligaba a empezar de cero por una fecha mal puesta. El resumen suma
un tercer boton —WhatsApp admite tres y usaba dos—: Editar lista los campos ya
cargados con su valor, se elige uno, se corrige y se vuelve derecho al resumen.

Los campos que alimentan a otros declaran 'invalida' en el seed: cambiar el
tipo de ausentismo borra el motivo y lo vuelve a preguntar, acotado al tipo
nuevo. Sin eso quedaba un motivo de Incapacidad dentro de un Permiso y el ERP
recibia datos incoherentes sin que nadie lo notara.

El motor engancha en un solo lugar, capAskNext: mientras queden campos sin
valor los pregunta en cadena, y cuando no queda ninguno vuelve al resumen en
vez de seguir el orden del formulario. Aplica a los cinco flujos de carga.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-20 22:59:25 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 6616b63683 fix: la fecha final del ausentismo mira hacia adelante
El selector ofrecia Hoy/Ayer/Anteayer tambien para el "hasta", cuando una
novedad que se registra hoy termina hoy o mas adelante. Ahora ese campo ofrece
Hoy/Manana/Pasado manana, y "Otra fecha" sigue aceptando cualquiera.

date_quick gana 'quick' => 'futuro'; sin esa clave se comporta igual que antes,
asi que las fechas de labores y ciclos —siempre pasadas— no cambian.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-20 22:53:03 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 2ef3091260 feat: ausentismo en dos pasos — tipo y despues motivo
17 motivos en una sola lista llegaban con el nombre cortado a 24 caracteres,
el limite de WhatsApp, y costaban de entender. Ahora primero se elige el tipo
—Incapacidad, Permiso, Licencia, Ausencia, Vacaciones...— y despues solo los
motivos de ese tipo.

Quitar el prefijo del grupo libera los caracteres que faltaban: "INCAPACIDAD
ENFERMEDAD < 3 DIAS" queda en "ENFERMEDAD < 3 DIAS". Sin siglas, que era la
otra opcion pero empeoraba justo lo que el cliente pidio arreglar. El nombre
completo va en la descripcion de la fila, asi no se pierde nada.

El ERP agrupa por nombre y no por id, para que una novedad nueva caiga sola en
su grupo; lo que no reconoce queda en 'Otros', visible.

desc_field es nuevo en los select: antes solo el multi_select podia describir.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-20 22:48:19 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 5ee2cfe2e6 fix: los lotes de ciclos mezclaban fincas
Los catalogos de los campos select y multi_select pedian la lista mandando solo
lo recolectado en el formulario, nunca el metadata de la conversacion: la finca
elegida no viajaba, {finca_id} quedaba sin resolver en la URL y el ERP lo leia
como 0 —todas las fincas—. Elegir REPOSO y ver lotes de otras fincas, tal cual
lo reporto el usuario.

Ahora el metadata acompana lo recolectado en ambos puntos. Los fixtures delatan
la fuga —sin finca_id valido cuelan un lote ajeno— y dos casos nuevos verifican
que a los catalogos de ciclos y labores solo lleguen lotes de la finca elegida.

De paso el select simple gana resolverPorCampos en su clave, que el multi ya
tenia: un catalogo por campo puede depender de lo elegido antes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-19 22:50:48 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 6c524a39ed fix: el NLU respeta los modulos del perfil
Los menus se filtraban por modulo pero el texto libre no: un perfil limitado a
pluviometria podia escribir "registrar ausentismo" y el NLU lo llevaba igual.
El mismo agujero que ya cerramos con las fincas.

moduloDeFlow() decide por convencion de nombre a que modulo pertenece cada
flow —'modulo' explicito manda— y se aplica en dos puntos: el catalogo que ve
el modelo llega filtrado, y el ruteo verifica igual la clave devuelta por si
el modelo alucina una que no le ofrecieron.

Lo permitido sigue directo: "subir pluviometria" cae en la fecha con un solo
mensaje, "subir labores diarias" arranca el flujo. Cinco casos nuevos.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-19 22:39:45 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 9ae4b0dee3 fix(tests): sin pdo_sqlite — db() falso en PHP puro
El servidor no trae el driver de SQLite y el arnes reventaba al arrancar.
NormalBot solo hace dos consultas —el endpoint por clave y el perfil por
numero— asi que dos mapas en memoria alcanzan y el arnes deja de depender de
cualquier driver de PDO.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-19 22:30:56 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 1f7d33b40d test: flujos ejecutados de punta a punta contra un ERP falso
setup/tests/ corre NormalBot de verdad —no un espejo—: SQLite en memoria con
los endpoints del seed reescritos hacia fixtures_api.php servido con php -S,
asi el curl real se ejecuta y los POST capturados se comparan contra el
contrato del procesador. ConversationContext, WhatsAppSender y AiBot son
falsos en memoria; el resto es el codigo de produccion.

51 casos: finca restringida que entra sola, ausentismo completo con rango
invertido rechazado, perfiles trabajador/supervisor, ciclos con multi-select
paginado, mantenimiento con requires/resolver, labores con cuadrilla, y NLU
que resuelve "plateo" por entity hasta el informe.

Destaparon dos errores reales:
- registrar_mantenimiento posteaba a labores_up sin novedad_id ni empleados,
  que el procesador exige: el ERP lo habria rechazado siempre. Ahora pide la
  labor del grupo elegido (endpoint nuevo novedades_mant_dn) y la cuadrilla.
- resolverPorCampos reventaba con warning al sustituir arreglos en la URL.

Uso: bash setup/tests/run.sh

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-19 22:27:05 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 6df1d482cc test: auditoria de flujos de punta a punta contra el ERP
Cruza el seed del bot con controller_api_externa.php y verifica lo que las
pruebas de navegacion no ven: que la peticion exista del otro lado, que cada
{placeholder} de la URL salga de un campo o del contexto, que el tipo de campo
este implementado, y que los menus respeten los limites de WhatsApp.

Resuelve las claves que dependen de lo elegido —lotes_{accion}_dn,
ciclos_{ciclo}_up— expandiendolas por las opciones del campo.

Verificada rompiendo el seed a proposito en siete formas distintas: las detecta
todas. Un chequeo que nunca falla no sirve de nada.

Uso: php setup/audit_flujos.php [ruta-a-PALMAS360]

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-19 22:12:23 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 42adeb64d0 feat: Fase 3 con campos condicionales; fix del monitor del ERP
Cosecha, polinizacion y fertilizacion en un solo flujo en vez de tres: skip_if
muestra el producto solo a las dos que lo llevan y el pase solo a polinizacion.
El lote se pregunta antes que el producto porque la dosis depende de los dos —
sale de la fertilizacion que se autorizo en su modulo, no la elige el usuario.

ErpMonitor construia api_base_url + /health, que no existe en el ERP: la ruta
correcta es ?peticion=ping y la URL sale de company_endpoints como el resto. Por
eso el panel mostraba el ERP caido. Tampoco mandaba el Bearer.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-19 21:01:38 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 8399a86972 feat: labores diarias fase 2; el bot repite lo que responde el ERP
Quinto flujo de carga: fecha, labor, lote, trabajadores y cantidad. El catalogo
de novedades lo filtra el ERP por fase, asi que el bot no decide cual
corresponde. El trabajador vinculado se pre-llena y el supervisor elige.

Ademas, collect_and_post descartaba la respuesta del POST y decia "listo" aunque
el ERP hubiera rechazado parte. Ahora repite lo que contesta: cuantos entraron,
quien ya tenia jornal, que quedo afuera.

Y from_phone respeta el tipo del campo: multi_select espera un arreglo aunque el
perfil traiga un solo tercero.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-19 20:32:42 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 356c157811 feat: mantenimiento cierra Fase 1; sin Flows, todo en chat
Mantenimiento no abre ni cierra ciclos porque no tiene tabla de ciclos: se
registra avance sobre lotes que ya tienen cantidad pendiente de asignar. Va
como flujo aparte y no dentro de ciclos, con la cantidad faltante en la
etiqueta para poder decidir sin salir de la lista.

Se descarta el camino de WhatsApp Flows: pluviometria y ciclos se quedan en
chat, que ya funcionan. Se quitan del menu las descripciones que prometian
formulario.

FLOWS-META.txt se conserva como referencia por si la decision cambia.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-19 18:28:15 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 e3399cca9e feat: el check de supervisor manda sobre el trabajador vinculado
El flag ya estaba en el ERP pero el bot no lo recibia, asi que un supervisor
con su propio tercero vinculado habria quedado reportando siempre a nombre
propio: justo el caso que motivo el check.

Ahora es_supervisor viaja en la sincronizacion y desactiva el pre-llenado, de
modo que se le pregunta de quien es cada registro aunque tenga tercero.

La columna la crea el seed, como las otras dos.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-19 16:17:49 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 673c15dae3 fix: los lotes para abrir ciclo salian en orden alfabetico
El pedido es mostrar los 10 con los ciclos mas largos —los que toca abrir— y
una opcion para ver el resto. La apertura ofrecia todos los lotes de la finca
por nombre, sin señal de cual estaba mas atrasado.

Ahora apertura y cierre traen 10 ordenados por dias, con los dias en la
etiqueta, y "Otro lote" cambia al listado completo sin perder lo ya marcado.
Si ningun lote tiene historial la consulta cae sola en alfabetico, porque el
DATEDIFF queda nulo y desempata por nombre.

Solo toca el flujo de carga; los informes quedan como estaban.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-11 21:09:51 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 d6bedfba54 chore: el seed crea las columnas del perfil por numero
tercero_id y modulos_json se crean desde el seed en vez de con un ALTER a mano:
ya se corre en cada despliegue y es idempotente, asi que no hay un paso manual
que alguien pueda saltearse.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-11 20:27:09 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 724179a3c6 feat: perfil por numero — trabajador vinculado y modulos permitidos
Dos perfiles, sin agregar ningun campo para distinguirlos:

- Con tercero vinculado en el ERP, el numero es de un trabajador: from_phone
  pre-llena empleado_id y el flujo se saltea el buscador. Sale gratis porque
  empleados y terceros son la misma tabla, asi que el id sirve tal cual.
- Sin vinculo es supervisor y elige de quien es el registro.

Los modulos habilitados (ciclos, ausentismos, produccion, pluviometria) llegan
del ERP y filtran las filas de menu. Las filas sin modulo declarado no se tocan,
asi la navegacion queda intacta.

Se mantiene la convencion de las fincas: nada marcado significa todo. Los
numeros que ya existen siguen igual sin migrar nada.

Requiere en la base del bot:
  ALTER TABLE company_phones
    ADD COLUMN tercero_id INT NULL, ADD COLUMN modulos_json TEXT NULL;
El codigo tolera que no existan todavia.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-11 20:21:32 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 94d4dcd26a refactor: el alcance de fincas lo resuelve el ERP, no el bot
Los catalogos ahora mandan ?wa= igual que los informes, y el endpoint fincas
devuelve solo las asignadas a ese numero. El "Todas las fincas" se antepone
unicamente a quien no tiene restriccion: con dos de cinco asignadas no
significa nada claro.

Con eso sale sobrando todo el filtrado del lado del bot —scope_from,
alcanceDelNumero y fincas_json en PhoneSync— y queda una sola fuente de verdad,
del lado donde estan los datos.

Efecto util: con una sola finca asignada el catalogo trae un unico item y
handleDynamicList auto-selecciona, asi que el usuario nunca ve la pregunta.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-05 23:59:35 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 478ba3ea73 feat: cada numero ve solo las fincas que el ERP le asigno
PhoneSync guarda el alcance que llega en whatsapp_numeros, y ask_finca acota la
lista con scope_from. Lista vacia = sin restriccion, para que quien tiene todas
vea tambien las fincas que se creen despues.

"Todas las fincas" sobrevive al filtro y pasa a significar "todas las mias"; de
acotarla se encarga el ERP, que es donde estan los datos.

Tolera que la columna todavia no exista: mientras tanto nadie queda restringido.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-05 23:53:34 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 eb0ae1f208 feat: los informes mandan el numero de quien pregunta
La API autentica por token de empresa, asi que el ERP veia "Rosa Blanca pide
produccion" y no podia acotar la respuesta a las fincas asignadas a esa
persona. Ahora cada consulta lleva ?wa= con el numero.

Va en executeApiReport y no en las URLs del seed porque los treinta informes
pasan por ahi: una linea los cubre a todos.

Sin efecto hasta que el ERP resuelva el alcance; mientras tanto es un
parametro que nadie lee.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-05 23:39:05 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 6a6f2ef44a fix: decir una categoria a secas devolvia el menu principal
Los submenus estaban marcados nlu_skip para que la IA no ruteara a navegacion,
pero eso dejo solo las hojas en el catalogo: "ausentismo" tenia cuatro
candidatos y ninguno se llamaba asi, "sanidad" ninguno, "produccion" dos. Como
no podia elegir, caia al fallback y aparecia el menu principal, que se siente
como que ignoro el mensaje.

Las cuatro categorias vuelven a ser ruteables. Para que el filtro de permisos
las alcance —por tipo un menu no es ni subida ni descarga— se agrega nlu_dir,
con lo que cat 1 solo ve registrar y cat 2 solo consultar, y la ambiguedad se
resuelve sola. Cat 3 ve las dos, asi que tiene un menu que pregunta cual.

Cuando aun asi no rutea, ahora avisa antes de mostrar el menu.

Se quita el aprendizaje por usuario: el orden por uso lo resuelve Palmas360
sobre los registros reales, y dos criterios compitiendo sobraba.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-05 23:20:25 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 2d92867431 feat: los motivos de ausentismo mas usados aparecen primero
El catalogo llega ordenado alfabeticamente y son 17 opciones, asi que el motivo
habitual quedaba en cualquier lado y a veces en la segunda pagina.

El bot cuenta que elige cada usuario y los sube. Es por usuario y no global a
proposito: quien registra incapacidades registra sobre todo incapacidades, y
eso le sirve mas que un promedio de toda la operacion.

El orden de PHP 8 es estable, asi que los empates conservan el alfabetico. El
contador se limita a 20 entradas y sobrevive al reset que corre tras cada
registro. Va como opcion por campo (frecuentes) para no reordenar listas donde
el alfabetico importa, como los lotes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-05 23:12:49 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 6ff2914162 fix: el saludo de cat 1 nombra los tres flujos que puede usar
Al sacar subir_ciclo_cosecha quedo nombrando solo ausentismo, cuando cat 1
tambien abre y cierra ciclos y carga pluviometria: eso tambien es reportar.

La prueba buscaba la frase vieja "subir ciclo"; ahora verifica los tres
flujos por separado.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-05 23:05:41 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 18c759e80d fix: dejar en el menu de envio solo los tres flujos de Fase 1
subir_ciclo_cosecha no correspondia. El audio menciona la cosecha una sola vez,
en la lista de Fase 3 —"amarrado al tiquete y hay que ver detalle de tiquete",
lo mas empinado y lo que deja de ultimo— y nunca detallo su flujo. Los tres
audios con paso a paso son pluviometria, ausentismo y ciclos.

Ademas el flow no hacia nada: upload_ciclo nunca se implemento.

El saludo de cat 1 tambien lo ponia de ejemplo; ahora nombra ausentismo.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-05 23:05:05 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 25a6ce7b1e fix: subir_ciclo_cosecha no respondia nada; marcar los que seran formulario
upload_ciclo estaba declarado en el seed y clasificado en el catalogo del NLU,
pero executeFunction nunca lo implemento: caia en default => null y el bot se
quedaba mudo al tocarlo. Pasa a ser un texto que avisa que esta en
construccion, hasta que exista el flujo de produccion.

Tampoco encajaba conceptualmente: abrir el ciclo de un lote ya lo hace
"Ciclos", y registrar la cosecha del dia es produccion, que va contra el
tiquete de bascula y es Fase 3.

Pluviometria y Ciclos avisan en su descripcion que van a pasar a formulario.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-05 23:01:26 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 d4325ce037 docs: dejar en FLOWS-META.txt solo los dos JSON
Las instrucciones y notas de verificacion salen del archivo para que el admin
copie y pegue directo. Quedan en el historial de git si hacen falta.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-05 09:13:39 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 8b9029a017 docs: verificar los Flows contra la documentacion de Meta
Componentes, propiedades y la accion complete confirmados contra los docs
oficiales. Lo unico sin confirmar es el numero de version: las paginas del
changelog no estaban accesibles. Queda anotado que hacer si el editor la
rechaza.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-05 08:47:50 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 598c3b4fda docs: especificacion de los dos Flows de WhatsApp para el admin de Meta
JSON completo de Ciclos y Pluviometria, con la configuracion que hay que poner
en WhatsApp Manager y las dos verificaciones previas a publicar.

Va versionado porque una vez publicado un Flow su estructura no se edita, y
conviene tener registro de que se publico.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-05 08:45:44 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 e5326bda8c feat: carga de ciclos — apertura y cierre de lotes con seleccion multiple
Ultimo flujo de Fase 1. Pregunta ciclo, accion, fecha y lotes, y postea a
ciclos_{ciclo}_up con lote_ids[], que es el contrato que quedo en el ERP.

Pieza nueva del motor, multi_select: la lista acumula, cada toque marca o
desmarca con un check en la etiqueta, y "Listo" cierra. WhatsApp no tiene
casillas, asi que el estado se muestra redibujando. De las 10 filas, dos van
para "Ver mas" y "Listo".

Tambien resolverPorCampos(), que reemplaza {campo} con lo ya elegido tanto en
la clave del endpoint como en la URL del POST. Con eso los seis tipos de ciclo
y las dos acciones comparten tres entradas de endpoint en vez de dieciocho:
apertura ofrece los lotes de la finca y cierre solo los que estan abiertos.

Mantenimiento no aparece: no tiene tabla de ciclos ni apertura/cierre, es una
labor y va con Fase 2.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-05 07:56:04 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 576ea7de40 fix: 7 de las 17 novedades de ausentismo eran inalcanzables
buildDynamicListResponse cortaba en 10 filas con array_slice y descartaba el
resto sin aviso, asi que el catalogo de motivos mostraba 10 de 17 y las otras
7 no habia forma de elegirlas.

Ahora pagina: 9 opciones y "Ver mas" con cuantas faltan, hasta agotarlas. El
10 dejo de estar suelto en el codigo y quedo como LISTA_MAX_FILAS, que es el
limite de WhatsApp de donde salio el problema.

Solo cubre los campos select; dynamic_list lo va a necesitar para los lotes de
Ciclos y va con ese trabajo.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-04 22:16:17 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 f06a400d16 fix: pluviometria se quedaba muda tras elegir la fecha
El selector de fecha que agregue a collect_for_each responde por lista, pero el
bloque __foreach de processInteractive solo contemplaba confirmar y cancelar:
la fecha caia en la resolucion de menus estaticos, no coincidia con ninguno y
el flujo moria sin mensaje.

Hasta ahora collect_for_each solo recibia numeros escritos, por eso nunca hizo
falta ese ruteo. Solo se desvia mientras se espera la fecha, para no secuestrar
la navegacion por menus durante el resto del bucle.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-04 20:38:40 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 c0fb2dc089 fix: rechazar rangos de fecha invertidos en ausentismo
El analisis de Gemini en Palmas360 marco un ausentismo con fecha final
anterior a la inicial: el bot lo dejaba pasar y el error recien aparecia
frente al revisor.

min_field valida entre campos al guardar, por los dos caminos (opcion de
lista y fecha escrita a mano). El aviso se envia aparte para poder volver a
mostrar el selector debajo.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-04 20:33:00 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 76ee42b96e feat: flujo de registro de pluviometria (Fase 1, carga de datos)
Segundo flujo de POST: fecha una sola vez y luego los milimetros de cada finca.

collect_for_each gana tres piezas que este flujo necesitaba:
- ask_date pregunta la fecha antes del bucle (Hoy/Ayer/Anteayer/Otra), en vez
  de repetirla por item o asumir hoy.
- exclude_values descarta el "Todas las fincas" (id 0) que el catalogo antepone
  para los informes y que aca no es una finca real.
- El payload pasa de un array plano a {fecha, fincas:[{id,label,valor}]}, que es
  lo que BotEntradaProcesador::pluviosidad() espera; ademas manda telefono y
  nombre, que el controller usa para la trazabilidad en bot_entrada.

Nota: el procesador ACTUALIZA la fila de pluviosidad del dia y falla si no
existe. Queda pendiente confirmar con Palmas360 quien las crea.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-04 20:22:03 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 0e72aa3d5a feat: flujo de registro de ausentismo (Fase 1, carga de datos)
Primer flujo de POST. El bot recolecta trabajador, motivo y rango de fechas, y
postea a ausentismos_up; Palmas360 decide si auto-aprueba o encola para
revision, como ya hace bot_entrada.

El campo lookup asumia que el endpoint devolvia un solo objeto, pero /empleados
devuelve una lista paginada: con eso el trabajador salia como "Desconocido" y
sin id. Ahora distingue lista de objeto, y si hay varias coincidencias las
ofrece para elegir en vez de quedarse con la primera.

Campos opcionales del contrato (eps, diagnostico_id) quedan fuera hasta
confirmar con Palmas360 que novedades son incapacidad.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-04 19:37:16 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 0bb1d3447e feat: bienvenida diaria; fix: el NLU filtraba JSON crudo al usuario
Bienvenida una vez por dia y por empresa, distinta por categoria (cat 1 no ve
ejemplos de descarga). Se manda aparte del retorno de process(), asi el usuario
recibe el saludo y acto seguido lo que pidio. __saludo sobrevive a
preservingReset para no repetirse tras cada informe.

Fix del leak: cuando la respuesta del modelo no parseaba como JSON se enviaba
cruda, y el usuario veia {"action":"chat","text":"...  Ahora se rescata solo el
texto, incluso de JSON truncado, y si no hay nada legible se devuelve vacio
para caer al menu de fallback.

Ademas el prompt le prohibe preguntar por datos que una opcion ya pide: ante
"descargar informe de mantenimiento" debe rutear al selector de grupo en vez de
preguntar por chat.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-01 11:06:35 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 8825f4e821 fix: el selector de empresas se comia los comandos de navegacion
isResetKeyword aceptaba menu, salir, atras, volver, inicio y regresar, o sea el
vocabulario completo de navegacion del bot. Ese chequeo corre en el webhook,
antes de NormalBot, asi que a un usuario que pertenece a varias empresas
cualquiera de esas palabras le borraba la sesion y lo devolvia al selector en
vez de navegar.

Ahora solo responde a palabras que signifiquen cambiar de empresa.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-01 11:01:30 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 a64421c527 feat: finca visible en cada menu, entities por campo y aviso de comandos
Entities:
- Se conserva el mapa {campo: valor} en vez de aplanarlo. El pre-llenado de
  formularios y el auto-registro de valores comparan la clave contra el nombre
  del campo, asi que con la lista plana nunca se disparaban.
- entity_key acota cada dynamic_list a su propio campo: antes una finca llamada
  "reposo" se comparaba contra grupos de mantenimiento y se descartaba ahi.
- Cada lista consume solo lo suyo, asi "finca reposo, grupo plateo" resuelve
  las dos cosas en una frase.

Finca visible:
- Se guarda la etiqueta ademas del id al elegir una opcion.
- El footer de cada menu muestra la finca activa; sin finca queda el nombre de
  la empresa, asi cat 1 no cambia. Los menus de botones ahora emiten footer.
- {finca} disponible en header, body y footer.

Aviso de comandos: los textos tras cada informe nombran menu, atras y salir.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-01 10:55:55 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 693623e10c fix: el seed ya no borra la configuracion de IA del admin
config_json guarda tanto la estructura del bot (menus, flujos, comandos) como
los ajustes que se editan desde el panel (ai_model, gemini_model, claude_model,
temperatura, prompt, whisper). El seed reescribia el JSON entero, asi que cada
corrida borraba lo segundo.

Ahora conserva toda clave de primer nivel que el seed no declara, e imprime
cuales conservo.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-01 10:46:07 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 c8909e75db fix(nlu): no adivinar cual informe de mantenimiento quiere el usuario
"informe de mantenimiento de plateo" no dice si quiere los mas atrasados o
todos los lotes, asi que entregar un PDF era adivinar. Ahora resuelve el grupo
por entity y muestra el submenu; solo va directo al informe cuando el usuario
lo nombra ("todos los lotes", "mas atrasados").

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-01 10:44:17 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 ce5d4ef2b2 fix: el NLU nunca atendia texto libre tras un informe; api_base_url apunta al ERP
preservingReset dejaba current_node en NULL, que process() no distingue de una
sesion nueva: se redisparaba el flujo de bienvenida (ask_finca), skip_if_set lo
saltaba al menu principal y el paso de NLU nunca corria. Por eso "elegir otra
finca" o "informe de mantenimiento de plateo" devolvian el menu. Ahora queda el
centinela __greeted, que marca sesion iniciada.

api_base_url guardaba el Graph API de WhatsApp, pero WhatsAppSender lo tiene
hardcodeado y todos los demas consumidores esperan el ERP.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-01 10:39:10 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 1b797df2f0 fix: PhoneSync usa company_endpoints en vez de api_base_url
api_base_url guarda el Graph API de WhatsApp (lo fija el propio seed), asi que
el cron armaba https://graph.facebook.com/v22.0/php/controller/... y recibia
401 cada 5 minutos desde siempre.

El endpoint numeros_dn ya estaba registrado con la URL absoluta correcta; ahora
la sincronizacion lo usa como el resto de llamadas al ERP.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-01 10:36:32 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 0e89258f67 feat: navegacion por niveles, intencion pendiente y NLU que aterriza en el informe
- "atras"/"volver"/"regresar" suben un nivel via mapa 'back' declarativo en el
  seed, en vez de saltar siempre al menu principal.
- "finca"/"cambiar finca" como comandos directos a reset_finca.
- requires/resolver: si el NLU rutea a un informe sin el dato que necesita, el
  bot lo pide y vuelve al informe original, no al submenu. Asi "informe de
  mantenimiento de plateo" entrega el PDF sin pasos intermedios.
- ciclo_mantenimiento consulta el catalogo completo cuando hay entities: los
  grupos fuera del top 3 tambien matchean.
- ask_finca con skip_if_set: deja de repreguntar la finca despues de cada
  informe; para cambiarla esta reset_finca.
- Boton "Otro grupo" en el submenu de mantenimiento.
- Fix: el auto-select por entities no mergeaba per_type y mandaba a cat 3 al
  menu equivocado al nombrar una finca.

validate_config.php verifica el grafo (back/resolver/botones/endpoints) y
test_navegacion.php recorre en seco los escenarios de ruteo.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-01 09:59:30 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 671fccd6b2 fix: mantenimiento PDF vacio — conservar grupo_mant y detectar placeholders
preservingReset borraba grupo_mant despues de cada informe, dejando
{grupo_id} sin resolver en la siguiente llamada del mismo submenu.
La API recibia grupo=0 y devolvia cero filas sin error visible.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-01 09:42:39 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 09db466fa6 fix: navegación — atras/cancelar/regresar + NLU desde menús activos
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-30 23:50:09 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 79d2399ddf fix: agregar comando 'salir' y limpiar __api_vars en reset de comandos
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-30 23:47:12 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 fe048f53a3 refactor: simplificar Por Categoría — sin toggle, greeting_flow directo
- Eliminar checkbox 'Pedir finca al inicio': innecesario porque el
  dynamic_list ya auto-selecciona cuando solo hay 1 opción (finca, empresa, etc.)
- greeting_flow controla si hay flujo de bienvenida; si está vacío → greeting_menu
- Si greeting_flow tiene valor → greeting='' (dispara el flow) y se oculta greeting_menu
- Hint text explica que con 1 sola opción se auto-selecciona sin preguntar
- save-per-type deriva greeting/greeting_menu del greeting_flow, no de un toggle

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-30 23:19:50 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 909c6be32f feat: expandir tab Por Categoría en bot-config
- Toggle "Pedir finca al inicio" por categoría → maneja greeting/greeting_flow
- Campo editable de greeting_flow (pre-llena ask_finca al activar el toggle)
- Selector de menú inicial (greeting_menu, visible cuando NO pide finca)
- Selector de flujo de respaldo (fallback_flow) por categoría
- Datalist con flows y menús disponibles para autocompletar
- save-per-type actualiza greeting, greeting_flow, greeting_menu y fallback_flow
  preservando menus/flows/commands del seed en cada categoría
- Cat 1: sin finca, configura greeting_menu desde admin
- Cat 2/3: pide finca activado, greeting_flow = ask_finca

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-30 23:14:49 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 0ddab6adcf feat: NLU routing + fix greeting para cat2/cat3 + nlu_descriptions
- Cat 2 y cat 3: greeting '' (no null) para que greeting_flow: ask_finca
  se dispare en el primer mensaje y el usuario seleccione su finca
- nlu_enabled: true en config root activa el routing inteligente
- NormalBot: step 6 llama AiBot::routeOrChat() antes del fallback_flow;
  si retorna 'route' ejecuta el flow; si 'chat' responde en texto libre
- reset_finca: nuevo flow+función que limpia el meta de finca y relanza
  ask_finca; el NLU puede enrutar "quiero cambiar de finca" aquí
- nlu_description en todos los flows consultables por el NLU; nlu_skip
  en menus de navegación interna para no exponerlos al modelo
- AiBot::buildFlowCatalog: agrega upload_ciclo a isUpload para que cat 2
  no vea flujos de subida en el catálogo del NLU

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-30 23:03:25 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 7fc36f1ebf fix: renombrar Rincofurus → Palmarum en menú sanidad vegetal
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-30 22:28:29 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 e223438f36 fix: timezone America/Bogota en punto de entrada del bot
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-30 22:24:10 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 93a33de819 fix: restaurar finca_id en mantenimiento; filtro correcto por finca
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-30 22:02:25 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 fbd48b2781 fix: mantenimiento no filtra por finca (grupos son de toda la empresa)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-30 21:58:05 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 855fe25159 feat: mantenimiento top muestra texto; quita opción histórico 30d
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-30 21:24:47 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 fd907c8cc0 fix: __valid_ids usa strval para evitar mismatch int vs string
buildDynamicListResponse castea IDs a string al armar la lista de
WhatsApp. La comparación estricta in_array fallaba si el JSON devolvía
el id como int. Ahora __valid_ids siempre contiene strings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-30 21:03:05 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 620ae3a1b3 fix: dynamic_list next_node busca en menus si no está en flows
Cuando el next_node de un collecting está en menus (no flows),
handleCollectingInput retornaba null sin respuesta. Ahora intenta
buildMenuResponse como fallback, lo que corrige el flujo de
selección de grupo en mantenimiento.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-30 20:55:40 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 db46ced033 feat: PDF producción cambia caption y filename a 'año'
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-30 20:45:37 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 b0462a7675 fix: seed no sobreescribe api_key en companies existentes
api_key contiene el token ERP de PALMAS360 configurado manualmente.
Se retira del ON DUPLICATE KEY UPDATE para que cada seed run no lo pise.
Solo aplica al INSERT inicial de una empresa nueva.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-30 20:38:12 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 591b29b966 feat: menú producción cambia 'Total del mes' a 'Totales facturados'
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-30 20:17:58 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 5dda66d27f fix: ciclo_*_todos usa date_mode=today en vez de current_month
La fecha que pasan estos nodos al endpoint es un punto de corte (hoy),
no un rango mensual. current_month enviaba fecha_desde=primer-día-mes
que el controller pasaba al modelo como referencia de días.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-30 20:07:16 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 fc23448509 chore: limpiar seed — eliminar clave y endpoints huérfanos
- Eliminada entrada duplicada 'ciclo_mantenimiento' tipo menu (reemplazada
  por el dynamic_list de abajo en el mismo array)
- Eliminado endpoint 'mantenimiento_top_texto_bot_dn' (nunca referenciado
  por ningún nodo de flujo)
- Eliminado endpoint duplicado 'mantenimiento_todos_dn' (URL idéntica a
  mantenimiento_todos_bot_dn, sin usos)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-30 18:09:29 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 f23283124a fix: preservar finca_id al resetear contexto tras generar reporte
El reset post-reporte borraba meta['finca']['finca_id'] causando que
todos los informes siguientes se generaran sin filtro de finca.

Nuevo helper preservingReset() restaura el grupo 'finca' después del
reset para que el usuario solo seleccione su finca una vez por sesión.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-30 17:58:10 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 5b0bd3b8b8 fix: al seleccionar id=0 en lista se activaba reset del multi-company
El id "0" de "Todas las fincas" coincidía con el keyword "0" en
isResetKeyword, borrando la sesión multi-empresa y mostrando el
pre-menú de empresa en vez de procesar la selección de finca.

Solución: isResetKeyword solo aplica para mensajes de texto libre;
las respuestas interactivas (list_reply/button_reply) nunca son
comandos de reinicio.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-30 16:43:07 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 c5955160bb feat(bot): mantenimiento con selección dinámica de grupo
- ciclo_mantenimiento → dynamic_list top 3 grupos más usados + "Otro grupo"
- "Otro grupo" → lista grupos 4-13 (append_options redirect)
- Tras seleccionar grupo → menú 3 botones: más largos, todos, histórico 30d
- Endpoints con {grupo_id} dinámico en URL

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-29 20:22:32 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 bc4b589d93 fix(bot): reordenar sanidad vegetal y renombrar Palmarum→Rincofurus
Orden correcto: Censo, Plagas, Rincofurus, Tratamiento.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-29 16:57:18 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 7120bd9675 feat(bot): sub-menú de 3 opciones por cada ciclo
Cada ciclo (cosecha, polinización, plagas, censo, palmarum,
tratamientos, mantenimiento) ahora presenta un menú de botones con:
  1. Más largos — texto top 10 con fecha actual
  2. Todos los lotes — PDF filtrado por finca
  3. Histórico 30d — PDF matriz (excepto mantenimiento)

Se agregan endpoints *_todos_dn apuntando a ciclos_*_dn (PDF).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-29 16:55:47 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 6572b9fcf6 feat(menus): reorganizar ciclos con sub-menú de sanidad
Estructura: Cosecha | Polinización | Sanidad (Plagas/Censo/Palmarum/Tratamientos) | Mantenimiento

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-29 16:45:04 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 2d98a9e7e0 fix(bot): enviar texto via WhatsAppSender antes de retornar PDF encadenado
El return de sendText() se descartaba; ahora se llama WhatsAppSender::sendText()
directamente para que el texto llegue antes del PDF/Excel.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-29 16:36:09 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 bd8544fce4 fix(seed): corregir flows y URLs de endpoints
- ask_finca como dynamic_list con meta_group=finca
- ciclos usan api_report con next_endpoint_key para texto+PDF
- produccion_total encadena texto → PDF
- ausentismos usan date_mode en vez de funcion inexistente
- URLs de endpoints sin {fecha_desde}/{fecha_hasta} (date_mode los agrega)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-29 16:23:00 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 2329df6ac9 feat(seed): agregar endpoint numeros_dn para sincronización de teléfonos WhatsApp
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-29 16:09:56 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 eb296ee078 fix(migrate): agregar columnas params y body_fields en company_endpoints
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-29 16:05:10 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 26b11d99e2 fix(migrate): agregar columna name en company_endpoints
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-29 15:58:30 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 0c82348641 fix(migrate): agregar tabla sessions faltante para DbSessionHandler
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-29 15:35:54 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 f378d02768 feat(setup): seed completo de companies, endpoints y menus restaurados
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-29 15:14:04 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 4b710ef33b test: script de validación endpoints ROSABLANCA
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 22:21:27 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 fe4e638667 feat(sync): cron de sincronización automática de números WhatsApp desde PALMAS360
- PhoneSync::syncAll() itera todas las empresas activas, llama a
  whatsapp_numeros de cada ERP y hace UPSERT en company_phones
- Números eliminados en PALMAS360 quedan is_active=0 automáticamente
- cron/sync_phones.php es el entry point; configurar cada 5 min en crontab

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 22:10:04 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 e3d9aac26d feat(bot): add last_7 date_mode y next_endpoint_key para texto+PDF en cadena
- last_7: fecha_desde = hoy-7 días, fecha_hasta = hoy (para ausentismos semana)
- next_endpoint_key en executeApiReport: si la primera llamada retorna JSON texto
  con status=1, envía el texto al usuario y luego ejecuta el endpoint siguiente
  (ej: produccion_total → texto inmediato + PDF a continuación)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 21:59:16 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 0df08bf953 feat: append_options en dynamic_list con redirect a otro flow
Permite añadir opciones fijas al final de una lista dinámica. Si el
usuario elige una de esas opciones, el bot redirige al flow con ese ID
en vez de guardar el valor como campo del formulario. Usado para el
botón "Ver más grupos" en el flujo de mantenimiento.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 20:29:51 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 91dd84cc8d feat: auto-select finca cuando la lista tiene un solo ítem
Si el endpoint de fincas devuelve exactamente 1 opción, el bot la
selecciona automáticamente sin mostrar la lista al usuario, y navega
directo al menú principal usando los flows del per_type activo.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 20:12:35 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 060041c855 fix: apply-menus hace merge en vez de reemplazar config completo
Antes sobreescribía todo el config_json con solo los flows base,
borrando nlu, whisper_*, gemini_*, flows custom y menus custom.
Ahora solo actualiza los flows/menus definidos en el script y
preserva todo lo demás que tenga la empresa en la BD.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-14 15:36:20 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 976f61b682 fix: match fuzzy de label en dynamic_list y collecting mid-flow
- handleDynamicList: usa str_contains bilateral para que "mantenimiento plateo"
  coincida con label "Plateo" (el NLU suele incluir palabras extra)
- handleCollectingInput: si el texto transcrito no es un ID válido, intenta
  match fuzzy contra __label_map guardado en el estado collecting
- handleDynamicList: guarda __label_map en el estado collecting para
  que mid-flow audio pueda auto-seleccionar sin ID exacto

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-05 22:03:57 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 3d04be14bc feat: auto-selección en dynamic_list cuando NLU extrae el grupo/filtro
- handleDynamicList: si __nlu_entities contiene un valor que coincide
  con el label de un ítem, lo auto-selecciona y navega al next_node
  sin mostrar la lista interactiva
- AiBot: NLU prompt ahora pide explícitamente grupos y filtros en entities

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-05 21:57:22 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 54a93adea9 feat: one-shot audio entity extraction y pre-fill de formularios
- AiBot: NLU ahora extrae entities (finca, valor, etc.) del audio
- BotRouter: guarda __nlu_entities en metadata antes de enrutar
- NormalBot: handleCollectAndPost pre-rellena campos desde entities
  y capAskNext salta los ya completados
- NormalBot: handleCollectForEach registra ítem directo si entities
  trae finca+valor coincidente, sin iterar todo el listado
- BotRouter: mid-flow audio va a NormalBot directamente (no NLU)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-05 21:48:48 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 d0c1b1d3be feat: mid-flow audio — transcribed text goes to NormalBot, not NLU
When a user sends audio while in an active flow (__cap, __foreach,
collecting, __api_vars), the Whisper transcription is passed directly
to NormalBot as the step answer instead of going through NLU routing.

Bot still shows '🎤 Escuché: _texto_' confirmation before processing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-05 21:38:25 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 1114c52df4 fix: wrap collected values in __ep_vars_combined for URL substitution
substituteUrlVars requires flat key-value pairs under __ep_vars_combined;
passing collected directly as meta skipped substitution since values are
strings not arrays, so {finca_id} was never replaced in the lotes URL.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-05 21:30:50 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 eed8116c66 feat: support nlu_skip flag to exclude intermediate flows from NLU catalog
Flows marked with nlu_skip:true are never offered to the NLU router,
preventing the AI from jumping directly to steps that require prior
data collection (e.g. submenu_mantenimiento_fecha needs grupo selected first).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-05 21:13:34 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 09342fa786 feat: pass collected values to fetchDynamicList for dependent selects
Enables select fields to filter their list based on previously collected
values via URL variable substitution (e.g. lotes filtered by finca_id).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-05 20:57:18 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 2bdbde50f5 fix: add response_format=json to self-hosted Whisper request
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-05 20:39:13 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 79eda71c5a fix: whisper_url takes priority over ai_provider for audio transcription
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-05 20:19:28 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 816c6d27c8 feat: show whisper-own in ai-logs filter and badge
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-05 20:16:18 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 fc54ec992b feat: self-hosted Whisper server support
- MediaTranscriber: if whisper_url set, POSTs to own server with
  Basic auth (audio_file field); falls back to OpenAI cloud Whisper
- Admin AI tab: new section for whisper_url / whisper_user / whisper_pass
- save-ai endpoint: saves the three whisper fields to config_json

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-05 20:07:49 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 9509c3b4fc feat: add skip_if support to collect_and_post flow fields
Allows fields to declare a skip_if condition so the bot skips them
based on previously collected values — used for eps/diagnostico fields
that only apply when novedad is an incapacidad type.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-05 20:07:44 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 497743ceb1 feat: NLU fallback — route unrecognized text to AI in hybrid+NLU mode
NormalBot.process() gains suppressFallback param. When true, steps 7-8
(greeting menu fallback, static fallback text) return null instead of
responding, letting BotRouter hand off to NLU/AI.

BotRouter.runHybridBot() passes suppressFallback=$nluEnabled so that:
- NLU on: unrecognized text → NLU → route to flow or AI chat
- NLU off: existing fallback behavior unchanged
- Commands, active flows, first-session greeting: always unaffected

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-05 09:09:49 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 54d778783e fix: use Layout::open/close in aiLogs() — page() does not exist
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-05 09:06:50 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 9852a749e6 fix: remove broken duplicate query in aiLogs()
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-05 09:05:05 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 5506ebd2af feat: AI usage logging (AiLogger, ai_logs table, admin view)
- AiLogger.php: new service, inserts into ai_logs on every AI call
- ai_logs table: added to migrate.php (provider, model, call_type, tokens, duration_ms)
- AiBot: logs chat, media_response, nlu calls with timing and token counts
- MediaTranscriber: logs whisper, geminiAudio, openaiVision, geminiVision, claudeVision
- /admin/ai-logs: paginated table with provider/company filter
- Layout.php: added IA Logs nav link
- index.php: require AiLogger before AiBot; added GET /admin/ai-logs route

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-05 09:03:21 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 1d9484a1e0 Fix NLU flow: media_id, upload perm, catalog filter, nlu_description field
- WpWebhook: inject media_id into context before calling BotRouter so
  MediaTranscriber can download audio/image (was passing caption='')
- BotRouter: use context['media_id'] for transcription (non-breaking fallback)
- AiBot::processMedia: fix canUpload to include permType 1 (solo reporta)
- AiBot::buildFlowCatalog: include text flows that have nlu_description set
- DashboardController + index.php: add nlu_description field per flow card

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-05 08:51:13 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 bb4b5dab10 Fix AI tab save: dedicated AJAX endpoint /save-ai
Instead of relying on the native HTML form (which couldn't reliably
isolate AI fields), the IA tab now uses an AJAX call identical to
save-per-type. The new /admin/bot-config/save-ai endpoint only
reads and merges AI fields into config_json, preserving menus/flows.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-05 08:28:41 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 5afa846b4e Add per-tab save buttons in bot-config page
Each tab (Comandos, Menús, Flujos, IA, General) now has its own
submit button so the user doesn't have to scroll to the bottom.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-04 22:16:04 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 eb436638fb fix: AI tab settings not saving — read from DOM in bot-config save JS
The JS FormData was built from the CONFIG object (DB state), missing
all AI inputs the user types: api_key, models, checkboxes (nlu,
ai_for_media). Now reads them directly from DOM before the fetch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-04 22:04:52 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 9e36a965c9 feat: NLU routing + audio/image transcription via AI
- NluRouter in AiBot: builds permission-filtered flow catalog, asks AI
  to route user intent to exact flow key or return chat response
- MediaTranscriber: downloads WA media, transcribes audio (Whisper/Gemini),
  extracts image intent (OpenAI/Gemini/Claude vision)
- BotRouter hybrid: text → NormalBot → NLU fallback if no match;
  audio/image → transcribe → NLU route; sends transcript preview first
- Admin IA tab: NLU checkbox with description
- Per-company nlu flag saved in config_json

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-04 21:50:01 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 2b859e3dc0 feat: multi-provider AI with per-company override and Claude support
- Global settings: custom AI panel with OpenAI/Gemini/Claude each with
  API key, model selector, and live test-connection button (no save needed)
- Per-company IA tab: same three providers + fallback to global when empty
- AiBot: reads per-company config_json first, falls back to env() global
- Added callClaude() for Anthropic Messages API
- More OpenAI models: gpt-4.1, gpt-4.1-mini
- test-ai endpoint: accepts provider/api_key/model from POST for live test

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-04 19:06:51 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 b82fcf6aaa feat: add Gemini 2.5 Flash as default model option
Added gemini-2.5-flash to the admin panel selector (first/default option)
and updated the fallback default in AiBot and migrate seed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-04 18:52:08 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 5883801989 fix: prepend api_base_url to relative endpoint URLs in NormalBot
After migration stripped base URLs from DB records, bot was calling
relative paths (e.g. /ruta) as-is without the base. buildUrl() helper
now prepends api_base_url for all four curl call sites.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-04 16:25:10 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 f06be3584f feat: two-message post-selection flow in multi-company pre-menu
After user selects a company, send short confirmation text then reset
bot_context and trigger BotRouter with empty message to fire the greeting.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-04 16:18:41 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 8f43ccf4c2 feat: pre-menú de selección de empresa para números multi-empresa
Cuando un número pertenece a más de una empresa, el bot muestra un
menú interactivo "¿Con cuál empresa deseas comunicarte?" antes de
cualquier flujo normal. La selección se cachea 8h en la tabla
multi_company_sessions. Keywords de reinicio (salir, inicio, menú...)
limpian la sesión y vuelven a mostrar el pre-menú.
Números de una sola empresa: flujo intacto.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-04 16:07:52 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 b349230362 chore: script de migración para quitar base URL de endpoints
Recorre company_endpoints y elimina el prefijo api_base_url de
las URLs absolutas, dejando solo la ruta relativa.
Dry-run por defecto (?run=1 para ejecutar).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-04 15:35:58 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 43933bfe3a feat: endpoints usan api_base_url como base, rutas relativas en UI
- Banner en tab Endpoints muestra la URL base de General
- Inputs de URL aceptan rutas relativas (?param=x o /ruta)
- Preview live de la URL completa (base+ruta) bajo cada input
- Backend prepend api_base_url en test y sync si URL no es absoluta
- Compatible hacia atrás: URLs absolutas existentes siguen funcionando

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-04 15:19:46 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 bfbc70451d feat: clonar endpoints desde otra empresa en pestaña Endpoints API
Agrega panel de clonación en la pestaña Endpoints API del editor de empresa.
Permite copiar los endpoints de otra empresa con dos modos: sobreescribir todo
o solo añadir los endpoints que aún no tienen URL configurada.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-04 13:50:39 -05:00
25 changed files with 6668 additions and 282 deletions
+142
View File
@@ -0,0 +1,142 @@
========================= FLOW 1 — CICLOS =========================
{
"version": "7.0",
"screens": [
{
"id": "CICLOS",
"title": "Ciclos de lotes",
"terminal": true,
"data": {
"titulo": { "type": "string", "__example__": "Abrir ciclos — Cosecha" },
"fecha": { "type": "string", "__example__": "2026-08-04" },
"lotes": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": { "type": "string" },
"title": { "type": "string" }
}
},
"__example__": [
{ "id": "4", "title": "REPOSO 1A" },
{ "id": "7", "title": "REPOSO 1B" },
{ "id": "9", "title": "REPOSO 2A" }
]
}
},
"layout": {
"type": "SingleColumnLayout",
"children": [
{ "type": "TextHeading", "text": "${data.titulo}" },
{
"type": "Form",
"name": "form_ciclos",
"children": [
{
"type": "DatePicker",
"name": "fecha",
"label": "Fecha",
"required": true,
"init-value": "${data.fecha}"
},
{
"type": "CheckboxGroup",
"name": "lotes",
"label": "Lotes",
"required": true,
"data-source": "${data.lotes}"
},
{
"type": "Footer",
"label": "Enviar",
"on-click-action": {
"name": "complete",
"payload": {
"fecha": "${form.fecha}",
"lotes": "${form.lotes}"
}
}
}
]
}
]
}
}
]
}
====================== FLOW 2 — PLUVIOMETRIA ======================
{
"version": "7.0",
"screens": [
{
"id": "PLUVIOMETRIA",
"title": "Registrar pluviometría",
"terminal": true,
"data": {
"fecha": { "type": "string", "__example__": "2026-08-04" },
"finca_1_label": { "type": "string", "__example__": "REPOSO" },
"finca_1_visible": { "type": "boolean", "__example__": true },
"finca_2_label": { "type": "string", "__example__": "ROSA BLANCA" },
"finca_2_visible": { "type": "boolean", "__example__": true },
"finca_3_label": { "type": "string", "__example__": "" },
"finca_3_visible": { "type": "boolean", "__example__": false },
"finca_4_label": { "type": "string", "__example__": "" },
"finca_4_visible": { "type": "boolean", "__example__": false },
"finca_5_label": { "type": "string", "__example__": "" },
"finca_5_visible": { "type": "boolean", "__example__": false },
"finca_6_label": { "type": "string", "__example__": "" },
"finca_6_visible": { "type": "boolean", "__example__": false },
"finca_7_label": { "type": "string", "__example__": "" },
"finca_7_visible": { "type": "boolean", "__example__": false },
"finca_8_label": { "type": "string", "__example__": "" },
"finca_8_visible": { "type": "boolean", "__example__": false }
},
"layout": {
"type": "SingleColumnLayout",
"children": [
{ "type": "TextBody", "text": "Milímetros de lluvia registrados por finca." },
{
"type": "Form",
"name": "form_pluvio",
"children": [
{ "type": "DatePicker", "name": "fecha", "label": "Fecha", "required": true, "init-value": "${data.fecha}" },
{ "type": "TextInput", "name": "finca_1", "label": "${data.finca_1_label}", "input-type": "number", "visible": "${data.finca_1_visible}" },
{ "type": "TextInput", "name": "finca_2", "label": "${data.finca_2_label}", "input-type": "number", "visible": "${data.finca_2_visible}" },
{ "type": "TextInput", "name": "finca_3", "label": "${data.finca_3_label}", "input-type": "number", "visible": "${data.finca_3_visible}" },
{ "type": "TextInput", "name": "finca_4", "label": "${data.finca_4_label}", "input-type": "number", "visible": "${data.finca_4_visible}" },
{ "type": "TextInput", "name": "finca_5", "label": "${data.finca_5_label}", "input-type": "number", "visible": "${data.finca_5_visible}" },
{ "type": "TextInput", "name": "finca_6", "label": "${data.finca_6_label}", "input-type": "number", "visible": "${data.finca_6_visible}" },
{ "type": "TextInput", "name": "finca_7", "label": "${data.finca_7_label}", "input-type": "number", "visible": "${data.finca_7_visible}" },
{ "type": "TextInput", "name": "finca_8", "label": "${data.finca_8_label}", "input-type": "number", "visible": "${data.finca_8_visible}" },
{
"type": "Footer",
"label": "Enviar",
"on-click-action": {
"name": "complete",
"payload": {
"fecha": "${form.fecha}",
"finca_1": "${form.finca_1}",
"finca_2": "${form.finca_2}",
"finca_3": "${form.finca_3}",
"finca_4": "${form.finca_4}",
"finca_5": "${form.finca_5}",
"finca_6": "${form.finca_6}",
"finca_7": "${form.finca_7}",
"finca_8": "${form.finca_8}"
}
}
}
]
}
]
}
}
]
}
+656 -75
View File
@@ -1113,12 +1113,23 @@ HTML;
$epStmt->execute([$id]); $epStmt->execute([$id]);
$allEps = $epStmt->fetchAll(\PDO::FETCH_ASSOC); $allEps = $epStmt->fetchAll(\PDO::FETCH_ASSOC);
$baseRaw = rtrim($company['api_base_url'] ?? '', '/');
$baseHtml = self::h($baseRaw);
$epRows = ''; $epRows = '';
foreach ($allEps as $ep) { foreach ($allEps as $ep) {
$epKey = self::h($ep['endpoint_key']); $epKey = self::h($ep['endpoint_key']);
$epName = self::h($ep['name'] ?? ''); $epName = self::h($ep['name'] ?? '');
$epUrl = self::h($ep['url'] ?? ''); $epUrl = self::h($ep['url'] ?? '');
$epMeth = $ep['method'] ?? 'GET'; $epMeth = $ep['method'] ?? 'GET';
// Preview full URL
$rawUrl = $ep['url'] ?? '';
if ($rawUrl === '' || str_starts_with($rawUrl, 'http')) {
$previewUrl = self::h($rawUrl);
} else {
$sep = str_starts_with($rawUrl, '?') ? '' : '/';
$previewUrl = self::h($baseRaw . $sep . ltrim($rawUrl, '/'));
}
$epDir = $ep['direction'] ?? 'download'; $epDir = $ep['direction'] ?? 'download';
$epParams = self::h($ep['params'] ?? ''); $epParams = self::h($ep['params'] ?? '');
$epAct = $ep['is_active'] ? 'checked' : ''; $epAct = $ep['is_active'] ? 'checked' : '';
@@ -1215,9 +1226,10 @@ VR;
<option {$mSel('GET')}>GET</option><option {$mSel('POST')}>POST</option> <option {$mSel('GET')}>GET</option><option {$mSel('POST')}>POST</option>
</select> </select>
</td> </td>
<td style="min-width:200px"> <td style="min-width:220px">
<input type="text" value="{$epUrl}" id="epu_{$epId}" placeholder="https://..." oninput="onUrlChange({$epId})" style="width:100%;font-size:12px;padding:4px 6px;border:1px solid #e5e7eb;border-radius:4px"> <input type="text" value="{$epUrl}" id="epu_{$epId}" placeholder="?peticion=xxx o /ruta" oninput="onUrlChange({$epId})" style="width:100%;font-size:12px;padding:4px 6px;border:1px solid #e5e7eb;border-radius:4px">
<div style="font-size:10px;color:#9ca3af;margin-top:2px">Usa {'{var}'} para variables. {$lastAt}</div> <div id="ep-preview-{$epId}" style="font-size:10px;color:#6b7280;font-family:monospace;margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="{$previewUrl}">{$previewUrl}</div>
<div style="font-size:10px;color:#9ca3af;margin-top:1px">Usa {'{var}'} para variables. {$lastAt}</div>
</td> </td>
<td><input type="checkbox" {$epAct} id="epa_{$epId}" title="Activo"></td> <td><input type="checkbox" {$epAct} id="epa_{$epId}" title="Activo"></td>
<td style="white-space:nowrap"> <td style="white-space:nowrap">
@@ -1246,15 +1258,21 @@ ROW;
$epHtml = <<<EP $epHtml = <<<EP
<div class="card-h" style="margin-bottom:12px">🔌 Endpoints API</div> <div class="card-h" style="margin-bottom:12px">🔌 Endpoints API</div>
<div style="background:#fffbeb;border:1px solid #fde68a;border-radius:8px;padding:10px 14px;margin-bottom:14px;font-size:12px;display:flex;align-items:center;gap:8px;flex-wrap:wrap">
<span>🔗 <strong>URL base:</strong></span>
<code style="background:#fff8e1;padding:2px 6px;border-radius:4px;font-size:12px">{$baseHtml}</code>
<a href="/admin/company/edit?id={$id}&tab=general" style="color:#1e40af;font-size:11px">cambiar en General </a>
<span style="color:#92400e;font-size:11px">· Los endpoints usan rutas relativas a esta URL</span>
</div>
<p style="font-size:12px;color:#7a8291;margin-bottom:14px"> <p style="font-size:12px;color:#7a8291;margin-bottom:14px">
Define los endpoints del ERP. Usa <code>{'{variable}'}</code> en la URL para variables que el bot pedirá al usuario.<br> Escribe solo la ruta o sufijo: <code>?peticion=produccion</code> o <code>/mi/ruta</code>. Se combina con la URL base.<br>
Ejemplo: <code>?peticion=produccion&amp;desde={'{desde}'}&amp;hasta={'{hasta}'}</code> Usa <code>{'{variable}'}</code> para variables que el bot pedirá al usuario. Ejemplo: <code>?desde={'{desde}'}&amp;hasta={'{hasta}'}</code>
</p> </p>
<div class="card" style="overflow-x:auto"> <div class="card" style="overflow-x:auto">
<table style="width:100%;min-width:700px"> <table style="width:100%;min-width:700px">
<thead> <thead>
<tr style="font-size:12px;text-align:left"> <tr style="font-size:12px;text-align:left">
<th>Key</th><th>Nombre</th><th>Dirección</th><th>Método</th><th>URL</th><th>Activo</th><th></th> <th>Key</th><th>Nombre</th><th>Dirección</th><th>Método</th><th>Ruta / sufijo</th><th>Activo</th><th></th>
</tr> </tr>
</thead> </thead>
<tbody id="epTableBody">{$epRows}</tbody> <tbody id="epTableBody">{$epRows}</tbody>
@@ -1290,14 +1308,43 @@ ROW;
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>URL</label> <label>Ruta / sufijo</label>
<input type="text" id="newEpUrl" placeholder="https://app.palmas360.com/...?peticion=xxx"> <input type="text" id="newEpUrl" placeholder="?peticion=xxx o /mi/ruta" oninput="updateNewEpPreview()">
<div id="newEpPreview" style="font-size:11px;color:#6b7280;font-family:monospace;margin-top:4px"></div>
</div> </div>
<div id="newEpMsg" style="margin-bottom:8px"></div> <div id="newEpMsg" style="margin-bottom:8px"></div>
<button class="btn-primary" onclick="addNewEp({$id})">Agregar</button> <button class="btn-primary" onclick="addNewEp({$id})">Agregar</button>
</div> </div>
</div> </div>
EP; EP;
// Prepend clone panel
$otherCompanies = array_filter(CompanyRepository::findAll(), fn($c) => (int)$c['id'] !== $id);
$cloneOpts = '<option value="">— Selecciona empresa origen —</option>';
foreach ($otherCompanies as $oc) {
$cloneOpts .= '<option value="' . (int)$oc['id'] . '">' . self::h($oc['name'] ?? $oc['display_name'] ?? '') . '</option>';
}
$clonePanel = <<<CLONE
<div style="background:#f0f4ff;border:1px solid #c7d7ff;border-radius:10px;padding:14px 16px;margin-bottom:20px">
<div style="font-size:13px;font-weight:600;color:#1e40af;margin-bottom:10px">📋 Clonar endpoints desde otra empresa</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
<select id="cloneSrc" style="flex:1;min-width:200px;padding:7px 10px;border:1px solid #c7d7ff;border-radius:6px;font-size:13px">
{$cloneOpts}
</select>
<button class="btn-secondary" onclick="showCloneOptions()">Clonar </button>
</div>
<div id="cloneOptions" style="display:none;margin-top:10px">
<div style="font-size:12px;color:#374151;margin-bottom:8px;font-weight:500">¿Cómo quieres clonar los endpoints existentes en esta empresa?</div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<button class="btn-primary" onclick="doClone('overwrite')" style="font-size:12px">Sobreescribir todo</button>
<button class="btn-secondary" onclick="doClone('add_only')" style="font-size:12px">Solo añadir faltantes</button>
<button onclick="cancelClone()" style="background:none;border:none;font-size:12px;color:#6b7280;cursor:pointer;padding:4px 8px">Cancelar</button>
</div>
</div>
<div id="cloneMsg" style="margin-top:8px"></div>
</div>
CLONE;
$epHtml = $clonePanel . $epHtml;
} }
// For "copy from" dropdown on new company form // For "copy from" dropdown on new company form
@@ -1592,8 +1639,33 @@ function collectVarConfigs(epId) {
return JSON.stringify(configs); return JSON.stringify(configs);
} }
const BASE_URL = '{$baseHtml}';
function buildFullUrl(path) {
if (!path || path.startsWith('http')) return path;
const base = BASE_URL.replace(/\/$/, '');
const sep = path.startsWith('?') ? '' : '/';
return base + sep + path.replace(/^\//, '');
}
function updateEpPreview(epId) {
const path = document.getElementById('epu_'+epId)?.value || '';
const el = document.getElementById('ep-preview-'+epId);
if (!el) return;
const full = buildFullUrl(path);
el.textContent = full || '';
el.title = full || '';
}
function updateNewEpPreview() {
const path = document.getElementById('newEpUrl')?.value || '';
const el = document.getElementById('newEpPreview');
if (el) el.textContent = buildFullUrl(path) || '';
}
// When URL changes, refresh the vars section // When URL changes, refresh the vars section
function onUrlChange(epId) { function onUrlChange(epId) {
updateEpPreview(epId);
const url = document.getElementById('epu_'+epId).value; const url = document.getElementById('epu_'+epId).value;
const matches = [...url.matchAll(/\{([^}]+)\}/g)].map(m => m[1]); const matches = [...url.matchAll(/\{([^}]+)\}/g)].map(m => m[1]);
const vrRows = document.getElementById('vr-rows-'+epId); const vrRows = document.getElementById('vr-rows-'+epId);
@@ -1700,6 +1772,38 @@ async function addNewEp(cid) {
msg.innerHTML='<div class="toast toast-error">'+j.error+'</div>'; msg.innerHTML='<div class="toast toast-error">'+j.error+'</div>';
} }
} }
function showCloneOptions() {
const src = document.getElementById('cloneSrc').value;
if (!src) { alert('Selecciona una empresa origen'); return; }
document.getElementById('cloneOptions').style.display = 'block';
}
function cancelClone() {
document.getElementById('cloneOptions').style.display = 'none';
}
async function doClone(mode) {
const src = document.getElementById('cloneSrc').value;
if (!src) return;
cancelClone();
const msg = document.getElementById('cloneMsg');
msg.innerHTML = '<span style="font-size:12px;color:#6b7280">Clonando...</span>';
const fd = new FormData();
fd.append('source_company_id', src);
fd.append('target_company_id', {$id});
fd.append('mode', mode);
try {
const r = await fetch('/admin/company/endpoints/clone', {method:'POST', body:fd});
const j = await r.json();
if (j.ok) {
msg.innerHTML = '<div class="toast toast-success" style="margin:0">' + j.message + '</div>';
setTimeout(() => location.reload(), 1200);
} else {
msg.innerHTML = '<div class="toast toast-error" style="margin:0">' + j.error + '</div>';
}
} catch(e) {
msg.innerHTML = '<div class="toast toast-error" style="margin:0">Error de red</div>';
}
}
</script> </script>
HTML; HTML;
} }
@@ -1737,9 +1841,15 @@ HTML;
exit; exit;
} }
// Llamar al endpoint // Llamar al endpoint — prepend base URL si es ruta relativa
$epUrl = $ep['url'];
if (!str_starts_with($epUrl, 'http')) {
$base = rtrim($company['api_base_url'] ?? '', '/');
$sep = str_starts_with($epUrl, '?') ? '' : '/';
$epUrl = $base . $sep . ltrim($epUrl, '/');
}
$apiKey = $company['api_key'] ?? ''; $apiKey = $company['api_key'] ?? '';
$ch = curl_init($ep['url']); $ch = curl_init($epUrl);
$opts = [ $opts = [
CURLOPT_RETURNTRANSFER => true, CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15, CURLOPT_TIMEOUT => 15,
@@ -1990,6 +2100,13 @@ HTML;
$company = CompanyRepository::findById($companyId); $company = CompanyRepository::findById($companyId);
$apiKey = $company['api_key'] ?? ''; $apiKey = $company['api_key'] ?? '';
// Prepend api_base_url for relative paths
if ($url !== '' && !str_starts_with($url, 'http')) {
$base = rtrim($company['api_base_url'] ?? '', '/');
$sep = str_starts_with($url, '?') ? '' : '/';
$url = $base . $sep . ltrim($url, '/');
}
$ch = curl_init($url); $ch = curl_init($url);
$opts = [ $opts = [
CURLOPT_RETURNTRANSFER => true, CURLOPT_RETURNTRANSFER => true,
@@ -2033,6 +2150,55 @@ HTML;
exit; exit;
} }
// ─── POST /admin/company/endpoints/clone ─────────────────────────────────
public static function companyEndpointsClone(): void
{
SessionAuth::require();
header('Content-Type: application/json; charset=utf-8');
$sourceId = (int)($_POST['source_company_id'] ?? 0);
$targetId = (int)($_POST['target_company_id'] ?? 0);
$mode = ($_POST['mode'] ?? '') === 'add_only' ? 'add_only' : 'overwrite';
if ($sourceId <= 0 || $targetId <= 0 || $sourceId === $targetId) {
echo json_encode(['ok' => false, 'error' => 'IDs inválidos']);
exit;
}
try {
$stmt = db()->prepare("SELECT endpoint_key, direction, url, method FROM company_endpoints WHERE company_id = ?");
$stmt->execute([$sourceId]);
$sourceEps = $stmt->fetchAll(\PDO::FETCH_ASSOC);
if (empty($sourceEps)) {
echo json_encode(['ok' => false, 'error' => 'La empresa origen no tiene endpoints configurados']);
exit;
}
$existingKeys = [];
if ($mode === 'add_only') {
$ex = db()->prepare("SELECT endpoint_key FROM company_endpoints WHERE company_id = ? AND url IS NOT NULL AND url != ''");
$ex->execute([$targetId]);
$existingKeys = array_column($ex->fetchAll(\PDO::FETCH_ASSOC), 'endpoint_key');
}
$upsert = db()->prepare("INSERT INTO company_endpoints (company_id, endpoint_key, direction, url, method) VALUES (?,?,?,?,?) ON DUPLICATE KEY UPDATE url=VALUES(url), method=VALUES(method)");
$count = 0;
foreach ($sourceEps as $ep) {
if ($mode === 'add_only' && in_array($ep['endpoint_key'], $existingKeys, true)) continue;
$upsert->execute([$targetId, $ep['endpoint_key'], $ep['direction'], $ep['url'], $ep['method']]);
$count++;
}
$label = $mode === 'add_only' ? 'añadidos' : 'sobreescritos';
echo json_encode(['ok' => true, 'count' => $count, 'message' => "{$count} endpoint(s) {$label} correctamente"]);
} catch (\PDOException $e) {
echo json_encode(['ok' => false, 'error' => 'Error de base de datos: ' . $e->getMessage()]);
}
exit;
}
// ─── GET /admin/sync-companies ────────────────────────────────────────── // ─── GET /admin/sync-companies ──────────────────────────────────────────
public static function syncCompanies(): void public static function syncCompanies(): void
@@ -2132,17 +2298,38 @@ HTML;
['key' => 'whatsapp_default_phone_number_id', 'label' => 'Phone Number ID (default)', 'type' => 'text', 'placeholder' => '384710295638401'], ['key' => 'whatsapp_default_phone_number_id', 'label' => 'Phone Number ID (default)', 'type' => 'text', 'placeholder' => '384710295638401'],
['key' => 'verify_hmac_signature', 'label' => 'Validar firma HMAC de Meta', 'type' => 'checkbox', 'placeholder' => ''], ['key' => 'verify_hmac_signature', 'label' => 'Validar firma HMAC de Meta', 'type' => 'checkbox', 'placeholder' => ''],
], ],
'Inteligencia Artificial' => [
['key' => 'ai_provider', 'label' => 'Proveedor', 'type' => 'select', 'options' => ['mock' => 'Mock (simulado)', 'openai' => 'OpenAI', 'gemini' => 'Google Gemini']],
['key' => 'openai_api_key', 'label' => 'OpenAI API Key', 'type' => 'password', 'placeholder' => 'sk-...'],
['key' => 'openai_model', 'label' => 'Modelo OpenAI', 'type' => 'select', 'options' => ['gpt-4o-mini' => 'GPT-4o Mini', 'gpt-4o' => 'GPT-4o', 'gpt-3.5-turbo' => 'GPT-3.5 Turbo']],
['key' => 'gemini_api_key', 'label' => 'Google Gemini API Key', 'type' => 'password', 'placeholder' => 'AIza...'],
['key' => 'gemini_model', 'label' => 'Modelo Gemini', 'type' => 'select', 'options' => ['gemini-2.0-flash' => 'Gemini 2.0 Flash', 'gemini-1.5-flash' => 'Gemini 1.5 Flash', 'gemini-1.5-pro' => 'Gemini 1.5 Pro']],
['key' => 'ai_max_tokens', 'label' => 'Máximo de tokens', 'type' => 'number', 'placeholder' => '500'],
['key' => 'ai_default_prompt', 'label' => 'System Prompt por defecto', 'type' => 'textarea', 'placeholder' => 'Eres un asistente...'],
],
]; ];
// ── Valores globales de IA para el panel custom ────────────────────────
$gAiProvider = $settings['ai_provider'] ?? 'openai';
$gOaiKey = self::h($settings['openai_api_key'] ?? '');
$gOaiModel = $settings['openai_model'] ?? 'gpt-4o-mini';
$gGemKey = self::h($settings['gemini_api_key'] ?? '');
$gGemModel = $settings['gemini_model'] ?? 'gemini-2.5-flash';
$gClaKey = self::h($settings['claude_api_key'] ?? '');
$gClaModel = $settings['claude_model'] ?? 'claude-haiku-4-5';
$gMaxTok = self::h($settings['ai_max_tokens'] ?? '500');
$gPrompt = self::h($settings['ai_default_prompt'] ?? '');
$gSelOai = $gAiProvider === 'openai' ? ' selected' : '';
$gSelGem = $gAiProvider === 'gemini' ? ' selected' : '';
$gSelCla = $gAiProvider === 'claude' ? ' selected' : '';
$gSelMock = $gAiProvider === 'mock' ? ' selected' : '';
$gShowOai = ($gAiProvider === 'openai' || $gAiProvider === '') ? '' : 'display:none';
$gShowGem = $gAiProvider === 'gemini' ? '' : 'display:none';
$gShowCla = $gAiProvider === 'claude' ? '' : 'display:none';
$sOaiMini = $gOaiModel === 'gpt-4o-mini' ? ' selected' : '';
$sOai41m = $gOaiModel === 'gpt-4.1-mini' ? ' selected' : '';
$sOai4o = $gOaiModel === 'gpt-4o' ? ' selected' : '';
$sOai41 = $gOaiModel === 'gpt-4.1' ? ' selected' : '';
$sOai35 = $gOaiModel === 'gpt-3.5-turbo' ? ' selected' : '';
$sGemF25 = $gGemModel === 'gemini-2.5-flash' ? ' selected' : '';
$sGemF20 = $gGemModel === 'gemini-2.0-flash' ? ' selected' : '';
$sGemF15 = $gGemModel === 'gemini-1.5-flash' ? ' selected' : '';
$sGemP15 = $gGemModel === 'gemini-1.5-pro' ? ' selected' : '';
$sClaH = $gClaModel === 'claude-haiku-4-5' ? ' selected' : '';
$sClaS = $gClaModel === 'claude-sonnet-5' ? ' selected' : '';
$sClaO = $gClaModel === 'claude-opus-4-8' ? ' selected' : '';
$versionHash = substr(sha1_file(__DIR__ . '/../.env'), 0, 8); $versionHash = substr(sha1_file(__DIR__ . '/../.env'), 0, 8);
http_response_code(200); http_response_code(200);
@@ -2203,45 +2390,131 @@ HTML;
echo '</div></div></div>'; echo '</div></div></div>';
} }
echo <<<HTML echo <<<HTML
<!-- IA Global custom card (inside form, saves with the rest) -->
<div class="card">
<div class="card-h">🤖 Inteligencia Artificial (Global)</div>
<div class="card-b">
<div class="form-grid">
<div>
<label class="lbl">Proveedor por defecto</label>
<select class="inp-sel" name="ai_provider" id="g_ai_provider" onchange="gAiChange(this.value)" style="width:100%">
<option value="openai"{$gSelOai}>OpenAI (GPT)</option>
<option value="gemini"{$gSelGem}>Google Gemini</option>
<option value="claude"{$gSelCla}>Anthropic Claude</option>
<option value="mock"{$gSelMock}>Mock (simulado)</option>
</select>
</div>
<div>
<label class="lbl">Máximo de tokens</label>
<input class="inp" type="number" name="ai_max_tokens" value="{$gMaxTok}" placeholder="500">
</div>
<div class="fw">
<label class="lbl">System Prompt por defecto</label>
<textarea class="inp" name="ai_default_prompt" rows="3" placeholder="Eres un asistente...">{$gPrompt}</textarea>
</div>
</div>
<!-- OpenAI -->
<div id="g_oai" style="margin-top:14px;padding:14px;background:#f8f9fb;border-radius:8px;border:1px solid #e5e7eb;{$gShowOai}">
<div style="font-weight:700;font-size:13px;margin-bottom:10px">🔵 OpenAI</div>
<div class="form-grid">
<div>
<label class="lbl">API Key</label>
<input class="inp" type="password" name="openai_api_key" id="g_oai_key" value="{$gOaiKey}" placeholder="sk-...">
</div>
<div>
<label class="lbl">Modelo</label>
<select class="inp-sel" name="openai_model" id="g_oai_model" style="width:100%">
<option value="gpt-4o-mini"{$sOaiMini}>GPT-4o Mini</option>
<option value="gpt-4.1-mini"{$sOai41m}>GPT-4.1 Mini</option>
<option value="gpt-4o"{$sOai4o}>GPT-4o</option>
<option value="gpt-4.1"{$sOai41}>GPT-4.1</option>
<option value="gpt-3.5-turbo"{$sOai35}>GPT-3.5 Turbo</option>
</select>
</div>
</div>
<div style="margin-top:10px">
<button type="button" class="btn-primary" onclick="testProvider('openai','g_oai_key','g_oai_model','g_oai_res')" style="font-size:12px;padding:6px 14px">Probar conexión</button>
<span id="g_oai_res" style="font-size:12px;margin-left:10px"></span>
</div>
</div>
<!-- Gemini -->
<div id="g_gem" style="margin-top:14px;padding:14px;background:#f8f9fb;border-radius:8px;border:1px solid #e5e7eb;{$gShowGem}">
<div style="font-weight:700;font-size:13px;margin-bottom:10px">🟢 Google Gemini</div>
<div class="form-grid">
<div>
<label class="lbl">API Key</label>
<input class="inp" type="password" name="gemini_api_key" id="g_gem_key" value="{$gGemKey}" placeholder="AIza...">
</div>
<div>
<label class="lbl">Modelo</label>
<select class="inp-sel" name="gemini_model" id="g_gem_model" style="width:100%">
<option value="gemini-2.5-flash"{$sGemF25}>Gemini 2.5 Flash</option>
<option value="gemini-2.0-flash"{$sGemF20}>Gemini 2.0 Flash</option>
<option value="gemini-1.5-flash"{$sGemF15}>Gemini 1.5 Flash</option>
<option value="gemini-1.5-pro"{$sGemP15}>Gemini 1.5 Pro</option>
</select>
</div>
</div>
<div style="margin-top:10px">
<button type="button" class="btn-primary" onclick="testProvider('gemini','g_gem_key','g_gem_model','g_gem_res')" style="font-size:12px;padding:6px 14px">Probar conexión</button>
<span id="g_gem_res" style="font-size:12px;margin-left:10px"></span>
</div>
</div>
<!-- Claude -->
<div id="g_cla" style="margin-top:14px;padding:14px;background:#f8f9fb;border-radius:8px;border:1px solid #e5e7eb;{$gShowCla}">
<div style="font-weight:700;font-size:13px;margin-bottom:10px">🟣 Anthropic Claude</div>
<div class="form-grid">
<div>
<label class="lbl">API Key</label>
<input class="inp" type="password" name="claude_api_key" id="g_cla_key" value="{$gClaKey}" placeholder="sk-ant-...">
</div>
<div>
<label class="lbl">Modelo</label>
<select class="inp-sel" name="claude_model" id="g_cla_model" style="width:100%">
<option value="claude-haiku-4-5"{$sClaH}>Claude Haiku 4.5 (rápido)</option>
<option value="claude-sonnet-5"{$sClaS}>Claude Sonnet 5</option>
<option value="claude-opus-4-8"{$sClaO}>Claude Opus 4.8</option>
</select>
</div>
</div>
<div style="margin-top:10px">
<button type="button" class="btn-primary" onclick="testProvider('claude','g_cla_key','g_cla_model','g_cla_res')" style="font-size:12px;padding:6px 14px">Probar conexión</button>
<span id="g_cla_res" style="font-size:12px;margin-left:10px"></span>
</div>
</div>
</div>
</div>
<div class="btn-row"> <div class="btn-row">
<a href="/admin/dashboard" class="btn-out" style="background:#ddd;color:#333;padding:10px 24px;border-radius:8px;text-decoration:none;font-size:14px">Cancelar</a> <a href="/admin/dashboard" class="btn-out" style="background:#ddd;color:#333;padding:10px 24px;border-radius:8px;text-decoration:none;font-size:14px">Cancelar</a>
<button type="submit" class="btn-primary">Guardar configuración</button> <button type="submit" class="btn-primary">Guardar configuración</button>
</div> </div>
</form> </form>
<div class="card" style="margin-top:16px">
<div class="card-h">Probar conexión IA</div>
<div class="card-b">
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
<button type="button" class="btn-primary" onclick="testAiConn()" id="btnTestAi">Probar conexión</button>
<span id="aiTestResult" style="font-size:13px"></span>
</div>
<small style="margin-top:8px;color:#888">Guarda primero la configuración, luego prueba la conexión. Se enviará un mensaje de prueba al proveedor activo.</small>
</div>
</div>
<div class="version-info">Los cambios se aplican inmediatamente. Los valores se almacenan en la base de datos.</div> <div class="version-info">Los cambios se aplican inmediatamente. Los valores se almacenan en la base de datos.</div>
</div> </div>
<script> <script>
function testAiConn() { function gAiChange(v) {
var btn = document.getElementById('btnTestAi'); document.getElementById('g_oai').style.display = (v === 'openai' || !v) ? '' : 'none';
var res = document.getElementById('aiTestResult'); document.getElementById('g_gem').style.display = v === 'gemini' ? '' : 'none';
btn.disabled = true; document.getElementById('g_cla').style.display = v === 'claude' ? '' : 'none';
res.textContent = 'Probando...'; }
res.style.color = '#666'; function testProvider(provider, keyId, modelId, resId) {
fetch('/admin/settings/test-ai', {method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded'}, body:''}) var key = document.getElementById(keyId)?.value || '';
var model = document.getElementById(modelId)?.value || '';
var res = document.getElementById(resId);
res.textContent = 'Probando...'; res.style.color = '#666';
var body = 'provider=' + encodeURIComponent(provider) + '&api_key=' + encodeURIComponent(key) + '&model=' + encodeURIComponent(model);
fetch('/admin/settings/test-ai', {method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded'}, body: body})
.then(function(r){return r.json();}) .then(function(r){return r.json();})
.then(function(d){ .then(function(d){
if (d.ok) { if (d.ok) { res.textContent = '✅ OK: ' + d.response; res.style.color = '#16a34a'; }
res.textContent = 'Conexion exitosa (' + d.provider + '): ' + d.response; else { res.textContent = '❌ ' + d.error; res.style.color = '#dc2626'; }
res.style.color = '#16a34a';
} else {
res.textContent = 'Error (' + d.provider + '): ' + d.error;
res.style.color = '#dc2626';
}
}) })
.catch(function(e){ res.textContent = 'Error de red'; res.style.color = '#dc2626'; }) .catch(function(){ res.textContent = 'Error de red'; res.style.color = '#dc2626'; });
.finally(function(){ btn.disabled = false; });
} }
</script> </script>
</body> </body>
@@ -2869,6 +3142,7 @@ HTML;
$fallbackVal = self::h($config['fallback'] ?? ''); $fallbackVal = self::h($config['fallback'] ?? '');
$aiPromptVal = self::h($config['ai_prompt'] ?? ''); $aiPromptVal = self::h($config['ai_prompt'] ?? '');
$aiForMediaChecked = ($config['ai_for_media'] ?? true) ? 'checked' : ''; $aiForMediaChecked = ($config['ai_for_media'] ?? true) ? 'checked' : '';
$nluChecked = !empty($config['nlu']) ? 'checked' : '';
$aiModelVal = self::h($config['ai_model'] ?? 'gpt-4o-mini'); $aiModelVal = self::h($config['ai_model'] ?? 'gpt-4o-mini');
$aiTempVal = self::h((string)($config['ai_temperature'] ?? 0.7)); $aiTempVal = self::h((string)($config['ai_temperature'] ?? 0.7));
$aiProviderVal = self::h($config['ai_provider'] ?? ''); $aiProviderVal = self::h($config['ai_provider'] ?? '');
@@ -2885,44 +3159,115 @@ HTML;
} }
$aiProviderOpenai = $aiProviderVal === 'openai' ? ' selected' : ''; $aiProviderOpenai = $aiProviderVal === 'openai' ? ' selected' : '';
$aiProviderGemini = $aiProviderVal === 'gemini' ? ' selected' : ''; $aiProviderGemini = $aiProviderVal === 'gemini' ? ' selected' : '';
$aiProviderClaude = $aiProviderVal === 'claude' ? ' selected' : '';
$aiProviderMock = $aiProviderVal === 'mock' ? ' selected' : ''; $aiProviderMock = $aiProviderVal === 'mock' ? ' selected' : '';
$openaiApiKeyVal = self::h($config['openai_api_key'] ?? ''); $openaiApiKeyVal = self::h($config['openai_api_key'] ?? '');
$aiModelVal = $config['ai_model'] ?? 'gpt-4o-mini';
$aiModelMini = $aiModelVal === 'gpt-4o-mini' ? ' selected' : '';
$aiModel4o = $aiModelVal === 'gpt-4o' ? ' selected' : '';
$aiModel41 = $aiModelVal === 'gpt-4.1' ? ' selected' : '';
$aiModel41mini = $aiModelVal === 'gpt-4.1-mini' ? ' selected' : '';
$aiModel35 = $aiModelVal === 'gpt-3.5-turbo'? ' selected' : '';
$geminiApiKeyVal = self::h($config['gemini_api_key'] ?? ''); $geminiApiKeyVal = self::h($config['gemini_api_key'] ?? '');
$geminiModelVal = $config['gemini_model'] ?? 'gemini-2.0-flash'; $geminiModelVal = $config['gemini_model'] ?? 'gemini-2.5-flash';
$whisperUrlVal = self::h($config['whisper_url'] ?? '');
$whisperUserVal = self::h($config['whisper_user'] ?? '');
$whisperPassVal = self::h($config['whisper_pass'] ?? '');
$geminiModelF25 = $geminiModelVal === 'gemini-2.5-flash' ? ' selected' : '';
$geminiModelF20 = $geminiModelVal === 'gemini-2.0-flash' ? ' selected' : ''; $geminiModelF20 = $geminiModelVal === 'gemini-2.0-flash' ? ' selected' : '';
$geminiModelF15 = $geminiModelVal === 'gemini-1.5-flash' ? ' selected' : ''; $geminiModelF15 = $geminiModelVal === 'gemini-1.5-flash' ? ' selected' : '';
$geminiModelP15 = $geminiModelVal === 'gemini-1.5-pro' ? ' selected' : ''; $geminiModelP15 = $geminiModelVal === 'gemini-1.5-pro' ? ' selected' : '';
$claudeApiKeyVal = self::h($config['claude_api_key'] ?? '');
$claudeModelVal = $config['claude_model'] ?? 'claude-haiku-4-5';
$claudeModelH = $claudeModelVal === 'claude-haiku-4-5' ? ' selected' : '';
$claudeModelS = $claudeModelVal === 'claude-sonnet-5' ? ' selected' : '';
$claudeModelO = $claudeModelVal === 'claude-opus-4-8' ? ' selected' : '';
// Determine which provider panel to show initially // Determine which provider panel to show initially
$showOpenai = ($aiProviderVal === 'openai' || $aiProviderVal === '') $showOpenai = ($aiProviderVal === 'openai' || $aiProviderVal === '')
? 'display:flex;flex-direction:column;gap:10px' : 'display:none'; ? 'display:flex;flex-direction:column;gap:10px' : 'display:none';
$showGemini = $aiProviderVal === 'gemini' $showGemini = $aiProviderVal === 'gemini'
? 'display:flex;flex-direction:column;gap:10px' : 'display:none'; ? 'display:flex;flex-direction:column;gap:10px' : 'display:none';
$showClaude = $aiProviderVal === 'claude'
? 'display:flex;flex-direction:column;gap:10px' : 'display:none';
// Per-category config HTML // Per-category config HTML
$perTypeConfig = $config['per_type'] ?? []; $perTypeConfig = $config['per_type'] ?? [];
$menuKeysList = array_keys($menus); $menuKeysList = array_keys($menus);
$catLabels = ['1' => 'Categoría 1 — Solo reporta', '2' => 'Categoría 2 — Solo recibe informes', '3' => 'Categoría 3 — Reporta y recibe']; $flowKeysList = array_keys($config['flows'] ?? []);
// Include per_type menus/flows in the option lists
foreach ($config['per_type'] ?? [] as $pt) {
foreach (array_keys($pt['menus'] ?? []) as $k) if (!in_array($k, $menuKeysList)) $menuKeysList[] = $k;
foreach (array_keys($pt['flows'] ?? []) as $k) if (!in_array($k, $flowKeysList)) $flowKeysList[] = $k;
}
$catMeta = [
'1' => ['label' => 'Categoría 1 — Solo reporta', 'icon' => '📤', 'bg' => '#f0fdf4', 'bd' => '#86efac', 'clr' => '#166534'],
'2' => ['label' => 'Categoría 2 — Solo recibe informes', 'icon' => '📥', 'bg' => '#eff6ff', 'bd' => '#93c5fd', 'clr' => '#1e40af'],
'3' => ['label' => 'Categoría 3 — Reporta y recibe', 'icon' => '🔄', 'bg' => '#fdf4ff', 'bd' => '#e879f9', 'clr' => '#701a75'],
];
$categoryTabHtml = ''; $categoryTabHtml = '';
// Build shared option lists
$menuOptsBase = '<option value="">— Sin menú —</option>';
foreach ($menuKeysList as $mk) $menuOptsBase .= '<option value="' . self::h($mk) . '">' . self::h($mk) . '</option>';
$flowOptsBase = '<option value="">— Ninguno —</option>';
foreach ($menuKeysList as $mk) $flowOptsBase .= '<option value="' . self::h($mk) . '">📑 ' . self::h($mk) . '</option>';
foreach ($flowKeysList as $fk) $flowOptsBase .= '<option value="' . self::h($fk) . '">🔀 ' . self::h($fk) . '</option>';
foreach ([1, 2, 3] as $cat) { foreach ([1, 2, 3] as $cat) {
$catCfg = $perTypeConfig[(string)$cat] ?? []; $catCfg = $perTypeConfig[(string)$cat] ?? [];
$m = $catMeta[(string)$cat];
$greetFlow = self::h($catCfg['greeting_flow'] ?? '');
$fallback = $catCfg['fallback_flow'] ?? '';
$selMenu = $catCfg['greeting_menu'] ?? ''; $selMenu = $catCfg['greeting_menu'] ?? '';
$opts = '<option value="">— Usa configuración global —</option>';
foreach ($menuKeysList as $mk) { // greeting_menu selector (insert selected attr)
$s = $selMenu === $mk ? ' selected' : ''; $menuOpts = str_replace(
$opts .= '<option value="' . self::h($mk) . '"' . $s . '>' . self::h($mk) . '</option>'; 'value="' . self::h($selMenu) . '"',
} 'value="' . self::h($selMenu) . '" selected',
$label = $catLabels[(string)$cat]; $menuOptsBase
);
// fallback selector (insert selected attr)
$fbOpts = str_replace(
'value="' . self::h($fallback) . '"',
'value="' . self::h($fallback) . '" selected',
$flowOptsBase
);
// Mostrar greeting_menu solo cuando NO hay greeting_flow (cat 1)
$hasGreetFlow = $greetFlow !== '';
$greetFlowStyle = '';
$greetMenuStyle = $hasGreetFlow ? 'display:none' : '';
$label = $m['label']; $icon = $m['icon']; $bg = $m['bg']; $bd = $m['bd']; $clr = $m['clr'];
$categoryTabHtml .= <<<CATHTML $categoryTabHtml .= <<<CATHTML
<div style="background:#f8f9fd;border:1px solid #eef1f5;border-radius:10px;padding:18px;margin-bottom:16px"> <div style="background:{$bg};border:1px solid {$bd};border-radius:10px;padding:18px;margin-bottom:16px">
<div style="font-weight:700;font-size:13px;color:#0b3d91;margin-bottom:12px">{$label}</div> <div style="display:flex;align-items:center;gap:8px;margin-bottom:14px">
<div class="form-group" style="margin-bottom:0"> <span style="font-size:20px">{$icon}</span>
<label>Menú de bienvenida (greeting menu)</label> <span style="font-weight:700;font-size:13px;color:{$clr}">{$label}</span>
</div>
<div class="form-group" style="{$greetFlowStyle};margin-bottom:12px">
<label>Flujo de bienvenida</label>
<input type="text" name="per_type_greeting_flow[{$cat}]" value="{$greetFlow}"
list="ptFlowsDatalist" placeholder="ej: ask_finca (se auto-selecciona si hay 1 sola opción)"
style="width:100%;padding:8px 12px;border:1px solid #e2e5ea;border-radius:8px;font-size:13px">
<div style="font-size:11px;color:#7a8291;margin-top:3px">
Flow que se ejecuta al primer mensaje. Si el flow retorna 1 sola opción (finca, empresa, etc.) la selecciona automáticamente sin preguntar.
</div>
</div>
<div id="cat-greet-menu-{$cat}" style="{$greetMenuStyle}" class="form-group" style="margin-bottom:12px">
<label>Menú inicial</label>
<select name="per_type_menu[{$cat}]" style="width:100%;padding:9px 12px;border:1px solid #e2e5ea;border-radius:8px;font-size:13px"> <select name="per_type_menu[{$cat}]" style="width:100%;padding:9px 12px;border:1px solid #e2e5ea;border-radius:8px;font-size:13px">
{$opts} {$menuOpts}
</select> </select>
<div style="font-size:11px;color:#7a8291;margin-top:4px">Menú que se muestra cuando el usuario escribe por primera vez o sin contexto activo</div> <div style="font-size:11px;color:#7a8291;margin-top:3px">Menú que se muestra cuando no hay flujo de bienvenida configurado</div>
</div> </div>
<div class="form-group" style="margin-bottom:0">
<label>Flujo de respaldo (fallback)</label>
<select name="per_type_fallback[{$cat}]" style="width:100%;padding:9px 12px;border:1px solid #e2e5ea;border-radius:8px;font-size:13px">
{$fbOpts}
</select>
<div style="font-size:11px;color:#7a8291;margin-top:3px">Se muestra cuando el usuario envía algo que no corresponde a ninguna opción</div>
</div> </div>
</div>
CATHTML; CATHTML;
} }
$aiModelMini = $aiModelVal === 'gpt-4o-mini' ? ' selected' : ''; $aiModelMini = $aiModelVal === 'gpt-4o-mini' ? ' selected' : '';
@@ -2976,6 +3321,7 @@ HTML;
</tbody> </tbody>
</table> </table>
<button type="button" class="add-btn" onclick="addCmd()">+ Agregar comando</button> <button type="button" class="add-btn" onclick="addCmd()">+ Agregar comando</button>
<div style="margin-top:14px"><button type="submit" class="btn-primary">💾 Guardar</button></div>
</div> </div>
<!-- ─── MENUS ────────────────────────────────────────────────────────── --> <!-- ─── MENUS ────────────────────────────────────────────────────────── -->
@@ -3065,6 +3411,7 @@ HTML;
echo <<<HTML echo <<<HTML
</div> </div>
<button type="button" class="add-btn" onclick="addMenu()">+ Agregar menú</button> <button type="button" class="add-btn" onclick="addMenu()">+ Agregar menú</button>
<div style="margin-top:14px"><button type="submit" class="btn-primary">💾 Guardar</button></div>
</div> </div>
<!-- ─── FLOWS ────────────────────────────────────────────────────────── --> <!-- ─── FLOWS ────────────────────────────────────────────────────────── -->
@@ -3092,6 +3439,7 @@ HTML;
$flowIdx = 0; $flowIdx = 0;
foreach ($flows as $fk => $flow) { foreach ($flows as $fk => $flow) {
$ft = $flow['type'] ?? 'text'; $ft = $flow['type'] ?? 'text';
$fNluDesc = self::h($flow['nlu_description'] ?? '');
$fMsg = self::h($flow['message'] ?? ''); $fMsg = self::h($flow['message'] ?? '');
$fFn = $flow['function'] ?? 'forward_to_ai'; $fFn = $flow['function'] ?? 'forward_to_ai';
$fMenu = $flow['menu'] ?? ''; $fMenu = $flow['menu'] ?? '';
@@ -3207,6 +3555,8 @@ HTML;
<div class=\"item-card\" id=\"flow-card-{$flowIdx}\"> <div class=\"item-card\" id=\"flow-card-{$flowIdx}\">
<div class=\"item-head\"> <div class=\"item-head\">
<span>ID: <input type=\"text\" name=\"flow_key[]\" value=\"" . self::h($fk) . "\" style=\"border:none;background:transparent;font-weight:700;color:#0b3d91;width:180px;font-size:13px\"></span> <span>ID: <input type=\"text\" name=\"flow_key[]\" value=\"" . self::h($fk) . "\" style=\"border:none;background:transparent;font-weight:700;color:#0b3d91;width:180px;font-size:13px\"></span>
<span style=\"font-size:11px;color:#7a8291;font-weight:400;margin-left:8px\">🧠 NLU:</span>
<input type=\"text\" name=\"flow_nlu_desc[]\" value=\"{$fNluDesc}\" placeholder=\"Descripción para IA (ej: Registrar pluviometría)\" style=\"border:none;background:#f0f4ff;border-radius:4px;padding:2px 8px;font-size:11px;color:#374151;flex:1;min-width:0\">
<button type=\"button\" class=\"del\" onclick=\"this.closest('.item-card').remove()\">✕</button> <button type=\"button\" class=\"del\" onclick=\"this.closest('.item-card').remove()\">✕</button>
</div> </div>
<div class=\"form-row\" style=\"align-items:flex-start;gap:10px\"> <div class=\"form-row\" style=\"align-items:flex-start;gap:10px\">
@@ -3340,12 +3690,19 @@ HTML;
echo <<<HTML echo <<<HTML
</div> </div>
<button type="button" class="add-btn" onclick="addFlowCard()">+ Agregar flujo</button> <button type="button" class="add-btn" onclick="addFlowCard()">+ Agregar flujo</button>
<div style="margin-top:14px"><button type="submit" class="btn-primary">💾 Guardar</button></div>
</div> </div>
<!-- ─── POR CATEGORÍA ────────────────────────────────────────────────── --> <!-- ─── POR CATEGORÍA ────────────────────────────────────────────────── -->
<div class="tab-content" id="tab-categories"> <div class="tab-content" id="tab-categories">
<div class="card-h">👥 Configuración por Categoría</div> <div class="card-h">👥 Configuración por Categoría</div>
<p style="font-size:12px;color:#7a8291;margin-bottom:16px">Define qué menú de bienvenida ve cada categoría de usuario cuando escribe por primera vez o sin contexto activo.</p> <p style="font-size:12px;color:#7a8291;margin-bottom:16px">Define el comportamiento inicial de cada categoría de usuario: si pide finca, qué menú muestra y qué hacer ante texto no reconocido.</p>
<datalist id="ptFlowsDatalist">
HTML;
foreach ($menuKeysList as $mk) echo '<option value="' . self::h($mk) . '">📑 Menú</option>' . "\n";
foreach ($flowKeysList as $fk) echo '<option value="' . self::h($fk) . '">🔀 Flow</option>' . "\n";
echo <<<HTML
</datalist>
{$categoryTabHtml} {$categoryTabHtml}
<button type="button" id="savePerTypeBtn" onclick="savePerType()" class="btn-primary" style="margin-top:8px">💾 Guardar categorías</button> <button type="button" id="savePerTypeBtn" onclick="savePerType()" class="btn-primary" style="margin-top:8px">💾 Guardar categorías</button>
</div> </div>
@@ -3359,6 +3716,7 @@ HTML;
<option value="">Usar configuración global</option> <option value="">Usar configuración global</option>
<option value="openai"{$aiProviderOpenai}>OpenAI (GPT)</option> <option value="openai"{$aiProviderOpenai}>OpenAI (GPT)</option>
<option value="gemini"{$aiProviderGemini}>Google Gemini</option> <option value="gemini"{$aiProviderGemini}>Google Gemini</option>
<option value="claude"{$aiProviderClaude}>Anthropic Claude</option>
<option value="mock"{$aiProviderMock}>Mock (simulado)</option> <option value="mock"{$aiProviderMock}>Mock (simulado)</option>
</select> </select>
<div style="font-size:11px;color:#7a8291;margin-top:3px">Si no se selecciona, usa el proveedor de Configuración General</div> <div style="font-size:11px;color:#7a8291;margin-top:3px">Si no se selecciona, usa el proveedor de Configuración General</div>
@@ -3375,7 +3733,9 @@ HTML;
<label>Modelo</label> <label>Modelo</label>
<select name="ai_model"> <select name="ai_model">
<option value="gpt-4o-mini"{$aiModelMini}>GPT-4o Mini</option> <option value="gpt-4o-mini"{$aiModelMini}>GPT-4o Mini</option>
<option value="gpt-4.1-mini"{$aiModel41mini}>GPT-4.1 Mini</option>
<option value="gpt-4o"{$aiModel4o}>GPT-4o</option> <option value="gpt-4o"{$aiModel4o}>GPT-4o</option>
<option value="gpt-4.1"{$aiModel41}>GPT-4.1</option>
<option value="gpt-3.5-turbo"{$aiModel35}>GPT-3.5 Turbo</option> <option value="gpt-3.5-turbo"{$aiModel35}>GPT-3.5 Turbo</option>
</select> </select>
</div> </div>
@@ -3392,6 +3752,7 @@ HTML;
<div class="form-group"> <div class="form-group">
<label>Modelo Gemini</label> <label>Modelo Gemini</label>
<select name="gemini_model"> <select name="gemini_model">
<option value="gemini-2.5-flash"{$geminiModelF25}>Gemini 2.5 Flash</option>
<option value="gemini-2.0-flash"{$geminiModelF20}>Gemini 2.0 Flash</option> <option value="gemini-2.0-flash"{$geminiModelF20}>Gemini 2.0 Flash</option>
<option value="gemini-1.5-flash"{$geminiModelF15}>Gemini 1.5 Flash</option> <option value="gemini-1.5-flash"{$geminiModelF15}>Gemini 1.5 Flash</option>
<option value="gemini-1.5-pro"{$geminiModelP15}>Gemini 1.5 Pro</option> <option value="gemini-1.5-pro"{$geminiModelP15}>Gemini 1.5 Pro</option>
@@ -3400,6 +3761,44 @@ HTML;
</div> </div>
</div> </div>
<!-- Claude fields -->
<div id="ai-claude-fields" style="{$showClaude}">
<div class="form-row">
<div class="form-group">
<label>API Key Anthropic</label>
<input type="password" name="claude_api_key" value="{$claudeApiKeyVal}" placeholder="sk-ant-...">
</div>
<div class="form-group">
<label>Modelo Claude</label>
<select name="claude_model">
<option value="claude-haiku-4-5"{$claudeModelH}>Claude Haiku 4.5 (rápido)</option>
<option value="claude-sonnet-5"{$claudeModelS}>Claude Sonnet 5</option>
<option value="claude-opus-4-8"{$claudeModelO}>Claude Opus 4.8</option>
</select>
</div>
</div>
</div>
<!-- Whisper server (auto-hosted) -->
<div class="form-group" style="margin-top:12px;padding:12px;background:#f0f7f0;border:1px solid #c6e0c6;border-radius:8px">
<label style="font-weight:600">🎙️ Servidor Whisper propio (transcripción de audio)</label>
<div style="font-size:11px;color:#4a5568;margin-bottom:8px">Si se configura, el audio se transcribe aquí en vez del proveedor seleccionado arriba.</div>
<div class="form-group">
<label>URL del servidor</label>
<input type="text" name="whisper_url" value="{$whisperUrlVal}" placeholder="https://whisper.u-s.app/asr">
</div>
<div class="form-row">
<div class="form-group">
<label>Usuario</label>
<input type="text" name="whisper_user" value="{$whisperUserVal}" placeholder="whisper">
</div>
<div class="form-group">
<label>Contraseña</label>
<input type="password" name="whisper_pass" value="{$whisperPassVal}">
</div>
</div>
</div>
<div class="form-row" style="margin-top:4px"> <div class="form-row" style="margin-top:4px">
<div class="form-group"> <div class="form-group">
<label>Temperatura ({$aiTempVal})</label> <label>Temperatura ({$aiTempVal})</label>
@@ -3421,6 +3820,19 @@ HTML;
Si está desactivado, el bot muestra el menú de categoría directamente. Si está desactivado, el bot muestra el menú de categoría directamente.
</div> </div>
</div> </div>
<div class="form-group" style="margin-top:12px;padding:14px;background:#f0f4ff;border:1px solid #c7d7f7;border-radius:10px">
<label style="display:flex;align-items:center;gap:10px;cursor:pointer">
<input type="checkbox" name="nlu" value="1" {$nluChecked}>
<span style="font-weight:700">🧠 Habilitar NLU enrutamiento por IA</span>
</label>
<div style="font-size:11px;color:#4a5568;margin-top:6px;line-height:1.6">
Cuando el usuario escribe algo que el bot no reconoce, la IA lo ubica en el menú o flujo correcto según sus permisos.<br>
Audio transcripción automática (Whisper/Gemini) enrutado.<br>
Imagen la IA extrae el contexto enrutado.<br>
<strong>Requiere bot_type = hybrid y proveedor de IA configurado.</strong>
</div>
</div>
<div style="margin-top:14px"><button type="button" id="saveAiBtn" onclick="saveAiConfig()" class="btn-primary">💾 Guardar IA</button></div>
</div> </div>
<!-- ─── GENERAL ──────────────────────────────────────────────────────── --> <!-- ─── GENERAL ──────────────────────────────────────────────────────── -->
@@ -3479,6 +3891,7 @@ HTML;
<div style="font-size:10px;color:#a0a8b8;text-align:center;margin-top:6px">Vista previa aproximada</div> <div style="font-size:10px;color:#a0a8b8;text-align:center;margin-top:6px">Vista previa aproximada</div>
</div> </div>
</div> </div>
<div style="margin-top:14px"><button type="submit" class="btn-primary">💾 Guardar</button></div>
</div> </div>
<div style="display:flex;gap:12px;margin-top:16px"> <div style="display:flex;gap:12px;margin-top:16px">
@@ -3497,8 +3910,12 @@ function savePerType() {
const fd = new FormData(); const fd = new FormData();
fd.set('company_id', cid); fd.set('company_id', cid);
[1, 2, 3].forEach(function(cat) { [1, 2, 3].forEach(function(cat) {
var gf = document.querySelector('[name="per_type_greeting_flow[' + cat + ']"]');
var sel = document.querySelector('[name="per_type_menu[' + cat + ']"]'); var sel = document.querySelector('[name="per_type_menu[' + cat + ']"]');
var fb = document.querySelector('[name="per_type_fallback[' + cat + ']"]');
if (gf) fd.append('per_type_greeting_flow[' + cat + ']', gf.value);
if (sel) fd.append('per_type_menu[' + cat + ']', sel.value); if (sel) fd.append('per_type_menu[' + cat + ']', sel.value);
if (fb) fd.append('per_type_fallback[' + cat + ']', fb.value);
}); });
var btn = document.getElementById('savePerTypeBtn'); var btn = document.getElementById('savePerTypeBtn');
if (btn) { btn.textContent = 'Guardando...'; btn.disabled = true; } if (btn) { btn.textContent = 'Guardando...'; btn.disabled = true; }
@@ -3514,6 +3931,45 @@ function savePerType() {
}); });
} }
function saveAiConfig() {
const cid = document.querySelector('input[name="company_id"]').value;
const fd = new FormData();
fd.set('company_id', cid);
const qs = sel => document.querySelector(sel);
fd.set('ai_provider', qs('select[name="ai_provider"]')?.value || '');
fd.set('ai_model', qs('select[name="ai_model"]')?.value || 'gpt-4o-mini');
fd.set('ai_temperature', qs('input[name="ai_temperature"]')?.value || '0.7');
fd.set('ai_prompt', qs('textarea[name="ai_prompt"]')?.value || '');
fd.set('gemini_model', qs('select[name="gemini_model"]')?.value || '');
fd.set('claude_model', qs('select[name="claude_model"]')?.value || '');
const oKey = qs('input[name="openai_api_key"]')?.value || '';
const gKey = qs('input[name="gemini_api_key"]')?.value || '';
const cKey = qs('input[name="claude_api_key"]')?.value || '';
if (oKey) fd.set('openai_api_key', oKey);
if (gKey) fd.set('gemini_api_key', gKey);
if (cKey) fd.set('claude_api_key', cKey);
fd.set('whisper_url', qs('input[name="whisper_url"]')?.value || '');
fd.set('whisper_user', qs('input[name="whisper_user"]')?.value || '');
const wPass = qs('input[name="whisper_pass"]')?.value || '';
if (wPass) fd.set('whisper_pass', wPass);
if (qs('input[name="ai_for_media"]')?.checked) fd.set('ai_for_media', '1');
if (qs('input[name="nlu"]')?.checked) fd.set('nlu', '1');
const btn = document.getElementById('saveAiBtn');
if (btn) { btn.textContent = 'Guardando...'; btn.disabled = true; }
fetch('/admin/bot-config/save-ai', { method: 'POST', body: fd })
.then(r => r.json())
.then(d => {
if (btn) { btn.textContent = '💾 Guardar IA'; btn.disabled = false; }
if (d.ok) { btn.textContent = '✅ Guardado'; setTimeout(() => { btn.textContent = '💾 Guardar IA'; }, 2000); }
else alert('❌ Error al guardar: ' + JSON.stringify(d));
})
.catch(e => {
if (btn) { btn.textContent = '💾 Guardar IA'; btn.disabled = false; }
alert('❌ Error de red: ' + e.message);
});
}
function switchTab(tab, btn) { function switchTab(tab, btn) {
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active')); document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-btn').forEach(t => t.classList.remove('active')); document.querySelectorAll('.tab-btn').forEach(t => t.classList.remove('active'));
@@ -3665,6 +4121,8 @@ function addFlowCard() {
'<div class="item-card" id="' + cardId + '">' + '<div class="item-card" id="' + cardId + '">' +
' <div class="item-head">' + ' <div class="item-head">' +
' <span>ID: <input type="text" name="flow_key[]" value="" style="border:none;background:transparent;font-weight:700;color:#0b3d91;width:180px;font-size:13px" placeholder="nuevo_flujo"></span>' + ' <span>ID: <input type="text" name="flow_key[]" value="" style="border:none;background:transparent;font-weight:700;color:#0b3d91;width:180px;font-size:13px" placeholder="nuevo_flujo"></span>' +
' <span style="font-size:11px;color:#7a8291;font-weight:400;margin-left:8px">🧠 NLU:</span>' +
' <input type="text" name="flow_nlu_desc[]" value="" placeholder="Descripción para IA (ej: Registrar pluviometría)" style="border:none;background:#f0f4ff;border-radius:4px;padding:2px 8px;font-size:11px;color:#374151;flex:1;min-width:0">' +
' <button type="button" class="del" onclick="this.closest(\'.item-card\').remove()">✕</button>' + ' <button type="button" class="del" onclick="this.closest(\'.item-card\').remove()">✕</button>' +
' </div>' + ' </div>' +
' <div class="form-row" style="align-items:flex-start;gap:10px">' + ' <div class="form-row" style="align-items:flex-start;gap:10px">' +
@@ -3787,14 +4245,15 @@ function addCapField(containerId) {
} }
function aiProviderChange(v) { function aiProviderChange(v) {
const oai = document.getElementById('ai-openai-fields'); const panels = {openai: 'ai-openai-fields', gemini: 'ai-gemini-fields', claude: 'ai-claude-fields'};
const gem = document.getElementById('ai-gemini-fields'); Object.entries(panels).forEach(([key, id]) => {
if (oai) oai.style.display = (v === 'openai' || v === '') ? 'flex' : 'none'; const el = document.getElementById(id);
if (oai) oai.style.flexDirection = 'column'; if (!el) return;
if (oai) oai.style.gap = '10px'; const show = (key === 'openai' && (v === 'openai' || v === '')) || v === key;
if (gem) gem.style.display = v === 'gemini' ? 'flex' : 'none'; el.style.display = show ? 'flex' : 'none';
if (gem) gem.style.flexDirection = 'column'; el.style.flexDirection = 'column';
if (gem) gem.style.gap = '10px'; el.style.gap = '10px';
});
} }
function updatePreview() { function updatePreview() {
@@ -4811,12 +5270,21 @@ function saveConfig() {
// General // General
fd.append('greeting', fullConfig.greeting || ''); fd.append('greeting', fullConfig.greeting || '');
fd.append('fallback', fullConfig.fallback || ''); fd.append('fallback', fullConfig.fallback || '');
fd.append('ai_prompt', fullConfig.ai_prompt || ''); // AI fields — leer del DOM porque el usuario puede editarlos sin tocar el CONFIG JS
fd.append('ai_model', fullConfig.ai_model || 'gpt-4o-mini'); const _qs = sel => document.querySelector(sel);
fd.append('ai_temperature', String(fullConfig.ai_temperature || 0.7)); fd.set('ai_provider', _qs('select[name="ai_provider"]')?.value || fullConfig.ai_provider || '');
fd.append('ai_provider', fullConfig.ai_provider || ''); fd.set('ai_prompt', _qs('textarea[name="ai_prompt"]')?.value || fullConfig.ai_prompt || '');
fd.append('approval_webhook', fullConfig.approval_webhook || ''); fd.set('ai_model', _qs('select[name="ai_model"]')?.value || fullConfig.ai_model || 'gpt-4o-mini');
fd.append('ignore_prefixes', (fullConfig.ignore_prefixes || []).join('\\n')); fd.set('ai_temperature', _qs('input[name="ai_temperature"]')?.value || String(fullConfig.ai_temperature || 0.7));
fd.set('openai_api_key', _qs('input[name="openai_api_key"]')?.value || '');
fd.set('gemini_api_key', _qs('input[name="gemini_api_key"]')?.value || '');
fd.set('gemini_model', _qs('select[name="gemini_model"]')?.value || '');
fd.set('claude_api_key', _qs('input[name="claude_api_key"]')?.value || '');
fd.set('claude_model', _qs('select[name="claude_model"]')?.value || '');
if (_qs('input[name="ai_for_media"]')?.checked) fd.set('ai_for_media', '1');
if (_qs('input[name="nlu"]')?.checked) fd.set('nlu', '1');
fd.set('approval_webhook', fullConfig.approval_webhook || '');
fd.set('ignore_prefixes', (fullConfig.ignore_prefixes || []).join('\\n'));
// Per-category menus — read from DOM so they're preserved on every AJAX save // Per-category menus — read from DOM so they're preserved on every AJAX save
[1, 2, 3].forEach(cat => { [1, 2, 3].forEach(cat => {
@@ -5313,4 +5781,117 @@ HTML;
} }
exit; exit;
} }
public static function aiLogs(): void
{
SessionAuth::require();
$page = max(1, (int)($_GET['page'] ?? 1));
$limit = 50;
$offset = ($page - 1) * $limit;
$provider = $_GET['provider'] ?? '';
$company = (int)($_GET['company'] ?? 0);
$where = [];
$params = [];
if ($provider !== '') { $where[] = 'provider = ?'; $params[] = $provider; }
if ($company > 0) { $where[] = 'company_id = ?'; $params[] = $company; }
$wSql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
$stmt = db()->prepare("SELECT COUNT(*) FROM ai_logs $wSql");
$stmt->execute($params);
$total = (int)$stmt->fetchColumn();
$stmt2 = db()->prepare("SELECT * FROM ai_logs $wSql ORDER BY id DESC LIMIT $limit OFFSET $offset");
$stmt2->execute($params);
$rows = $stmt2->fetchAll(\PDO::FETCH_ASSOC);
$companies = db()->query("SELECT id, name FROM companies ORDER BY name")->fetchAll(\PDO::FETCH_ASSOC);
$companyMap = [];
foreach ($companies as $c) $companyMap[(int)$c['id']] = self::h($c['name']);
$pages = (int)ceil($total / $limit);
$companyOptions = '<option value="">Todas las empresas</option>';
foreach ($companies as $c) {
$sel = ($company === (int)$c['id']) ? ' selected' : '';
$companyOptions .= '<option value="' . (int)$c['id'] . '"' . $sel . '>' . self::h($c['name']) . '</option>';
}
$providerOptions = '';
foreach (['', 'openai', 'gemini', 'claude', 'whisper-own'] as $p) {
$label = $p === '' ? 'Todos' : ucfirst($p);
$sel = $provider === $p ? ' selected' : '';
$providerOptions .= "<option value=\"{$p}\"{$sel}>{$label}</option>";
}
$rows_html = '';
foreach ($rows as $r) {
$cid = (int)($r['company_id'] ?? 0);
$cname = $companyMap[$cid] ?? '<span style="color:#aaa">—</span>';
$badge = match($r['provider'] ?? '') {
'openai' => 'background:#10a37f;color:#fff',
'gemini' => 'background:#4285f4;color:#fff',
'claude' => 'background:#d97706;color:#fff',
'whisper-own' => 'background:#7c3aed;color:#fff',
default => 'background:#6b7280;color:#fff',
};
$tok = ($r['tokens_in'] ?? null) !== null
? self::h($r['tokens_in']) . '+' . self::h($r['tokens_out'])
: '—';
$rows_html .= '<tr>'
. '<td style="color:#9ca3af;font-size:11px">' . self::h(substr($r['created_at'] ?? '', 0, 16)) . '</td>'
. '<td>' . $cname . '</td>'
. '<td>' . self::h($r['phone_number'] ?? '') . '</td>'
. '<td><span style="' . $badge . ';border-radius:4px;padding:2px 7px;font-size:11px">' . self::h($r['provider']) . '</span></td>'
. '<td style="font-size:11px">' . self::h($r['model'] ?? '') . '</td>'
. '<td><span style="background:#f3f4f6;border-radius:4px;padding:2px 6px;font-size:11px">' . self::h($r['call_type'] ?? '') . '</span></td>'
. '<td style="font-size:11px;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="' . self::h($r['input_preview'] ?? '') . '">' . self::h(mb_substr($r['input_preview'] ?? '', 0, 60)) . '</td>'
. '<td style="font-size:11px;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="' . self::h($r['output_preview'] ?? '') . '">' . self::h(mb_substr($r['output_preview'] ?? '', 0, 60)) . '</td>'
. '<td style="text-align:right;font-size:11px">' . $tok . '</td>'
. '<td style="text-align:right;font-size:11px">' . number_format((int)($r['duration_ms'] ?? 0)) . 'ms</td>'
. '</tr>';
}
$pager = '';
for ($i = 1; $i <= $pages; $i++) {
$active = $i === $page ? ' style="font-weight:bold"' : '';
$url = '/admin/ai-logs?page=' . $i . ($provider ? '&provider=' . urlencode($provider) : '') . ($company ? '&company=' . $company : '');
$pager .= "<a href=\"{$url}\"{$active} class=\"btn-sm\">{$i}</a> ";
}
$user = SessionAuth::user();
echo Layout::open('IA Logs', 'ai-logs', $user['name'] ?? 'Admin');
?>
<style>
.filter-bar{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:16px}
.filter-bar select{padding:5px 10px;border:1px solid #d1d5db;border-radius:6px;font-size:13px}
.log-table{width:100%;border-collapse:collapse;font-size:13px}
.log-table th{background:#f9fafb;padding:8px 10px;text-align:left;border-bottom:2px solid #e5e7eb;font-size:11px;text-transform:uppercase;color:#6b7280;white-space:nowrap}
.log-table td{padding:7px 10px;border-bottom:1px solid #f3f4f6;vertical-align:middle}
.log-table tr:hover td{background:#fafafa}
@media(prefers-color-scheme:dark){.log-table th{background:#1f2937;border-color:#374151;color:#9ca3af}.log-table td{border-color:#1f2937}.log-table tr:hover td{background:#111827}}
</style>
<div class="card" style="padding:20px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<h2 style="margin:0">🤖 IA Logs <span style="font-size:14px;color:#9ca3af;font-weight:400">(<?= number_format($total) ?> registros)</span></h2>
</div>
<form class="filter-bar" method="get">
<select name="provider" onchange="this.form.submit()"><?= $providerOptions ?></select>
<select name="company" onchange="this.form.submit()"><?= $companyOptions ?></select>
</form>
<div style="overflow-x:auto">
<table class="log-table">
<thead><tr>
<th>Fecha</th><th>Empresa</th><th>Teléfono</th><th>Proveedor</th><th>Modelo</th>
<th>Tipo</th><th>Entrada</th><th>Salida</th><th>Tokens</th><th>Duración</th>
</tr></thead>
<tbody><?= $rows_html ?: '<tr><td colspan="10" style="text-align:center;color:#9ca3af;padding:30px">Sin registros</td></tr>' ?></tbody>
</table>
</div>
<?php if ($pages > 1): ?><div style="margin-top:12px"><?= $pager ?></div><?php endif; ?>
</div>
<?php
echo Layout::close();
}
} }
+1
View File
@@ -53,6 +53,7 @@ class Layout
['bot-config', '/admin/bot-config', 'bot-config', 'Bot'], ['bot-config', '/admin/bot-config', 'bot-config', 'Bot'],
['conv-flow', '/admin/conversation-flow', 'conv-flow', 'Flujos'], ['conv-flow', '/admin/conversation-flow', 'conv-flow', 'Flujos'],
['settings', '/admin/settings', 'settings', 'Ajustes'], ['settings', '/admin/settings', 'settings', 'Ajustes'],
['ai-logs', '/admin/ai-logs', 'live', 'IA Logs'],
], ],
]; ];
+167 -26
View File
@@ -167,48 +167,168 @@ class WpWebhook
} }
} }
// Resolución principal: busca el número remitente en company_phones // Devuelve todas las empresas a las que pertenece el número
private static function resolveCompanyByNumber(string $from): bool private static function findCompaniesByNumber(string $from): array
{ {
if ($from === '') { if ($from === '') return [];
self::$currentCompany = null;
return false;
}
try { try {
$stmt = db()->prepare(" $stmt = db()->prepare("
SELECT cp.company_id, cp.permission_type SELECT cp.company_id, cp.permission_type
FROM company_phones cp FROM company_phones cp
WHERE cp.wa_number = ? AND cp.is_active = 1 WHERE cp.wa_number = ? AND cp.is_active = 1
LIMIT 1 ORDER BY cp.company_id ASC
"); ");
$stmt->execute([$from]); $stmt->execute([$from]);
$row = $stmt->fetch(\PDO::FETCH_ASSOC); return $stmt->fetchAll(\PDO::FETCH_ASSOC);
} catch (\PDOException $e) { } catch (\PDOException $e) {
self::log('ERROR', 'resolveCompanyByNumber DB: ' . $e->getMessage()); self::log('ERROR', 'findCompaniesByNumber DB: ' . $e->getMessage());
self::$currentCompany = null; return [];
return false; }
} }
if (!$row) { // Pre-menú multi-empresa: retorna true si la empresa quedó resuelta, false si hay que esperar al usuario
self::$currentCompany = null; private static function handleMultiCompany(string $from, string $name, array $companies, string $rawText, string $phoneNumberId, string $msgType = 'text'): bool
self::log('WARN', "Número {$from} no registrado en ninguna empresa"); {
// 1. El usuario tocó un botón de selección de empresa
if (str_starts_with($rawText, '__co_')) {
$selectedId = (int)substr($rawText, 5);
$company = CompanyRepository::findById($selectedId);
if (!$company) {
self::sendCompanyPreMenu($from, $phoneNumberId, $name, $companies);
return false; return false;
} }
try {
$company = CompanyRepository::findById((int)$row['company_id']); db()->prepare("
if ($company === null) { INSERT INTO multi_company_sessions (wa_number, companies_json, selected_id, expires_at)
self::$currentCompany = null; VALUES (?, ?, ?, DATE_ADD(NOW(), INTERVAL 8 HOUR))
return false; ON DUPLICATE KEY UPDATE selected_id = VALUES(selected_id), expires_at = VALUES(expires_at)
")->execute([$from, json_encode($companies), $selectedId]);
} catch (\PDOException $e) {
self::log('ERROR', 'handleMultiCompany save session: ' . $e->getMessage());
} }
$company['_permission_type'] = self::getPermissionType($companies, $selectedId);
self::$currentCompany = $company;
$displayName = $company['display_name'] ?: $company['name'];
// Adjuntar tipo de permiso al contexto de empresa // Mensaje 1: confirmación
$company['_permission_type'] = (int)$row['permission_type']; WhatsAppSender::sendText($from, "✅ Conectado con *{$displayName}*.", $phoneNumberId);
// Mensaje 2: greeting del bot — reset contexto y disparar con mensaje vacío
$botCtx = ConversationContext::getOrCreate((int)$selectedId, $from, 'normal');
ConversationContext::reset((int)$botCtx['id']);
$synthCtx = [
'from' => $from,
'name' => $name,
'message_id' => 'mc_' . uniqid(),
'type' => 'text',
'timestamp' => time(),
'phone_number_id' => $phoneNumberId,
'display_phone' => '',
'permission_type' => self::getPermissionType($companies, $selectedId),
];
BotRouter::route($company, $synthCtx, '', 'text');
self::log('INFO', "Multi-empresa: {$from} seleccionó empresa #{$selectedId} ({$displayName})");
return false;
}
// 2. Keyword de reinicio → borrar sesión y mostrar pre-menú
// Solo aplica para texto libre; las respuestas interactivas no son keywords
if ($msgType === 'text' && self::isResetKeyword($rawText)) {
try { db()->prepare("DELETE FROM multi_company_sessions WHERE wa_number = ?")->execute([$from]); } catch (\PDOException $e) {}
self::sendCompanyPreMenu($from, $phoneNumberId, $name, $companies);
return false;
}
// 3. Sesión activa → usar empresa guardada
try {
$stmt = db()->prepare("SELECT selected_id FROM multi_company_sessions WHERE wa_number = ? AND selected_id IS NOT NULL AND expires_at > NOW() LIMIT 1");
$stmt->execute([$from]);
$session = $stmt->fetch(\PDO::FETCH_ASSOC);
} catch (\PDOException $e) {
self::log('ERROR', 'handleMultiCompany read session: ' . $e->getMessage());
$session = null;
}
if ($session) {
$selectedId = (int)$session['selected_id'];
try { db()->prepare("UPDATE multi_company_sessions SET expires_at = DATE_ADD(NOW(), INTERVAL 8 HOUR) WHERE wa_number = ?")->execute([$from]); } catch (\PDOException $e) {}
$company = CompanyRepository::findById($selectedId);
if (!$company) {
try { db()->prepare("DELETE FROM multi_company_sessions WHERE wa_number = ?")->execute([$from]); } catch (\PDOException $e) {}
self::sendCompanyPreMenu($from, $phoneNumberId, $name, $companies);
return false;
}
$company['_permission_type'] = self::getPermissionType($companies, $selectedId);
self::$currentCompany = $company; self::$currentCompany = $company;
self::log('INFO', "Número {$from} → empresa: {$company['name']} (permiso tipo {$row['permission_type']})");
return true; return true;
} }
// 4. Sin sesión → mostrar pre-menú
try {
db()->prepare("
INSERT INTO multi_company_sessions (wa_number, companies_json, selected_id, expires_at)
VALUES (?, ?, NULL, DATE_ADD(NOW(), INTERVAL 10 MINUTE))
ON DUPLICATE KEY UPDATE companies_json = VALUES(companies_json), selected_id = NULL, expires_at = VALUES(expires_at)
")->execute([$from, json_encode($companies)]);
} catch (\PDOException $e) {
self::log('ERROR', 'handleMultiCompany insert pending: ' . $e->getMessage());
}
self::sendCompanyPreMenu($from, $phoneNumberId, $name, $companies);
return false;
}
private static function sendCompanyPreMenu(string $to, string $phoneNumberId, string $name, array $companies): void
{
$items = [];
foreach ($companies as $cp) {
$co = CompanyRepository::findById((int)$cp['company_id']);
if ($co) $items[] = ['id' => (int)$cp['company_id'], 'name' => $co['display_name'] ?: $co['name']];
}
if (empty($items)) return;
$greeting = $name ? "Hola *{$name}* 👋\n" : "Hola 👋\n";
$body = $greeting . "¿Con cuál empresa deseas comunicarte?";
if (count($items) <= 3) {
$buttons = [];
foreach ($items as $item) {
$buttons[] = ['type' => 'reply', 'reply' => ['id' => '__co_' . $item['id'], 'title' => mb_substr($item['name'], 0, 20)]];
}
$interactive = ['type' => 'button', 'body' => ['text' => $body], 'action' => ['buttons' => $buttons]];
} else {
$rows = [];
foreach ($items as $item) {
$rows[] = ['id' => '__co_' . $item['id'], 'title' => mb_substr($item['name'], 0, 24)];
}
$interactive = ['type' => 'list', 'body' => ['text' => $body], 'action' => ['button' => 'Ver empresas', 'sections' => [['title' => 'Empresas', 'rows' => $rows]]]];
}
WhatsAppSender::sendInteractive($to, $interactive, $phoneNumberId);
self::log('INFO', "Pre-menú de empresa enviado a {$to} (" . count($items) . " opciones)");
}
/**
* Solo palabras que signifiquen "cambiar de empresa". Antes incluía menu,
* salir, atras, volver, inicio y regresar — el vocabulario de navegación del
* bot— así que a un usuario multi-empresa se le comían todos esos comandos
* acá arriba y nunca llegaban a NormalBot.
*/
private static function isResetKeyword(string $text): bool
{
$n = mb_strtolower(trim($text));
$n = str_replace(['á','é','í','ó','ú','ü','ñ'], ['a','e','i','o','u','u','n'], $n);
return in_array($n, ['cambiar empresa', 'cambiar de empresa', 'empresas', 'reiniciar', 'reset'], true);
}
private static function getPermissionType(array $companies, int $companyId): int
{
foreach ($companies as $cp) {
if ((int)$cp['company_id'] === $companyId) return (int)$cp['permission_type'];
}
return 3;
}
private static function sendRejectionMessage(string $to, string $phoneNumberId): void private static function sendRejectionMessage(string $to, string $phoneNumberId): void
{ {
$token = env('WHATSAPP_ACCESS_TOKEN', ''); $token = env('WHATSAPP_ACCESS_TOKEN', '');
@@ -283,15 +403,34 @@ class WpWebhook
} }
} }
// Resolver empresa por número remitente // Resolver empresa(s) por número remitente
$allowed = self::resolveCompanyByNumber($from); $matchedCompanies = self::findCompaniesByNumber($from);
if (!$allowed) { if (empty($matchedCompanies)) {
self::saveWebhookLog('messages', $from, $name, $type, '[NÚMERO NO HABILITADO]'); self::saveWebhookLog('messages', $from, $name, $type, '[NÚMERO NO HABILITADO]');
self::sendRejectionMessage($from, $phoneNumberId); self::sendRejectionMessage($from, $phoneNumberId);
continue; continue;
} }
if (count($matchedCompanies) === 1) {
$co = CompanyRepository::findById((int)$matchedCompanies[0]['company_id']);
if (!$co) continue;
$co['_permission_type'] = (int)$matchedCompanies[0]['permission_type'];
self::$currentCompany = $co;
self::log('INFO', "Número {$from} → empresa: {$co['name']} (permiso {$matchedCompanies[0]['permission_type']})");
} else {
$rawText = match ($type) {
'text' => $msg['text']['body'] ?? '',
'interactive' => $msg['interactive']['button_reply']['id'] ?? $msg['interactive']['list_reply']['id'] ?? '',
'button' => $msg['button']['payload'] ?? $msg['button']['text'] ?? '',
default => '',
};
if (!self::handleMultiCompany($from, $name, $matchedCompanies, $rawText, $phoneNumberId, $type)) {
continue;
}
self::log('INFO', "Número {$from} → empresa (multi-sel): " . (self::$currentCompany['name'] ?? '?'));
}
$context = [ $context = [
'from' => $from, 'from' => $from,
'name' => $name, 'name' => $name,
@@ -348,6 +487,8 @@ class WpWebhook
$isNew = self::saveConversation($ctx, $preview, $mediaId); $isNew = self::saveConversation($ctx, $preview, $mediaId);
self::forwardToCompany($ctx, $preview, $mediaId); self::forwardToCompany($ctx, $preview, $mediaId);
if ($isNew && self::$currentCompany !== null) { if ($isNew && self::$currentCompany !== null) {
// media_id in context so BotRouter/MediaTranscriber can download the file
$ctx['media_id'] = $mediaId;
BotRouter::route(self::$currentCompany, $ctx, $caption, $type); BotRouter::route(self::$currentCompany, $ctx, $caption, $type);
} }
} }
+21
View File
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../config/env.php';
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../services/CompanyRepository.php';
require_once __DIR__ . '/../services/PhoneSync.php';
$results = PhoneSync::syncAll();
$ts = date('Y-m-d H:i:s');
foreach ($results as $r) {
$status = $r['status'];
if ($status === 'ok') {
echo "[{$ts}] {$r['company']}: {$r['upserted']} upserted, {$r['deactivated']} deactivated\n";
} elseif ($status === 'skip') {
echo "[{$ts}] {$r['company']}: skip — {$r['reason']}\n";
} else {
echo "[{$ts}] {$r['company']}: ERROR — {$r['reason']}\n";
}
}
+135
View File
@@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
$BASE = 'https://app.palmas360.com/rosablanca/php/controller/controller_api_externa.php';
$KEY = 'd8ed3ac2dbe633cd5e0d75cf50503d7be7084a7dbf0ca65325aa54e831491379'; // test key (IP-unrestricted)
$TODAY = date('Y-m-d');
$M1 = date('Y-m-01');
function hit(string $label, string $url, string $key): void
{
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 20,
CURLOPT_HTTPHEADER => ["Authorization: Bearer {$key}", 'Accept: application/json'],
]);
$body = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$type = curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
$err = curl_error($ch);
curl_close($ch);
if ($err) { echo "❌ [{$label}] cURL: {$err}\n"; return; }
$isPdf = str_contains($type, 'pdf');
$isXls = str_contains($type, 'spreadsheet') || str_contains($type, 'excel');
$isJson = str_contains($type, 'json');
if ($isPdf || $isXls) {
$ext = $isPdf ? 'PDF' : 'EXCEL';
$size = round(strlen($body) / 1024, 1);
echo "✅ [{$label}] HTTP {$code}{$ext} {$size} KB\n";
return;
}
$d = json_decode($body, true);
$status = $d['status'] ?? '?';
if ($code === 200 && in_array($status, ['1', 'sin_datos'])) {
$msg = $d['message'] ?? $d['mensaje'] ?? $d['mensaje'] ?? '';
echo "✅ [{$label}] HTTP {$code} status={$status} " . mb_substr($msg, 0, 80) . "\n";
} else {
$msg = $d['mensaje'] ?? $d['message'] ?? mb_substr($body, 0, 120);
echo "❌ [{$label}] HTTP {$code}{$msg}\n";
}
}
// ── 1. Fincas (para obtener un finca_id real) ─────────────────────────────
echo "\n── Fincas ──\n";
$ch = curl_init("{$BASE}?peticion=fincas");
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>10,
CURLOPT_HTTPHEADER=>["Authorization: Bearer {$KEY}"]]);
$raw = curl_exec($ch); curl_close($ch);
$fincas = json_decode($raw, true);
$fincaId = 0;
if (!empty($fincas['datos'])) {
foreach ($fincas['datos'] as $f) {
echo " id={$f['id']} {$f['label']}\n";
if ($fincaId === 0 && (int)$f['id'] > 0) $fincaId = (int)$f['id']; // skip id=0 (Todas)
}
} else {
echo " " . mb_substr($raw, 0, 200) . "\n";
}
echo " → Usando finca_id={$fincaId} para los tests filtrados\n";
// ── 2. Ausentismos texto ──────────────────────────────────────────────────
echo "\n── Ausentismos texto ──\n";
hit('hoy', "{$BASE}?peticion=ausentismos_bot_texto&fecha_desde={$TODAY}&fecha_hasta={$TODAY}", $KEY);
hit('semana', "{$BASE}?peticion=ausentismos_bot_texto&fecha_desde=" . date('Y-m-d', strtotime('-7 days')) . "&fecha_hasta={$TODAY}", $KEY);
// ── 3. Ausentismos PDF ────────────────────────────────────────────────────
echo "\n── Ausentismos PDF (mes) ──\n";
hit('mes_pdf', "{$BASE}?peticion=ausentismos_bot_pdf&fecha_desde={$M1}&fecha_hasta={$TODAY}", $KEY);
// ── 4. Producción total texto ─────────────────────────────────────────────
echo "\n── Producción total texto ──\n";
hit('sin_finca', "{$BASE}?peticion=produccion_total_bot&fecha_desde={$M1}&fecha_hasta={$TODAY}", $KEY);
if ($fincaId > 0)
hit("finca_{$fincaId}", "{$BASE}?peticion=produccion_total_bot&fecha_desde={$M1}&fecha_hasta={$TODAY}&finca_id={$fincaId}", $KEY);
// ── 5. Producción total PDF ───────────────────────────────────────────────
echo "\n── Producción total PDF ──\n";
if ($fincaId > 0)
hit("finca_{$fincaId}", "{$BASE}?peticion=produccion_total_pdf_bot&fecha_desde={$M1}&fecha_hasta={$TODAY}&finca_id={$fincaId}", $KEY);
// ── 6. Producción por lotes Excel ─────────────────────────────────────────
echo "\n── Producción lotes Excel ──\n";
hit('lotes', "{$BASE}?peticion=produccion_kilos_lotes&fecha_desde={$M1}&fecha_hasta={$TODAY}", $KEY);
// ── 7. Ciclos existentes ──────────────────────────────────────────────────
echo "\n── Ciclos (spot check) ──\n";
hit('cosecha', "{$BASE}?peticion=ciclos_cosecha_dn&fecha_desde={$M1}&finca_id={$fincaId}", $KEY);
hit('polinizacion',"{$BASE}?peticion=ciclos_polinizacion_dn&fecha_desde={$M1}&finca_id={$fincaId}", $KEY);
hit('mantenimiento',"{$BASE}?peticion=mantenimiento_dn&fecha_desde={$M1}&grupo=0&finca_id={$fincaId}", $KEY);
// ── 8. Top texto (lotes con ciclos más largos) ───────────────────────────
echo "\n── Top texto (sin finca) ──\n";
hit('cosecha_top', "{$BASE}?peticion=cosecha_top_texto_bot", $KEY);
hit('polinizacion_top', "{$BASE}?peticion=polinizacion_top_texto_bot", $KEY);
hit('censo_top', "{$BASE}?peticion=censo_top_texto_bot", $KEY);
hit('plagas_top', "{$BASE}?peticion=plagas_top_texto_bot", $KEY);
hit('palm_top', "{$BASE}?peticion=palm_top_texto_bot", $KEY);
hit('tratamiento_top', "{$BASE}?peticion=tratamiento_top_texto_bot", $KEY);
hit('mant_top', "{$BASE}?peticion=mantenimiento_top_texto_bot&grupo=0", $KEY);
if ($fincaId > 0) {
echo "\n── Top texto (con finca_id={$fincaId}) ──\n";
hit('cosecha_top', "{$BASE}?peticion=cosecha_top_texto_bot&finca_id={$fincaId}", $KEY);
hit('polinizacion_top', "{$BASE}?peticion=polinizacion_top_texto_bot&finca_id={$fincaId}", $KEY);
hit('censo_top', "{$BASE}?peticion=censo_top_texto_bot&finca_id={$fincaId}", $KEY);
hit('plagas_top', "{$BASE}?peticion=plagas_top_texto_bot&finca_id={$fincaId}", $KEY);
hit('palm_top', "{$BASE}?peticion=palm_top_texto_bot&finca_id={$fincaId}", $KEY);
hit('tratamiento_top', "{$BASE}?peticion=tratamiento_top_texto_bot&finca_id={$fincaId}", $KEY);
hit('mant_top', "{$BASE}?peticion=mantenimiento_top_texto_bot&grupo=0&finca_id={$fincaId}", $KEY);
}
// ── 9. Histórico 30 días (PDF) ────────────────────────────────────────────
echo "\n── Histórico bot PDF (sin finca) ──\n";
hit('cosecha_hist', "{$BASE}?peticion=cosecha_historico_bot&fecha_hasta={$TODAY}&finca_id=0", $KEY);
hit('polinizacion_hist', "{$BASE}?peticion=polinizacion_historico_bot&fecha_hasta={$TODAY}&finca_id=0", $KEY);
hit('censo_hist', "{$BASE}?peticion=censo_historico_bot&fecha_hasta={$TODAY}&finca_id=0", $KEY);
hit('plagas_hist', "{$BASE}?peticion=plagas_historico_bot&fecha_hasta={$TODAY}&finca_id=0", $KEY);
hit('palm_hist', "{$BASE}?peticion=palm_historico_bot&fecha_hasta={$TODAY}&finca_id=0", $KEY);
hit('tratamiento_hist', "{$BASE}?peticion=tratamiento_historico_bot&fecha_hasta={$TODAY}&finca_id=0", $KEY);
if ($fincaId > 0) {
echo "\n── Histórico bot PDF (con finca_id={$fincaId}) ──\n";
hit('cosecha_hist', "{$BASE}?peticion=cosecha_historico_bot&fecha_hasta={$TODAY}&finca_id={$fincaId}", $KEY);
hit('polinizacion_hist', "{$BASE}?peticion=polinizacion_historico_bot&fecha_hasta={$TODAY}&finca_id={$fincaId}", $KEY);
hit('censo_hist', "{$BASE}?peticion=censo_historico_bot&fecha_hasta={$TODAY}&finca_id={$fincaId}", $KEY);
hit('plagas_hist', "{$BASE}?peticion=plagas_historico_bot&fecha_hasta={$TODAY}&finca_id={$fincaId}", $KEY);
hit('palm_hist', "{$BASE}?peticion=palm_historico_bot&fecha_hasta={$TODAY}&finca_id={$fincaId}", $KEY);
hit('tratamiento_hist', "{$BASE}?peticion=tratamiento_historico_bot&fecha_hasta={$TODAY}&finca_id={$fincaId}", $KEY);
}
echo "\n── Fin del test ──\n";
+135 -27
View File
@@ -1,6 +1,8 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
date_default_timezone_set('America/Bogota');
require_once __DIR__ . '/../config/env.php'; require_once __DIR__ . '/../config/env.php';
require_once __DIR__ . '/../config/db.php'; require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../services/Settings.php'; require_once __DIR__ . '/../services/Settings.php';
@@ -37,7 +39,9 @@ require_once __DIR__ . '/../services/OutboundWorker.php';
require_once __DIR__ . '/../services/ErpSync.php'; require_once __DIR__ . '/../services/ErpSync.php';
require_once __DIR__ . '/../services/ConversationContext.php'; require_once __DIR__ . '/../services/ConversationContext.php';
require_once __DIR__ . '/../services/NormalBot.php'; require_once __DIR__ . '/../services/NormalBot.php';
require_once __DIR__ . '/../services/AiLogger.php';
require_once __DIR__ . '/../services/AiBot.php'; require_once __DIR__ . '/../services/AiBot.php';
require_once __DIR__ . '/../services/MediaTranscriber.php';
require_once __DIR__ . '/../services/BotRouter.php'; require_once __DIR__ . '/../services/BotRouter.php';
require_once __DIR__ . '/../services/PendingApproval.php'; require_once __DIR__ . '/../services/PendingApproval.php';
require_once __DIR__ . '/../services/ErpMonitor.php'; require_once __DIR__ . '/../services/ErpMonitor.php';
@@ -282,6 +286,7 @@ $routes = [
['POST', '/admin/company/endpoint/save', fn() => DashboardController::companyEndpointSave()], ['POST', '/admin/company/endpoint/save', fn() => DashboardController::companyEndpointSave()],
['POST', '/admin/company/endpoint/test', fn() => DashboardController::companyEndpointTest()], ['POST', '/admin/company/endpoint/test', fn() => DashboardController::companyEndpointTest()],
['POST', '/admin/company/endpoint/delete', fn() => DashboardController::companyEndpointDelete()], ['POST', '/admin/company/endpoint/delete', fn() => DashboardController::companyEndpointDelete()],
['POST', '/admin/company/endpoints/clone', fn() => DashboardController::companyEndpointsClone()],
// ─── Admin: listar pendientes de aprobación ───────────────────────────── // ─── Admin: listar pendientes de aprobación ─────────────────────────────
['GET', '/admin/pending-list', fn() => DashboardController::pendingList()], ['GET', '/admin/pending-list', fn() => DashboardController::pendingList()],
@@ -393,6 +398,7 @@ $routes = [
// Flows // Flows
$flowKeys = $_POST['flow_key'] ?? []; $flowKeys = $_POST['flow_key'] ?? [];
$flowNluDescs = $_POST['flow_nlu_desc'] ?? [];
$flowTypes = $_POST['flow_type'] ?? []; $flowTypes = $_POST['flow_type'] ?? [];
$flowMessages = $_POST['flow_message'] ?? []; $flowMessages = $_POST['flow_message'] ?? [];
$flowFunctions = $_POST['flow_function'] ?? []; $flowFunctions = $_POST['flow_function'] ?? [];
@@ -441,6 +447,9 @@ $routes = [
$f = ['type' => $ftype]; $f = ['type' => $ftype];
$nluDesc = trim($flowNluDescs[$i] ?? '');
if ($nluDesc !== '') $f['nlu_description'] = $nluDesc;
if ($ftype === 'text') { if ($ftype === 'text') {
$f['message'] = trim($flowMessages[$i] ?? ''); $f['message'] = trim($flowMessages[$i] ?? '');
@@ -533,12 +542,17 @@ $routes = [
$aiProvider = trim($_POST['ai_provider'] ?? ''); $aiProvider = trim($_POST['ai_provider'] ?? '');
if ($aiProvider !== '') $config['ai_provider'] = $aiProvider; else unset($config['ai_provider']); if ($aiProvider !== '') $config['ai_provider'] = $aiProvider; else unset($config['ai_provider']);
$config['ai_for_media'] = isset($_POST['ai_for_media']); $config['ai_for_media'] = isset($_POST['ai_for_media']);
$config['nlu'] = isset($_POST['nlu']);
$openaiKey = trim($_POST['openai_api_key'] ?? ''); $openaiKey = trim($_POST['openai_api_key'] ?? '');
if ($openaiKey !== '') $config['openai_api_key'] = $openaiKey; if ($openaiKey !== '') $config['openai_api_key'] = $openaiKey;
$geminiKey = trim($_POST['gemini_api_key'] ?? ''); $geminiKey = trim($_POST['gemini_api_key'] ?? '');
if ($geminiKey !== '') $config['gemini_api_key'] = $geminiKey; if ($geminiKey !== '') $config['gemini_api_key'] = $geminiKey;
$geminiModel = trim($_POST['gemini_model'] ?? ''); $geminiModel = trim($_POST['gemini_model'] ?? '');
if ($geminiModel !== '') $config['gemini_model'] = $geminiModel; if ($geminiModel !== '') $config['gemini_model'] = $geminiModel;
$claudeKey = trim($_POST['claude_api_key'] ?? '');
if ($claudeKey !== '') $config['claude_api_key'] = $claudeKey;
$claudeModel = trim($_POST['claude_model'] ?? '');
if ($claudeModel !== '') $config['claude_model'] = $claudeModel;
// per_type is managed exclusively via /admin/bot-config/save-per-type — copy as-is from DB // per_type is managed exclusively via /admin/bot-config/save-per-type — copy as-is from DB
if (isset($existingConfig['per_type'])) { if (isset($existingConfig['per_type'])) {
@@ -562,7 +576,8 @@ $routes = [
// Preserve any DB keys the form doesn't explicitly manage (welcome_menu, per_type, etc.) // Preserve any DB keys the form doesn't explicitly manage (welcome_menu, per_type, etc.)
$formManagedKeys = ['commands', 'menus', 'flows', 'ai_prompt', 'ai_model', 'ai_temperature', $formManagedKeys = ['commands', 'menus', 'flows', 'ai_prompt', 'ai_model', 'ai_temperature',
'ai_provider', 'openai_api_key', 'gemini_api_key', 'gemini_model', 'ai_provider', 'openai_api_key', 'gemini_api_key', 'gemini_model',
'ai_for_media', 'greeting', 'fallback', 'approval_webhook', 'claude_api_key', 'claude_model',
'ai_for_media', 'nlu', 'greeting', 'fallback', 'approval_webhook',
'ignore_prefixes', 'welcome_menu', 'per_type']; 'ignore_prefixes', 'welcome_menu', 'per_type'];
foreach ($existingConfig as $k => $v) { foreach ($existingConfig as $k => $v) {
if (!in_array($k, $formManagedKeys, true) && !isset($config[$k])) { if (!in_array($k, $formManagedKeys, true) && !isset($config[$k])) {
@@ -579,6 +594,60 @@ $routes = [
exit; exit;
})()], })()],
// ─── Admin: guardar solo campos IA (endpoint separado) ───────────────────
['POST', '/admin/bot-config/save-ai', fn() => (function () {
SessionAuth::require();
header('Content-Type: application/json; charset=utf-8');
$companyId = (int)($_POST['company_id'] ?? 0);
if ($companyId === 0) { echo json_encode(['ok' => false, 'error' => 'missing company_id']); exit; }
$company = CompanyRepository::findById($companyId);
if (!$company) { echo json_encode(['ok' => false, 'error' => 'not found']); exit; }
$config = json_decode($company['config_json'] ?? '{}', true) ?: [];
$aiProvider = trim($_POST['ai_provider'] ?? '');
if ($aiProvider !== '') $config['ai_provider'] = $aiProvider; else unset($config['ai_provider']);
$aiModel = trim($_POST['ai_model'] ?? '');
if ($aiModel !== '') $config['ai_model'] = $aiModel;
$aiTemp = trim($_POST['ai_temperature'] ?? '');
if ($aiTemp !== '') $config['ai_temperature'] = (float)$aiTemp;
$aiPrompt = trim($_POST['ai_prompt'] ?? '');
$config['ai_prompt'] = $aiPrompt;
$config['ai_for_media'] = isset($_POST['ai_for_media']);
$config['nlu'] = isset($_POST['nlu']);
$openaiKey = trim($_POST['openai_api_key'] ?? '');
if ($openaiKey !== '') $config['openai_api_key'] = $openaiKey;
$geminiKey = trim($_POST['gemini_api_key'] ?? '');
if ($geminiKey !== '') $config['gemini_api_key'] = $geminiKey;
$geminiModel = trim($_POST['gemini_model'] ?? '');
if ($geminiModel !== '') $config['gemini_model'] = $geminiModel;
$claudeKey = trim($_POST['claude_api_key'] ?? '');
if ($claudeKey !== '') $config['claude_api_key'] = $claudeKey;
$claudeModel = trim($_POST['claude_model'] ?? '');
if ($claudeModel !== '') $config['claude_model'] = $claudeModel;
$whisperUrl = trim($_POST['whisper_url'] ?? '');
$config['whisper_url'] = $whisperUrl;
$whisperUser = trim($_POST['whisper_user'] ?? '');
$config['whisper_user'] = $whisperUser;
$whisperPass = trim($_POST['whisper_pass'] ?? '');
if ($whisperPass !== '') $config['whisper_pass'] = $whisperPass;
CompanyRepository::save(['id' => $companyId, 'config_json' => json_encode($config, JSON_UNESCAPED_UNICODE)]);
echo json_encode(['ok' => true]);
exit;
})()],
// ─── Admin: guardar per_type por categoría (endpoint separado) ─────────── // ─── Admin: guardar per_type por categoría (endpoint separado) ───────────
['POST', '/admin/bot-config/save-per-type', fn() => (function () { ['POST', '/admin/bot-config/save-per-type', fn() => (function () {
SessionAuth::require(); SessionAuth::require();
@@ -595,18 +664,43 @@ $routes = [
$perType = (is_array($rawPt) && count(array_filter(array_keys($rawPt), 'is_string')) > 0) $perType = (is_array($rawPt) && count(array_filter(array_keys($rawPt), 'is_string')) > 0)
? $rawPt : []; ? $rawPt : [];
$perTypeMenus = $_POST['per_type_menu'] ?? []; $ptGreetFlow = $_POST['per_type_greeting_flow'] ?? [];
$ptMenus = $_POST['per_type_menu'] ?? [];
$ptFallback = $_POST['per_type_fallback'] ?? [];
foreach ([1, 2, 3] as $cat) { foreach ([1, 2, 3] as $cat) {
$mk = trim($perTypeMenus[$cat] ?? '');
$key = (string)$cat; $key = (string)$cat;
if ($mk !== '') { $current = $perType[$key] ?? []; // preserve menus/flows/commands from seed
$perType[$key] = array_merge($perType[$key] ?? [], ['greeting_menu' => $mk]);
$gf = trim($ptGreetFlow[$cat] ?? '');
if ($gf !== '') {
// Si tiene greeting_flow → greeting='' para que el flow se dispare al primer mensaje
$current['greeting'] = '';
$current['greeting_flow'] = $gf;
unset($current['greeting_menu']);
} else { } else {
unset($perType[$key]['greeting_menu']); // Sin greeting_flow → usa greeting_menu (cat 1)
if (empty($perType[$key])) unset($perType[$key]); $current['greeting'] = null;
unset($current['greeting_flow']);
$mk = trim($ptMenus[$cat] ?? '');
if ($mk !== '') {
$current['greeting_menu'] = $mk;
} else {
unset($current['greeting_menu']);
} }
} }
$config['per_type'] = empty($perType) ? new stdClass() : $perType;
$fb = trim($ptFallback[$cat] ?? '');
if ($fb !== '') {
$current['fallback_flow'] = $fb;
} else {
unset($current['fallback_flow']);
}
$perType[$key] = $current;
}
$config['per_type'] = $perType;
CompanyRepository::save(['id' => $companyId, 'config_json' => json_encode($config, JSON_UNESCAPED_UNICODE)]); CompanyRepository::save(['id' => $companyId, 'config_json' => json_encode($config, JSON_UNESCAPED_UNICODE)]);
echo json_encode(['ok' => true]); echo json_encode(['ok' => true]);
@@ -615,13 +709,15 @@ $routes = [
// ─── Admin: configuración general ────────────────────────────────────── // ─── Admin: configuración general ──────────────────────────────────────
['GET', '/admin/settings', fn() => DashboardController::settings()], ['GET', '/admin/settings', fn() => DashboardController::settings()],
['GET', '/admin/ai-logs', fn() => DashboardController::aiLogs()],
['POST', '/admin/settings/save', fn() => (function () { ['POST', '/admin/settings/save', fn() => (function () {
SessionAuth::require(); SessionAuth::require();
$allowed = [ $allowed = [
'whatsapp_access_token', 'whatsapp_app_secret', 'whatsapp_verify_token', 'whatsapp_access_token', 'whatsapp_app_secret', 'whatsapp_verify_token',
'whatsapp_business_account_id', 'whatsapp_default_phone_number_id', 'whatsapp_business_account_id', 'whatsapp_default_phone_number_id',
'ai_provider', 'openai_api_key', 'openai_model', 'gemini_api_key', 'gemini_model', 'ai_max_tokens', 'ai_default_prompt', 'ai_provider', 'openai_api_key', 'openai_model', 'gemini_api_key', 'gemini_model',
'claude_api_key', 'claude_model', 'ai_max_tokens', 'ai_default_prompt',
'verify_hmac_signature', 'verify_hmac_signature',
]; ];
// Checkbox: si no está presente, desactivar // Checkbox: si no está presente, desactivar
@@ -651,32 +747,44 @@ $routes = [
['POST', '/admin/settings/test-ai', fn() => (function () { ['POST', '/admin/settings/test-ai', fn() => (function () {
SessionAuth::require(); SessionAuth::require();
$provider = Settings::get('ai_provider', 'mock'); // Accept provider/api_key/model from POST (live test), or fall back to saved settings
$provider = trim($_POST['provider'] ?? '') ?: Settings::get('ai_provider', 'mock');
$apiKey = trim($_POST['api_key'] ?? '');
$model = trim($_POST['model'] ?? '');
if ($provider === 'gemini') { if ($provider === 'gemini') {
$apiKey = Settings::get('gemini_api_key', ''); if ($apiKey === '') $apiKey = Settings::get('gemini_api_key', '');
$model = Settings::get('gemini_model', 'gemini-2.0-flash'); if ($model === '') $model = Settings::get('gemini_model', 'gemini-2.5-flash');
if ($apiKey === '') { jsonResponse(200, ['ok' => false, 'provider' => 'gemini', 'error' => 'API Key no configurada. Guarda primero.']); return; } if ($apiKey === '') { jsonResponse(200, ['ok' => false, 'provider' => 'gemini', 'error' => 'API Key vacía']); return; }
$url = "https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent?key={$apiKey}"; $url = "https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent?key={$apiKey}";
$payload = json_encode(['contents' => [['role' => 'user', 'parts' => [['text' => 'Responde solo: OK']]]]]); $body = json_encode(['contents' => [['role' => 'user', 'parts' => [['text' => 'Responde solo: OK']]]]]);
$ch = curl_init($url); $ch = curl_init($url);
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => $payload, CURLOPT_HTTPHEADER => ['Content-Type: application/json'], CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 15]); curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body, CURLOPT_HTTPHEADER => ['Content-Type: application/json'], CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 15]);
$resp = curl_exec($ch); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); $err = curl_error($ch); curl_close($ch); $resp = curl_exec($ch); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); $err = curl_error($ch); curl_close($ch);
if ($code !== 200) { $msg = $err ?: (json_decode($resp, true)['error']['message'] ?? "HTTP {$code}"); jsonResponse(200, ['ok' => false, 'provider' => 'gemini', 'error' => $msg]); return; } if ($code !== 200) { jsonResponse(200, ['ok' => false, 'provider' => 'gemini', 'error' => $err ?: (json_decode($resp, true)['error']['message'] ?? "HTTP {$code}")]); return; }
$text = json_decode($resp, true)['candidates'][0]['content']['parts'][0]['text'] ?? '(sin respuesta)'; jsonResponse(200, ['ok' => true, 'provider' => 'gemini', 'response' => trim(json_decode($resp, true)['candidates'][0]['content']['parts'][0]['text'] ?? '(sin respuesta)')]);
jsonResponse(200, ['ok' => true, 'provider' => 'gemini', 'response' => trim($text)]);
} elseif ($provider === 'openai') { } elseif ($provider === 'openai') {
$apiKey = Settings::get('openai_api_key', ''); if ($apiKey === '') $apiKey = Settings::get('openai_api_key', '');
$model = Settings::get('openai_model', 'gpt-4o-mini'); if ($model === '') $model = Settings::get('openai_model', 'gpt-4o-mini');
if ($apiKey === '') { jsonResponse(200, ['ok' => false, 'provider' => 'openai', 'error' => 'API Key no configurada. Guarda primero.']); return; } if ($apiKey === '') { jsonResponse(200, ['ok' => false, 'provider' => 'openai', 'error' => 'API Key vacía']); return; }
$payload = json_encode(['model' => $model, 'messages' => [['role' => 'user', 'content' => 'Responde solo: OK']], 'max_tokens' => 5]); $body = json_encode(['model' => $model, 'messages' => [['role' => 'user', 'content' => 'Responde solo: OK']], 'max_tokens' => 5]);
$ch = curl_init('https://api.openai.com/v1/chat/completions'); $ch = curl_init('https://api.openai.com/v1/chat/completions');
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => $payload, CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Authorization: Bearer ' . $apiKey], CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 15]); curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body, CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Authorization: Bearer ' . $apiKey], CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 15]);
$resp = curl_exec($ch); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); $err = curl_error($ch); curl_close($ch); $resp = curl_exec($ch); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); $err = curl_error($ch); curl_close($ch);
if ($code !== 200) { $msg = $err ?: (json_decode($resp, true)['error']['message'] ?? "HTTP {$code}"); jsonResponse(200, ['ok' => false, 'provider' => 'openai', 'error' => $msg]); return; } if ($code !== 200) { jsonResponse(200, ['ok' => false, 'provider' => 'openai', 'error' => $err ?: (json_decode($resp, true)['error']['message'] ?? "HTTP {$code}")]); return; }
$text = json_decode($resp, true)['choices'][0]['message']['content'] ?? '(sin respuesta)'; jsonResponse(200, ['ok' => true, 'provider' => 'openai', 'response' => trim(json_decode($resp, true)['choices'][0]['message']['content'] ?? '(sin respuesta)')]);
jsonResponse(200, ['ok' => true, 'provider' => 'openai', 'response' => trim($text)]); } elseif ($provider === 'claude') {
if ($apiKey === '') $apiKey = Settings::get('claude_api_key', '');
if ($model === '') $model = Settings::get('claude_model', 'claude-haiku-4-5');
if ($apiKey === '') { jsonResponse(200, ['ok' => false, 'provider' => 'claude', 'error' => 'API Key vacía']); return; }
$body = json_encode(['model' => $model, 'max_tokens' => 10, 'messages' => [['role' => 'user', 'content' => 'Responde solo: OK']]]);
$ch = curl_init('https://api.anthropic.com/v1/messages');
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body, CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'x-api-key: ' . $apiKey, 'anthropic-version: 2023-06-01'], CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 15]);
$resp = curl_exec($ch); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); $err = curl_error($ch); curl_close($ch);
if ($code !== 200) { jsonResponse(200, ['ok' => false, 'provider' => 'claude', 'error' => $err ?: (json_decode($resp, true)['error']['message'] ?? "HTTP {$code}")]); return; }
jsonResponse(200, ['ok' => true, 'provider' => 'claude', 'response' => trim(json_decode($resp, true)['content'][0]['text'] ?? '(sin respuesta)')]);
} else { } else {
jsonResponse(200, ['ok' => true, 'provider' => 'mock', 'response' => 'Mock activo — no hay proveedor real configurado.']); jsonResponse(200, ['ok' => true, 'provider' => 'mock', 'response' => 'Mock activo.']);
} }
})()], })()],
+30 -10
View File
@@ -167,6 +167,10 @@ $menuConfig = [
], ],
]; ];
// Claves de flows y menus definidas en este script (las únicas que se actualizan)
$baseFlowKeys = array_keys($menuConfig['flows']);
$baseMenuKeys = array_keys($menuConfig['menus']);
$companies = CompanyRepository::findAll(true); $companies = CompanyRepository::findAll(true);
$count = 0; $count = 0;
@@ -179,27 +183,43 @@ foreach ($companies as $company) {
} }
} }
$preserveKeys = ['greeting', 'fallback', 'ai_prompt', 'ai_model', 'ai_temperature', 'ai_provider', 'approval_webhook', 'ignore_prefixes', 'ai_max_tokens']; // Partir del config existente para preservar TODO lo custom (nlu, whisper_*, gemini_*, etc.)
foreach ($preserveKeys as $key) { $merged = $existing;
if (array_key_exists($key, $existing)) {
$menuConfig[$key] = $existing[$key]; // Actualizar SOLO los flows base definidos aquí; los flows custom se preservan
foreach ($menuConfig['flows'] as $key => $flow) {
$merged['flows'][$key] = $flow;
}
// Actualizar SOLO los menus base; los menus custom se preservan
foreach ($menuConfig['menus'] as $key => $menu) {
$merged['menus'][$key] = $menu;
}
// Actualizar commands, per_type y otros campos estructurales del template
foreach (['commands', 'per_type'] as $structKey) {
if (isset($menuConfig[$structKey])) {
$merged[$structKey] = $menuConfig[$structKey];
} }
} }
if (!isset($menuConfig['greeting'])) { // Aplicar defaults solo si no existen
$menuConfig['greeting'] = '¡Bienvenido! Escribe *menu* para ver las opciones disponibles.'; if (!isset($merged['greeting'])) {
$merged['greeting'] = '¡Bienvenido! Escribe *menu* para ver las opciones disponibles.';
} }
if (!isset($menuConfig['fallback'])) { if (!isset($merged['fallback'])) {
$menuConfig['fallback'] = 'No entendí. Escribe *menu* para ver las opciones disponibles.'; $merged['fallback'] = 'No entendí. Escribe *menu* para ver las opciones disponibles.';
} }
CompanyRepository::save([ CompanyRepository::save([
'id' => (int)$company['id'], 'id' => (int)$company['id'],
'config_json' => json_encode($menuConfig, JSON_UNESCAPED_UNICODE), 'config_json' => json_encode($merged, JSON_UNESCAPED_UNICODE),
]); ]);
$name = $company['display_name'] ?: $company['name']; $name = $company['display_name'] ?: $company['name'];
echo "{$name} actualizada\n"; $customFlows = count(array_diff(array_keys($merged['flows'] ?? []), $baseFlowKeys));
$customMenus = count(array_diff(array_keys($merged['menus'] ?? []), $baseMenuKeys));
echo "{$name} actualizada (preservados: {$customFlows} flows custom, {$customMenus} menus custom)\n";
$count++; $count++;
} }
+59
View File
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../config/env.php';
require_once __DIR__ . '/../config/db.php';
$isDry = ($_GET['run'] ?? '') !== '1';
header('Content-Type: text/html; charset=utf-8');
echo '<pre style="font-family:monospace;font-size:13px;padding:20px">';
echo ($isDry ? "🔍 DRY RUN — ningún cambio se aplicará\n" : "🚀 EJECUTANDO — actualizando la DB\n");
echo str_repeat('─', 60) . "\n\n";
$companies = db()->query("SELECT id, name, api_base_url FROM companies WHERE api_base_url IS NOT NULL AND api_base_url != ''")->fetchAll(PDO::FETCH_ASSOC);
$totalUpdated = 0;
foreach ($companies as $co) {
$base = rtrim($co['api_base_url'], '/');
$eps = db()->prepare("SELECT id, endpoint_key, url FROM company_endpoints WHERE company_id = ? AND url IS NOT NULL AND url != ''");
$eps->execute([$co['id']]);
$rows = $eps->fetchAll(PDO::FETCH_ASSOC);
$hits = [];
foreach ($rows as $ep) {
$url = $ep['url'];
if (!str_starts_with($url, $base)) continue;
$suffix = substr($url, strlen($base));
if ($suffix === '') $suffix = '/';
$hits[] = ['id' => $ep['id'], 'key' => $ep['endpoint_key'], 'before' => $url, 'after' => $suffix];
}
if (empty($hits)) continue;
echo "🏢 {$co['name']} (id={$co['id']})\n";
echo " Base: {$base}\n";
foreach ($hits as $h) {
echo " [{$h['key']}]\n";
echo " antes: {$h['before']}\n";
echo " después: {$h['after']}\n";
if (!$isDry) {
db()->prepare("UPDATE company_endpoints SET url = ? WHERE id = ?")->execute([$h['after'], $h['id']]);
}
$totalUpdated++;
}
echo "\n";
}
echo str_repeat('─', 60) . "\n";
if ($totalUpdated === 0) {
echo "✅ Ningún endpoint tiene la base URL como prefijo. Nada que cambiar.\n";
} elseif ($isDry) {
echo "📋 {$totalUpdated} endpoint(s) serían modificados.\n";
echo " → Para aplicar: abre este script con ?run=1\n";
} else {
echo "{$totalUpdated} endpoint(s) actualizados.\n";
}
echo '</pre>';
+298 -11
View File
@@ -3,6 +3,11 @@ declare(strict_types=1);
class AiBot class AiBot
{ {
// Context forwarded to provider methods so AiLogger has phone + call type
private static string $logPhone = '';
private static string $logType = 'chat';
private static int $logCompany = 0;
public static function processMedia(array $company, array $context, string $mediaType, string $caption): ?array public static function processMedia(array $company, array $context, string $mediaType, string $caption): ?array
{ {
$permType = (int)($context['permission_type'] ?? 1); $permType = (int)($context['permission_type'] ?? 1);
@@ -17,7 +22,7 @@ class AiBot
default => 'un archivo', default => 'un archivo',
}; };
$canUpload = $permType === 3; $canUpload = in_array($permType, [1, 3], true); // 1=solo reporta, 3=ambos
$permDesc = $canUpload $permDesc = $canUpload
? 'El usuario tiene permisos para subir información y reportes.' ? 'El usuario tiene permisos para subir información y reportes.'
: 'El usuario solo puede recibir información o descargar informes, NO puede subir archivos.'; : 'El usuario solo puede recibir información o descargar informes, NO puede subir archivos.';
@@ -36,7 +41,7 @@ class AiBot
$ctxId = (int)$botCtx['id']; $ctxId = (int)$botCtx['id'];
ConversationContext::addAiMessage($ctxId, ['role' => 'user', 'content' => $userMessage]); ConversationContext::addAiMessage($ctxId, ['role' => 'user', 'content' => $userMessage]);
$result = self::callLlm($systemPrompt, $ctxId, $company); $result = self::callLlm($systemPrompt, $ctxId, $company, 'media_response', $context['from']);
if ($result === null) { if ($result === null) {
return null; return null;
} }
@@ -61,7 +66,7 @@ class AiBot
ConversationContext::addAiMessage($ctxId, ['role' => 'user', 'content' => $input]); ConversationContext::addAiMessage($ctxId, ['role' => 'user', 'content' => $input]);
$result = self::callLlm($systemPrompt, $ctxId, $company); $result = self::callLlm($systemPrompt, $ctxId, $company, 'chat', $context['from']);
if ($result === null) { if ($result === null) {
return null; return null;
@@ -77,14 +82,21 @@ class AiBot
]; ];
} }
private static function callLlm(string $systemPrompt, int $ctxId, array $company): ?array private static function callLlm(string $systemPrompt, int $ctxId, array $company, string $callType = 'chat', string $phone = ''): ?array
{ {
$provider = env('AI_PROVIDER', 'openai'); self::$logType = $callType;
self::$logPhone = $phone;
self::$logCompany = (int)($company['id'] ?? 0);
$cfg = self::getConfig($company);
// Per-company provider overrides global; empty = use global
$provider = $cfg['ai_provider'] ?? env('AI_PROVIDER', 'openai');
$history = ConversationContext::getAiHistory($ctxId); $history = ConversationContext::getAiHistory($ctxId);
return match ($provider) { return match ($provider) {
'openai' => self::callOpenAI($systemPrompt, $history, $company), 'openai' => self::callOpenAI($systemPrompt, $history, $company),
'gemini' => self::callGemini($systemPrompt, $history), 'gemini' => self::callGemini($systemPrompt, $history, $company),
'claude' => self::callClaude($systemPrompt, $history, $company),
'mock' => self::mockResponse($history), 'mock' => self::mockResponse($history),
default => self::callOpenAI($systemPrompt, $history, $company), default => self::callOpenAI($systemPrompt, $history, $company),
}; };
@@ -92,12 +104,13 @@ class AiBot
private static function callOpenAI(string $systemPrompt, array $history, array $company): ?array private static function callOpenAI(string $systemPrompt, array $history, array $company): ?array
{ {
$apiKey = env('OPENAI_API_KEY', ''); $cfg = self::getConfig($company);
$apiKey = $cfg['openai_api_key'] ?? env('OPENAI_API_KEY', '');
if ($apiKey === '') { if ($apiKey === '') {
return null; return null;
} }
$model = env('OPENAI_MODEL', 'gpt-4o-mini'); $model = $cfg['ai_model'] ?? env('OPENAI_MODEL', 'gpt-4o-mini');
$messages = [ $messages = [
['role' => 'system', 'content' => $systemPrompt], ['role' => 'system', 'content' => $systemPrompt],
@@ -117,6 +130,7 @@ class AiBot
'temperature' => 0.7, 'temperature' => 0.7,
]); ]);
$t0 = (int)(microtime(true) * 1000);
$ch = curl_init('https://api.openai.com/v1/chat/completions'); $ch = curl_init('https://api.openai.com/v1/chat/completions');
curl_setopt_array($ch, [ curl_setopt_array($ch, [
CURLOPT_POST => true, CURLOPT_POST => true,
@@ -133,6 +147,7 @@ class AiBot
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch); $error = curl_error($ch);
curl_close($ch); curl_close($ch);
$ms = (int)(microtime(true) * 1000) - $t0;
if ($httpCode !== 200 || $response === false) { if ($httpCode !== 200 || $response === false) {
WpWebhook::log('ERROR', "OpenAI API error: {$error} HTTP:{$httpCode}"); WpWebhook::log('ERROR', "OpenAI API error: {$error} HTTP:{$httpCode}");
@@ -142,15 +157,19 @@ class AiBot
$data = json_decode($response, true); $data = json_decode($response, true);
$text = $data['choices'][0]['message']['content'] ?? null; $text = $data['choices'][0]['message']['content'] ?? null;
$lastUser = end($history)['content'] ?? '';
AiLogger::log(self::$logCompany, 'openai', $model, self::$logType, $lastUser, $text ?? '', $ms, self::$logPhone, $data['usage']['prompt_tokens'] ?? null, $data['usage']['completion_tokens'] ?? null);
return $text !== null ? ['content' => $text] : null; return $text !== null ? ['content' => $text] : null;
} }
private static function callGemini(string $systemPrompt, array $history): ?array private static function callGemini(string $systemPrompt, array $history, array $company = []): ?array
{ {
$apiKey = env('GEMINI_API_KEY', ''); $cfg = self::getConfig($company);
$apiKey = $cfg['gemini_api_key'] ?? env('GEMINI_API_KEY', '');
if ($apiKey === '') return null; if ($apiKey === '') return null;
$model = env('GEMINI_MODEL', 'gemini-2.0-flash'); $model = $cfg['gemini_model'] ?? env('GEMINI_MODEL', 'gemini-2.5-flash');
$maxTokens = (int)env('AI_MAX_TOKENS', '500'); $maxTokens = (int)env('AI_MAX_TOKENS', '500');
// Gemini usa "contents" con roles "user"/"model" // Gemini usa "contents" con roles "user"/"model"
@@ -170,6 +189,7 @@ class AiBot
'generationConfig' => ['maxOutputTokens' => $maxTokens, 'temperature' => 0.7], 'generationConfig' => ['maxOutputTokens' => $maxTokens, 'temperature' => 0.7],
]); ]);
$t0 = (int)(microtime(true) * 1000);
$url = "https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent?key={$apiKey}"; $url = "https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent?key={$apiKey}";
$ch = curl_init($url); $ch = curl_init($url);
curl_setopt_array($ch, [ curl_setopt_array($ch, [
@@ -184,6 +204,7 @@ class AiBot
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch); $error = curl_error($ch);
curl_close($ch); curl_close($ch);
$ms = (int)(microtime(true) * 1000) - $t0;
if ($httpCode !== 200 || $response === false) { if ($httpCode !== 200 || $response === false) {
WpWebhook::log('ERROR', "Gemini API error: {$error} HTTP:{$httpCode}"); WpWebhook::log('ERROR', "Gemini API error: {$error} HTTP:{$httpCode}");
@@ -193,6 +214,69 @@ class AiBot
$data = json_decode($response, true); $data = json_decode($response, true);
$text = $data['candidates'][0]['content']['parts'][0]['text'] ?? null; $text = $data['candidates'][0]['content']['parts'][0]['text'] ?? null;
$lastUser = end($history)['content'] ?? '';
$tokIn = $data['usageMetadata']['promptTokenCount'] ?? null;
$tokOut = $data['usageMetadata']['candidatesTokenCount'] ?? null;
AiLogger::log(self::$logCompany, 'gemini', $model, self::$logType, $lastUser, $text ?? '', $ms, self::$logPhone, $tokIn, $tokOut);
return $text !== null ? ['content' => $text] : null;
}
private static function callClaude(string $systemPrompt, array $history, array $company = []): ?array
{
$cfg = self::getConfig($company);
$apiKey = $cfg['claude_api_key'] ?? env('CLAUDE_API_KEY', '');
if ($apiKey === '') return null;
$model = $cfg['claude_model'] ?? env('CLAUDE_MODEL', 'claude-haiku-4-5');
$maxTokens = (int)env('AI_MAX_TOKENS', '500');
$messages = [];
foreach ($history as $msg) {
$messages[] = ['role' => $msg['role'] ?? 'user', 'content' => $msg['content'] ?? ''];
}
if (empty($messages)) {
$messages[] = ['role' => 'user', 'content' => ''];
}
$payload = json_encode([
'model' => $model,
'max_tokens' => $maxTokens,
'system' => $systemPrompt,
'messages' => $messages,
]);
$t0 = (int)(microtime(true) * 1000);
$ch = curl_init('https://api.anthropic.com/v1/messages');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'x-api-key: ' . $apiKey,
'anthropic-version: 2023-06-01',
],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
$ms = (int)(microtime(true) * 1000) - $t0;
if ($httpCode !== 200 || $response === false) {
WpWebhook::log('ERROR', "Claude API error: {$error} HTTP:{$httpCode}");
return null;
}
$data = json_decode($response, true);
$text = $data['content'][0]['text'] ?? null;
$lastUser = end($history)['content'] ?? '';
AiLogger::log(self::$logCompany, 'claude', $model, self::$logType, $lastUser, $text ?? '', $ms, self::$logPhone, $data['usage']['input_tokens'] ?? null, $data['usage']['output_tokens'] ?? null);
return $text !== null ? ['content' => $text] : null; return $text !== null ? ['content' => $text] : null;
} }
@@ -244,6 +328,209 @@ PROMPT;
. "Responde de forma breve y apropiada según los permisos del usuario. Sin saludos largos."; . "Responde de forma breve y apropiada según los permisos del usuario. Sin saludos largos.";
} }
// ── NLU: enrutamiento inteligente ────────────────────────────────────────
/**
* Dado un mensaje de texto, decide si enrutar a un flow existente o responder en chat libre.
* Devuelve: ['action'=>'route','key'=>'flow_key'] | ['action'=>'chat','text'=>'...']
*/
public static function routeOrChat(array $company, array $context, string $input, array $modulosPermitidos = []): array
{
$config = self::getConfig($company);
$permType = (int)($context['permission_type'] ?? 1);
$catalog = self::buildFlowCatalog($config, $permType, $modulosPermitidos);
if (empty($catalog)) {
return ['action' => 'chat', 'text' => ''];
}
$systemPrompt = self::buildNluPrompt(
$company['display_name'] ?? $company['name'] ?? 'la empresa',
$catalog,
$permType
);
$raw = self::callLlmOnce($systemPrompt, $input, $company, 'nlu', $context['from'] ?? '');
if ($raw === null) {
return ['action' => 'chat', 'text' => ''];
}
// Strip markdown fences if AI wrapped JSON
$clean = trim(preg_replace('/^```(?:json)?\s*/i', '', preg_replace('/\s*```$/m', '', $raw)));
$json = json_decode($clean, true);
if (!is_array($json) || !isset($json['action'])) {
// JSON inválido o truncado. Mandar $clean tal cual le escupía el
// {"action":"chat",...} al usuario, así que se rescata solo el texto.
self::log("NLU: respuesta no parseable → " . mb_substr($clean, 0, 200));
return ['action' => 'chat', 'text' => self::rescatarTexto($clean)];
}
if ($json['action'] === 'route' && isset($json['key'])) {
$allFlows = $config['flows'] ?? [];
foreach ($config['per_type'] ?? [] as $pt) {
$allFlows = array_merge($allFlows, $pt['flows'] ?? []);
}
if (isset($allFlows[$json['key']])) {
return [
'action' => 'route',
'key' => $json['key'],
'entities' => is_array($json['entities'] ?? null) ? $json['entities'] : [],
];
}
}
return ['action' => 'chat', 'text' => self::rescatarTexto((string)($json['text'] ?? $clean))];
}
/**
* Extrae el texto legible de una respuesta que no parseó (típicamente JSON
* truncado por límite de tokens). Si no hay nada rescatable devuelve '',
* y NormalBot cae al menú de fallback — mejor eso que mostrarle JSON.
*/
private static function rescatarTexto(string $crudo): string
{
$crudo = trim($crudo);
if ($crudo === '') return '';
// "text":"lo que sirve → rescatar aunque falte el cierre
if (preg_match('/"text"\s*:\s*"(.*?)(?:"\s*[,}]|$)/s', $crudo, $m)) {
$texto = stripcslashes($m[1]);
return str_contains($texto, '{"action"') ? '' : trim($texto);
}
// Cualquier resto con pinta de JSON o de key interna no se muestra
if (str_contains($crudo, '{"') || str_contains($crudo, '"action"')) return '';
return $crudo;
}
private static function buildFlowCatalog(array $config, int $permType, array $modulosPermitidos = []): array
{
$flows = $config['flows'] ?? [];
$menus = $config['menus'] ?? [];
// Build human labels from menu rows/buttons
$labels = [];
foreach ($menus as $menu) {
foreach ($menu['sections'] ?? [] as $section) {
foreach ($section['rows'] ?? [] as $row) {
if (($row['id'] ?? '') !== '') $labels[$row['id']] = $row['title'] ?? $row['id'];
}
}
foreach ($menu['buttons'] ?? [] as $btn) {
if (($btn['id'] ?? '') !== '') $labels[$btn['id']] = $btn['title'] ?? $btn['id'];
}
}
$catalog = [];
foreach ($flows as $key => $flow) {
$type = $flow['type'] ?? 'text';
$fn = $flow['function'] ?? '';
// nlu_dir deja que un menú de categoría declare su dirección: por tipo
// no es ni subida ni descarga, y sin eso el filtro de permisos no lo
// tocaba y se le ofrecían informes a quien solo reporta.
$dirDeclarada = $flow['nlu_dir'] ?? '';
$isUpload = in_array($type, ['collect_and_post', 'collect_for_each', 'submit_form'], true)
|| ($type === 'function' && $fn === 'upload_ciclo')
|| $dirDeclarada === 'subida';
$isDownload = ($type === 'function' && $fn === 'api_report')
|| $dirDeclarada === 'descarga';
// Permission filter:
// type 1 = solo reporta (upload), type 2 = solo recibe (download), type 3 = ambos
if ($isUpload && !in_array($permType, [1, 3], true)) continue;
if ($isDownload && !in_array($permType, [2, 3], true)) continue;
// Flujos marcados como internos — no exponer al NLU
if (!empty($flow['nlu_skip'])) continue;
// El perfil del número acota qué módulos puede usar: lo que el menú
// no muestra, el NLU tampoco lo ofrece
if ($modulosPermitidos) {
$mod = NormalBot::moduloDeFlow($key, $flow);
if ($mod !== null && !in_array($mod, $modulosPermitidos, true)) continue;
}
// Skip internal/navigation flows unless they have an explicit NLU description
$hasNluDesc = isset($flow['nlu_description']) && $flow['nlu_description'] !== '';
if (in_array($type, ['text', 'image'], true) && !$isUpload && !$isDownload && !$hasNluDesc) continue;
$label = $flow['nlu_description'] ?? $labels[$key] ?? $key;
$dir = $isUpload ? 'subida' : ($isDownload ? 'descarga' : '');
$catalog[] = ['key' => $key, 'label' => $label, 'dir' => $dir];
}
return $catalog;
}
private static function buildNluPrompt(string $company, array $catalog, int $permType): string
{
$permLabel = match ($permType) {
1 => 'puede subir/reportar datos al sistema',
2 => 'puede descargar reportes e informes',
3 => 'puede subir datos y descargar reportes',
default => 'acceso básico',
};
$lines = array_map(fn($item) =>
'- ' . $item['key'] . ': "' . $item['label'] . '"' . ($item['dir'] !== '' ? ' [' . $item['dir'] . ']' : ''),
$catalog
);
return "Eres el asistente de {$company}. El usuario {$permLabel}.\n\n"
. "Opciones disponibles:\n" . implode("\n", $lines) . "\n\n"
. "Analiza el mensaje y determina si se refiere claramente a una opción.\n\n"
. "Si sí → responde SOLO este JSON (sin markdown):\n"
. "{\"action\":\"route\",\"key\":\"<key_exacto>\",\"entities\":{\"campo\":\"valor\",...}}\n\n"
. "En 'entities' incluye los datos que el usuario ya mencionó: nombre de finca, grupo, filtro, fecha, valor numérico, etc. Usa el nombre tal como lo dijo el usuario.\n"
. "Si no mencionó datos extra, omite entities o usa {}.\n\n"
. "IMPORTANTE — no adivines cuál informe quiere.\n"
. "Poné siempre el filtro (grupo, finca) en entities: el sistema lo resuelve solo y se saltea la lista.\n"
. "Pero elegí el key así:\n"
. " · Dijo QUÉ informe (\"todos los lotes\", \"más atrasados\", \"histórico\") → ruteá a ese informe.\n"
. " · Solo nombró el tema y el filtro → ruteá al menú del tema, para que él elija el informe.\n"
. " · Nombró solo una categoría (\"ausentismo\", \"producción\", \"sanidad\", \"ciclos\") → ruteá al\n"
. " menú de esa categoría. NUNCA respondas por chat pidiendo que aclare: el menú ya pregunta.\n"
. "Ejemplos:\n"
. " \"quiero el informe de mantenimiento de plateo\"{\"action\":\"route\",\"key\":\"ciclo_mantenimiento\",\"entities\":{\"grupo\":\"plateo\"}}\n"
. " \"mantenimiento de plateo, todos los lotes\"{\"action\":\"route\",\"key\":\"ciclo_mantenimiento_todos\",\"entities\":{\"grupo\":\"plateo\"}}\n"
. " \"qué lotes llevan más tiempo sin corona\"{\"action\":\"route\",\"key\":\"ciclo_mantenimiento_top\",\"entities\":{\"grupo\":\"corona\"}}\n"
. " \"mantenimiento\" (sin grupo) → {\"action\":\"route\",\"key\":\"ciclo_mantenimiento\"}\n"
. " \"quiero cambiar de finca\"{\"action\":\"route\",\"key\":\"reset_finca\"}\n\n"
. "NUNCA preguntes por un dato que una opción ya pide (grupo, finca, período):\n"
. "ruteá a esa opción y el bot lo pregunta con su propia lista.\n"
. " \"descargar informe de mantenimiento\" → route a ciclo_mantenimiento, NO preguntar el grupo.\n\n"
. "Usá chat solo si el mensaje no corresponde a ninguna opción → responde SOLO:\n"
. "{\"action\":\"chat\",\"text\":\"<respuesta corta en español, máx 2 oraciones>\"}\n\n"
. "No inventes keys. Usa exactamente los keys de la lista.";
}
/** Llamada de 1 turno sin historial — para NLU routing. */
private static function callLlmOnce(string $systemPrompt, string $userMessage, array $company, string $callType = 'nlu', string $phone = ''): ?string
{
self::$logType = $callType;
self::$logPhone = $phone;
self::$logCompany = (int)($company['id'] ?? 0);
$cfg = self::getConfig($company);
$provider = $cfg['ai_provider'] ?? env('AI_PROVIDER', 'openai');
$history = [['role' => 'user', 'content' => $userMessage]];
$result = match ($provider) {
'openai' => self::callOpenAI($systemPrompt, $history, $company),
'gemini' => self::callGemini($systemPrompt, $history, $company),
'claude' => self::callClaude($systemPrompt, $history, $company),
default => null,
};
return $result['content'] ?? null;
}
// ── Shared helpers ────────────────────────────────────────────────────────
private static function getConfig(array $company): array private static function getConfig(array $company): array
{ {
$json = $company['config_json'] ?? ''; $json = $company['config_json'] ?? '';
+40
View File
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
class AiLogger
{
public static function log(
?int $companyId,
string $provider,
string $model,
string $callType,
string $inputPreview,
string $outputPreview,
int $durationMs,
string $phone = '',
?int $tokensIn = null,
?int $tokensOut = null
): void {
try {
db()->prepare("
INSERT INTO ai_logs
(company_id, phone_number, provider, model, call_type,
input_preview, output_preview, tokens_in, tokens_out, duration_ms)
VALUES (?,?,?,?,?,?,?,?,?,?)
")->execute([
$companyId ?: null,
$phone !== '' ? $phone : null,
$provider,
$model,
$callType,
mb_substr($inputPreview, 0, 500),
mb_substr($outputPreview, 0, 500),
$tokensIn,
$tokensOut,
$durationMs,
]);
} catch (\Throwable) {
// Never break the bot for a log failure
}
}
}
+83 -8
View File
@@ -87,12 +87,12 @@ class BotRouter
}; };
} }
private static function runNormalBot(array $company, array $context, string $input, string $inputType): ?array private static function runNormalBot(array $company, array $context, string $input, string $inputType, bool $suppressFallback = false): ?array
{ {
if ($inputType === 'interactive' || $inputType === 'button') { if ($inputType === 'interactive' || $inputType === 'button') {
return NormalBot::processInteractive($company, $context, $input); return NormalBot::processInteractive($company, $context, $input);
} }
return NormalBot::process($company, $context, $input); return NormalBot::process($company, $context, $input, $suppressFallback);
} }
private static function runAiBot(array $company, array $context, string $input): ?array private static function runAiBot(array $company, array $context, string $input): ?array
@@ -108,23 +108,98 @@ class BotRouter
private static function runHybridBot(array $company, array $context, string $input, string $inputType): ?array private static function runHybridBot(array $company, array $context, string $input, string $inputType): ?array
{ {
$config = self::getConfig($company); $config = self::getConfig($company);
$nluEnabled = (bool)($config['nlu'] ?? false);
// Media (image/audio/video/document) → AI handles it if enabled
if (self::isMediaType($inputType)) {
$aiEnabled = (bool)($config['ai_for_media'] ?? true); $aiEnabled = (bool)($config['ai_for_media'] ?? true);
// ── Media (audio/imagen) ──────────────────────────────────────────────
if (self::isMediaType($inputType)) {
if (!$aiEnabled) { if (!$aiEnabled) {
return self::categoryMenuFallback($company, $context, $config); return self::categoryMenuFallback($company, $context, $config);
} }
if ($nluEnabled && in_array($inputType, ['audio', 'image'], true)) {
$mediaId = $context['media_id'] ?? $input; // injected by WpWebhook::handleMedia
$text = $inputType === 'audio'
? MediaTranscriber::transcribeAudio($mediaId, $company)
: MediaTranscriber::extractFromImage($mediaId, $company);
if ($text !== null && trim($text) !== '') {
self::log("NLU media [{$inputType}]: texto extraído → \"{$text}\"");
$prefix = $inputType === 'audio' ? '🎤 Escuché' : '🖼️ Imagen analizada';
WhatsAppSender::sendText(
$context['from'],
"{$prefix}: _{$text}_",
$context['phone_number_id'] ?? ''
);
// Si el usuario está en medio de un flujo activo, el texto va directo
// a NormalBot como respuesta al paso actual — no al NLU
if (self::hasActiveFlow((int)$company['id'], $context['from'])) {
self::log("NLU media: flujo activo detectado → NormalBot recibe \"{$text}\"");
return self::runNormalBot($company, $context, $text, 'text', false);
}
return self::runNluOnText($company, $context, $text, $config);
}
}
return AiBot::processMedia($company, $context, $inputType, $input); return AiBot::processMedia($company, $context, $inputType, $input);
} }
// Text / interactive / button → NormalBot only, never AI // ── Texto / interactivo: NormalBot primero ────────────────────────────
$response = self::runNormalBot($company, $context, $input, $inputType); // suppressFallback=true cuando NLU activo: si NormalBot no reconoce, retorna null
// y dejamos que NLU decida en vez de mostrar el menú de bienvenida como fallback
$response = self::runNormalBot($company, $context, $input, $inputType, $nluEnabled);
if ($response !== null) { if ($response !== null) {
return $response; return $response;
} }
// NormalBot returned null → show the category greeting menu as fallback // NormalBot no reconoció → NLU si está activo, si no menú categoría
if ($nluEnabled && $inputType === 'text') {
return self::runNluOnText($company, $context, $input, $config);
}
return self::categoryMenuFallback($company, $context, $config);
}
private static function hasActiveFlow(int $companyId, string $phone): bool
{
$botCtx = ConversationContext::getOrCreate($companyId, $phone, 'normal');
$meta = ConversationContext::getMetadata((int)$botCtx['id']);
return !empty($meta['__cap']) || !empty($meta['__foreach']) || !empty($meta['collecting']) || !empty($meta['__api_vars']);
}
private static function runNluOnText(array $company, array $context, string $input, array $config): ?array
{
$result = AiBot::routeOrChat($company, $context, $input);
if ($result['action'] === 'route') {
$key = $result['key'];
$botCtx = ConversationContext::getOrCreate((int)$company['id'], $context['from'], 'normal');
$ctxId = (int)$botCtx['id'];
$entities = $result['entities'] ?? [];
if (!empty($entities)) {
$m = ConversationContext::getMetadata($ctxId);
$m['__nlu_entities'] = $entities;
ConversationContext::updateMetadata($ctxId, $m);
self::log("NLU: entities extraídas → " . json_encode($entities));
}
ConversationContext::updateNode($ctxId, $key);
self::log("NLU: {$context['from']} → flow [{$key}]");
return NormalBot::process($company, $context, '');
}
$text = trim($result['text'] ?? '');
if ($text !== '') {
self::log("NLU: {$context['from']} → chat libre");
return [
'action' => 'send',
'type' => 'text',
'to' => $context['from'],
'payload' => json_encode(['text' => $text]),
];
}
return self::categoryMenuFallback($company, $context, $config); return self::categoryMenuFallback($company, $context, $config);
} }
+19 -3
View File
@@ -17,9 +17,24 @@ class ErpMonitor
public static function check(array $company): array public static function check(array $company): array
{ {
$baseUrl = rtrim($company['api_base_url'] ?? '', '/'); // El ERP no expone /health sino ?peticion=ping, y la URL sale de
$healthUrl = $baseUrl !== '' ? $baseUrl . '/health' : ''; // company_endpoints como el resto de las llamadas: api_base_url no
// sirve para esto. Sin esto el panel mostraba el ERP siempre caído.
$apiKey = $company['api_key'] ?? ''; $apiKey = $company['api_key'] ?? '';
$healthUrl = '';
try {
$stmt = db()->prepare(
"SELECT url FROM company_endpoints WHERE company_id = ? AND endpoint_key = ? AND is_active = 1 LIMIT 1"
);
$stmt->execute([(int)$company['id'], 'numeros_dn']);
$url = (string)($stmt->fetchColumn() ?: '');
if ($url !== '') {
// Cualquier endpoint sirve de referencia; se reemplaza por ping
$healthUrl = preg_replace('/peticion=[^&]*/', 'peticion=ping', $url);
}
} catch (\PDOException $e) {
$healthUrl = '';
}
if ($healthUrl === '') { if ($healthUrl === '') {
return [ return [
@@ -27,7 +42,7 @@ class ErpMonitor
'company_name' => $company['name'] ?? '', 'company_name' => $company['name'] ?? '',
'status' => 'unknown', 'status' => 'unknown',
'latency_ms' => null, 'latency_ms' => null,
'error' => 'Sin URL configurada', 'error' => 'Sin endpoint registrado para esta empresa',
'last_check' => date('Y-m-d H:i:s'), 'last_check' => date('Y-m-d H:i:s'),
]; ];
} }
@@ -40,6 +55,7 @@ class ErpMonitor
CURLOPT_TIMEOUT => 10, CURLOPT_TIMEOUT => 10,
CURLOPT_CONNECTTIMEOUT => 5, CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_HTTPHEADER => [ CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $apiKey,
'X-API-Key: ' . $apiKey, 'X-API-Key: ' . $apiKey,
'User-Agent: bot-palmas360-monitor/1.0', 'User-Agent: bot-palmas360-monitor/1.0',
], ],
+356
View File
@@ -0,0 +1,356 @@
<?php
declare(strict_types=1);
/**
* Descarga media de WhatsApp y la transcribe/analiza con el proveedor de IA activo.
* Retorna siempre texto plano para pasar al NluRouter.
*/
class MediaTranscriber
{
// ── WhatsApp media download ───────────────────────────────────────────────
private static function downloadMedia(string $mediaId): ?array
{
$token = env('WHATSAPP_ACCESS_TOKEN', '');
if ($token === '') return null;
// 1) Obtener URL del archivo
$ch = curl_init("https://graph.facebook.com/v18.0/{$mediaId}");
curl_setopt_array($ch, [
CURLOPT_HTTPHEADER => ["Authorization: Bearer {$token}", "User-Agent: bot-palmas360/1.0"],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
]);
$resp = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code !== 200 || !$resp) return null;
$data = json_decode($resp, true);
$url = $data['url'] ?? '';
$mime = $data['mime_type'] ?? 'application/octet-stream';
if ($url === '') return null;
// 2) Descargar binario
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_HTTPHEADER => ["Authorization: Bearer {$token}", "User-Agent: bot-palmas360/1.0"],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_TIMEOUT => 45,
]);
$bytes = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code !== 200 || !$bytes || strlen($bytes) < 100) return null;
return ['bytes' => $bytes, 'mime' => $mime];
}
// ── Audio → texto ─────────────────────────────────────────────────────────
public static function transcribeAudio(string $mediaId, array $company): ?string
{
$cfg = self::getConfig($company);
$cfg['__company_id'] = (int)($company['id'] ?? 0);
$provider = $cfg['ai_provider'] ?? env('AI_PROVIDER', 'openai');
$media = self::downloadMedia($mediaId);
if ($media === null) return null;
// Servidor Whisper propio tiene prioridad sobre el proveedor IA
if (trim($cfg['whisper_url'] ?? '') !== '') {
return self::whisper($media['bytes'], $media['mime'], $cfg);
}
return match ($provider) {
'openai' => self::whisper($media['bytes'], $media['mime'], $cfg),
'gemini' => self::geminiAudio($media['bytes'], $media['mime'], $cfg),
default => null, // Claude no soporta audio
};
}
private static function whisper(string $bytes, string $mime, array $cfg): ?string
{
$ext = str_contains($mime, 'ogg') ? 'ogg' : (str_contains($mime, 'mp4') ? 'mp4' : 'ogg');
$tmpFile = tempnam(sys_get_temp_dir(), 'wa_audio_') . '.' . $ext;
file_put_contents($tmpFile, $bytes);
$whisperUrl = trim($cfg['whisper_url'] ?? '');
$whisperUser = trim($cfg['whisper_user'] ?? '');
$whisperPass = trim($cfg['whisper_pass'] ?? '');
$useOwn = $whisperUrl !== '';
if ($useOwn) {
$t0 = (int)(microtime(true) * 1000);
$ch = curl_init($whisperUrl);
$opts = [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => [
'audio_file' => new CURLFile($tmpFile, $mime, 'audio.' . $ext),
'response_format' => 'json',
],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 120,
];
if ($whisperUser !== '') {
$opts[CURLOPT_USERPWD] = "{$whisperUser}:{$whisperPass}";
}
curl_setopt_array($ch, $opts);
$resp = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$ms = (int)(microtime(true) * 1000) - $t0;
@unlink($tmpFile);
if ($code !== 200 || !$resp) return null;
// Respuesta puede ser JSON {"text":"..."} o texto plano
$data = json_decode($resp, true);
$text = is_array($data) ? trim($data['text'] ?? '') : trim($resp);
if ($text === '') return null;
AiLogger::log($cfg['__company_id'] ?? null, 'whisper-own', 'whisper', 'transcribe', '[audio]', $text, $ms);
return $text;
}
// Fallback: OpenAI cloud Whisper
$apiKey = $cfg['openai_api_key'] ?? env('OPENAI_API_KEY', '');
if ($apiKey === '') { @unlink($tmpFile); return null; }
$t0 = (int)(microtime(true) * 1000);
$ch = curl_init('https://api.openai.com/v1/audio/transcriptions');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => [
'file' => new CURLFile($tmpFile, $mime, 'audio.' . $ext),
'model' => 'whisper-1',
'language' => 'es',
],
CURLOPT_HTTPHEADER => ["Authorization: Bearer {$apiKey}"],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 60,
]);
$resp = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$ms = (int)(microtime(true) * 1000) - $t0;
@unlink($tmpFile);
if ($code !== 200 || !$resp) return null;
$data = json_decode($resp, true);
$text = isset($data['text']) ? trim($data['text']) : null;
if ($text !== null) {
AiLogger::log($cfg['__company_id'] ?? null, 'openai', 'whisper-1', 'transcribe', '[audio]', $text, $ms);
}
return $text;
}
private static function geminiAudio(string $bytes, string $mime, array $cfg): ?string
{
$apiKey = $cfg['gemini_api_key'] ?? env('GEMINI_API_KEY', '');
$model = $cfg['gemini_model'] ?? env('GEMINI_MODEL', 'gemini-2.5-flash');
if ($apiKey === '') return null;
// Gemini acepta audio inline como base64
$payload = json_encode([
'contents' => [[
'parts' => [
['inline_data' => ['mime_type' => $mime, 'data' => base64_encode($bytes)]],
['text' => 'Transcribe este audio de WhatsApp en español. Devuelve SOLO el texto transcrito, sin explicaciones ni puntuación extra.'],
],
]],
]);
$url = "https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent?key={$apiKey}";
$t0 = (int)(microtime(true) * 1000);
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 60,
]);
$resp = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$ms = (int)(microtime(true) * 1000) - $t0;
if ($code !== 200 || !$resp) return null;
$data = json_decode($resp, true);
$text = $data['candidates'][0]['content']['parts'][0]['text'] ?? null;
$text = $text !== null ? trim($text) : null;
if ($text !== null) {
AiLogger::log($cfg['__company_id'] ?? null, 'gemini', $model, 'transcribe', '[audio]', $text, $ms);
}
return $text;
}
// ── Imagen → intención/texto ──────────────────────────────────────────────
public static function extractFromImage(string $mediaId, array $company): ?string
{
$cfg = self::getConfig($company);
$cfg['__company_id'] = (int)($company['id'] ?? 0);
$provider = $cfg['ai_provider'] ?? env('AI_PROVIDER', 'openai');
$media = self::downloadMedia($mediaId);
if ($media === null) return null;
$b64 = base64_encode($media['bytes']);
$mime = $media['mime'];
$prompt = 'Analiza esta imagen de WhatsApp. Si contiene texto, extráelo. '
. 'Describe en una oración qué quiere hacer el usuario o qué información contiene. '
. 'Responde en español, máximo 2 oraciones.';
return match ($provider) {
'openai' => self::openaiVision($b64, $mime, $prompt, $cfg),
'gemini' => self::geminiVision($b64, $mime, $prompt, $cfg),
'claude' => self::claudeVision($b64, $mime, $prompt, $cfg),
default => null,
};
}
private static function openaiVision(string $b64, string $mime, string $prompt, array $cfg): ?string
{
$apiKey = $cfg['openai_api_key'] ?? env('OPENAI_API_KEY', '');
$model = $cfg['ai_model'] ?? env('OPENAI_MODEL', 'gpt-4o');
// Vision requires gpt-4o or gpt-4.1 — downgrade if mini is selected
if (str_contains($model, 'mini') || str_contains($model, '3.5')) $model = 'gpt-4o';
if ($apiKey === '') return null;
$payload = json_encode([
'model' => $model,
'max_tokens' => 200,
'messages' => [[
'role' => 'user',
'content' => [
['type' => 'image_url', 'image_url' => ['url' => "data:{$mime};base64,{$b64}"]],
['type' => 'text', 'text' => $prompt],
],
]],
]);
$t0 = (int)(microtime(true) * 1000);
$ch = curl_init('https://api.openai.com/v1/chat/completions');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => ['Content-Type: application/json', "Authorization: Bearer {$apiKey}"],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
]);
$resp = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$ms = (int)(microtime(true) * 1000) - $t0;
if ($code !== 200 || !$resp) return null;
$data = json_decode($resp, true);
$text = $data['choices'][0]['message']['content'] ?? null;
$text = $text !== null ? trim($text) : null;
if ($text !== null) {
AiLogger::log($cfg['__company_id'] ?? null, 'openai', $model, 'vision', '[image]', $text, $ms);
}
return $text;
}
private static function geminiVision(string $b64, string $mime, string $prompt, array $cfg): ?string
{
$apiKey = $cfg['gemini_api_key'] ?? env('GEMINI_API_KEY', '');
$model = $cfg['gemini_model'] ?? env('GEMINI_MODEL', 'gemini-2.5-flash');
if ($apiKey === '') return null;
$payload = json_encode([
'contents' => [[
'parts' => [
['inline_data' => ['mime_type' => $mime, 'data' => $b64]],
['text' => $prompt],
],
]],
]);
$url = "https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent?key={$apiKey}";
$t0 = (int)(microtime(true) * 1000);
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
]);
$resp = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$ms = (int)(microtime(true) * 1000) - $t0;
if ($code !== 200 || !$resp) return null;
$data = json_decode($resp, true);
$text = $data['candidates'][0]['content']['parts'][0]['text'] ?? null;
$text = $text !== null ? trim($text) : null;
if ($text !== null) {
AiLogger::log($cfg['__company_id'] ?? null, 'gemini', $model, 'vision', '[image]', $text, $ms);
}
return $text;
}
private static function claudeVision(string $b64, string $mime, string $prompt, array $cfg): ?string
{
$apiKey = $cfg['claude_api_key'] ?? env('CLAUDE_API_KEY', '');
$model = $cfg['claude_model'] ?? env('CLAUDE_MODEL', 'claude-haiku-4-5');
if ($apiKey === '') return null;
$payload = json_encode([
'model' => $model,
'max_tokens' => 200,
'messages' => [[
'role' => 'user',
'content' => [
['type' => 'image', 'source' => ['type' => 'base64', 'media_type' => $mime, 'data' => $b64]],
['type' => 'text', 'text' => $prompt],
],
]],
]);
$t0 = (int)(microtime(true) * 1000);
$ch = curl_init('https://api.anthropic.com/v1/messages');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => ['Content-Type: application/json', "x-api-key: {$apiKey}", 'anthropic-version: 2023-06-01'],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
]);
$resp = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$ms = (int)(microtime(true) * 1000) - $t0;
if ($code !== 200 || !$resp) return null;
$data = json_decode($resp, true);
$text = $data['content'][0]['text'] ?? null;
$text = $text !== null ? trim($text) : null;
if ($text !== null) {
AiLogger::log($cfg['__company_id'] ?? null, 'claude', $model, 'vision', '[image]', $text, $ms);
}
return $text;
}
// ── Helpers ───────────────────────────────────────────────────────────────
private static function getConfig(array $company): array
{
$json = $company['config_json'] ?? '';
if ($json === '') return [];
$cfg = json_decode($json, true);
return is_array($cfg) ? $cfg : [];
}
}
+1559 -82
View File
File diff suppressed because it is too large Load Diff
+126
View File
@@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
class PhoneSync
{
/** Endpoint registrado en company_endpoints que devuelve los números habilitados */
private const ENDPOINT_KEY = 'numeros_dn';
public static function syncAll(): array
{
$companies = CompanyRepository::findAll();
$results = [];
foreach ($companies as $company) {
$results[] = self::syncCompany($company);
}
return $results;
}
public static function syncCompany(array $company): array
{
$companyId = (int)$company['id'];
$name = $company['name'] ?? "company {$companyId}";
$apiKey = $company['api_key'] ?? '';
// La URL sale de company_endpoints, igual que el resto de llamadas al ERP.
// api_base_url no sirve acá: guarda el Graph API de WhatsApp, no el ERP.
$stmt = db()->prepare(
"SELECT url FROM company_endpoints WHERE company_id = ? AND endpoint_key = ? AND is_active = 1 LIMIT 1"
);
$stmt->execute([$companyId, self::ENDPOINT_KEY]);
$url = (string)($stmt->fetchColumn() ?: '');
if ($url === '') {
return ['company' => $name, 'status' => 'skip', 'reason' => 'sin endpoint ' . self::ENDPOINT_KEY];
}
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_HTTPHEADER => [
'Accept: application/json',
'Authorization: Bearer ' . $apiKey,
'X-API-Key: ' . $apiKey,
],
]);
$body = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlErr = curl_error($ch);
curl_close($ch);
if ($curlErr !== '') {
return ['company' => $name, 'status' => 'error', 'reason' => 'cURL: ' . $curlErr];
}
if ($httpCode !== 200) {
return ['company' => $name, 'status' => 'error', 'reason' => "HTTP {$httpCode}"];
}
$data = json_decode((string)$body, true);
if (!is_array($data) || ($data['status'] ?? '') !== '1') {
$msg = $data['mensaje'] ?? $data['message'] ?? 'respuesta inválida';
return ['company' => $name, 'status' => 'error', 'reason' => $msg];
}
$numeros = $data['numeros'] ?? [];
if (empty($numeros)) {
return ['company' => $name, 'status' => 'ok', 'upserted' => 0, 'deactivated' => 0];
}
$db = db();
$upsert = $db->prepare("
INSERT INTO company_phones (company_id, wa_number, label, permission_type, tercero_id, modulos_json, es_supervisor, is_active)
VALUES (?, ?, ?, ?, ?, ?, ?, 1)
ON DUPLICATE KEY UPDATE
label = VALUES(label),
permission_type = VALUES(permission_type),
tercero_id = VALUES(tercero_id),
modulos_json = VALUES(modulos_json),
es_supervisor = VALUES(es_supervisor),
is_active = 1
");
$activeNumbers = [];
foreach ($numeros as $n) {
$waNumber = trim($n['wa_number'] ?? '');
if ($waNumber === '') continue;
// Un supervisor reporta por otros, así que se le pregunta de quién
// es el registro aunque tenga su propio tercero vinculado.
$tercero = !empty($n['tercero_id']) ? (int)$n['tercero_id'] : null;
$modulos = (array)($n['modulos'] ?? []);
$upsert->execute([
$companyId,
$waNumber,
trim($n['nombre'] ?? ''),
(int)($n['permiso'] ?? 1),
$tercero,
$modulos ? json_encode($modulos) : null,
!empty($n['es_supervisor']) ? 1 : 0,
]);
$activeNumbers[] = $waNumber;
}
// Deactivate numbers removed from PALMAS360
$deactivated = 0;
if (!empty($activeNumbers)) {
$placeholders = implode(',', array_fill(0, count($activeNumbers), '?'));
$stmt = $db->prepare("
UPDATE company_phones SET is_active = 0
WHERE company_id = ? AND is_active = 1 AND wa_number NOT IN ({$placeholders})
");
$stmt->execute(array_merge([$companyId], $activeNumbers));
$deactivated = $stmt->rowCount();
}
return [
'company' => $name,
'status' => 'ok',
'upserted' => count($activeNumbers),
'deactivated' => $deactivated,
];
}
}
+220
View File
@@ -0,0 +1,220 @@
<?php
declare(strict_types=1);
/**
* Audita cada flujo de punta a punta, cruzando el seed del bot contra el
* controlador del ERP. Verifica lo que las pruebas de navegación no ven:
* que la peticion exista del otro lado, que los {placeholder} se puedan
* resolver, que el tipo de campo esté implementado y que el POST mande lo
* que el procesador exige.
*
* Uso: php setup/audit_flujos.php [ruta-a-PALMAS360]
*/
$rutaErp = $argv[1] ?? '/Users/lizandro/Documents/GitHub/PALMAS360';
// ─── Config del bot ──────────────────────────────────────────────────────────
$seed = file_get_contents(__DIR__ . '/seed_palmas.php');
$open = strpos($seed, '[', strpos($seed, '$configJson = ['));
$d = 0; $end = $open;
for ($i = $open, $n = strlen($seed); $i < $n; $i++) {
if ($seed[$i] === '[') $d++;
elseif ($seed[$i] === ']') { $d--; if (!$d) { $end = $i; break; } }
}
$config = eval('return ' . substr($seed, $open, $end - $open + 1) . ';');
// Endpoints registrados: clave => url
preg_match_all("/\['key' => '([^']+)',\s*'dir' => '([^']+)',\s*'url' => \\\$BASE \. '([^']*)'/", substr($seed, $end), $m);
$endpoints = [];
foreach ($m[1] as $i => $k) $endpoints[$k] = ['dir' => $m[2][$i], 'url' => $m[3][$i]];
// ─── Lo que expone el ERP ────────────────────────────────────────────────────
$apiPath = $rutaErp . '/php/controller/controller_api_externa.php';
$hayErp = is_file($apiPath);
$peticiones = [];
$procesador = [];
if ($hayErp) {
preg_match_all("/case '([a-z0-9_]+)':/i", file_get_contents($apiPath), $mp);
$peticiones = array_flip($mp[1]);
$procPath = $rutaErp . '/php/services/BotEntradaProcesador.php';
if (is_file($procPath)) {
$proc = file_get_contents($procPath);
// requeridos por tipo, para cruzar con lo que manda el bot
if (preg_match_all("/\\\$requeridos = \[([^\]]+)\]/", $proc, $mr)) $procesador['labores_up'] = $mr[1][0];
if (preg_match_all("/\\\$required = \[([^\]]+)\]/", $proc, $mq)) $procesador['otros'] = $mq[1];
}
}
// ─── Tipos de campo y de flujo implementados ─────────────────────────────────
$bot = file_get_contents(dirname(__DIR__) . '/services/NormalBot.php');
$tiposFlujo = [];
if (preg_match_all("/'([a-z_]+)'\s*=> self::handle|'([a-z_]+)'\s*=> self::send|'([a-z_]+)'\s*=> self::build/", $bot, $mt)) {
$tiposFlujo = array_filter(array_merge($mt[1], $mt[2], $mt[3]));
}
$tiposFlujo = array_flip(array_merge($tiposFlujo, ['text', 'menu', 'function', 'image']));
$tiposCampo = ['text', 'lookup', 'select', 'multi_select', 'static_select', 'date_quick'];
// ─── Auditoría ───────────────────────────────────────────────────────────────
$prob = [];
$avis = [];
$ok = 0;
function grupoDeMeta(array $config, string $clave): bool {
// Claves que salen del metadata acumulado, no de un campo del flujo
foreach ($config['flows'] as $f) {
if (($f['meta_key'] ?? '') === $clave) return true;
}
return in_array($clave, ['finca_id', 'grupo_id', 'wa_number'], true);
}
foreach (['global' => []] + $config['per_type'] as $cat => $pt) {
$flows = array_merge($config['flows'], $pt['flows'] ?? []);
$menus = array_merge($config['menus'], $pt['menus'] ?? []);
foreach ($flows as $key => $f) {
$tipo = $f['type'] ?? 'text';
$ref = "cat {$cat} · {$key}";
if (!isset($tiposFlujo[$tipo])) {
$prob[] = "{$ref}: tipo de flujo '{$tipo}' no implementado";
continue;
}
// Endpoints que el flujo usa, con los placeholders que llevan
$usados = [];
foreach (['endpoint_key', 'source_endpoint_key', 'full_endpoint_key', 'source_endpoint_key_all'] as $k) {
if (!empty($f[$k])) $usados[] = $f[$k];
}
foreach ($f['fields'] ?? [] as $campo) {
foreach (['source_endpoint_key', 'full_endpoint_key', 'lookup_endpoint_key'] as $k) {
if (!empty($campo[$k])) $usados[] = $campo[$k];
}
$tc = $campo['type'] ?? 'text';
if (!in_array($tc, $tiposCampo, true)) {
$prob[] = "{$ref}: campo '{$campo['key']}' usa tipo '{$tc}', que no existe";
}
}
if (!empty($f['params']['endpoint_key'])) $usados[] = $f['params']['endpoint_key'];
if (!empty($f['params']['next_endpoint_key'])) $usados[] = $f['params']['next_endpoint_key'];
$clavesCampo = array_column($f['fields'] ?? [], 'key');
foreach (array_unique($usados) as $ek) {
// La clave puede depender de lo elegido: lotes_{accion}_dn
$variantes = [$ek];
if (preg_match('/\{(\w+)\}/', $ek, $mm)) {
$campo = $mm[1];
$opts = [];
foreach ($f['fields'] ?? [] as $c) {
if (($c['key'] ?? '') === $campo) $opts = array_column($c['options'] ?? [], 'id');
}
if (!$opts) {
$prob[] = "{$ref}: la clave '{$ek}' depende de '{$campo}', que no es un campo con opciones";
continue;
}
$variantes = array_map(fn($o) => str_replace($mm[0], $o, $ek), $opts);
}
foreach ($variantes as $v) {
if (!isset($endpoints[$v])) {
$prob[] = "{$ref}: endpoint '{$v}' sin registrar";
continue;
}
$ok++;
$url = $endpoints[$v]['url'];
// La peticion tiene que existir del otro lado
if ($hayErp && preg_match('/peticion=([a-z0-9_{}]+)/i', $url, $mu)) {
$pet = $mu[1];
$pets = [$pet];
if (preg_match('/\{(\w+)\}/', $pet, $mc)) {
$opts = [];
foreach ($f['fields'] ?? [] as $c) {
if (($c['key'] ?? '') === $mc[1]) $opts = array_column($c['options'] ?? [], 'id');
}
$pets = array_map(fn($o) => str_replace($mc[0], $o, $pet), $opts);
}
foreach ($pets as $p) {
if (!isset($peticiones[$p])) $prob[] = "{$ref}: el ERP no expone '?peticion={$p}'";
}
}
// Cada {placeholder} de la URL tiene que poder resolverse
preg_match_all('/\{(\w+)\}/', $url, $mph);
foreach (array_unique($mph[1]) as $ph) {
if (in_array($ph, $clavesCampo, true)) continue;
if (grupoDeMeta($config, $ph)) continue;
$prob[] = "{$ref}: '{$ph}' en la URL de '{$v}' no sale de ningún campo ni del contexto";
}
}
}
// Un flujo que postea sin campos no manda nada
if ($tipo === 'collect_and_post' && empty($f['fields'])) {
$prob[] = "{$ref}: collect_and_post sin campos";
}
// requires necesita su resolver, y el resolver tiene que existir
if (!empty($f['requires'])) {
$r = $f['resolver'] ?? '';
if ($r === '') $prob[] = "{$ref}: declara requires sin resolver";
elseif (!isset($flows[$r])) $prob[] = "{$ref}: resolver '{$r}' no existe";
}
}
// Cada opción de menú debe llevar a algún lado
foreach ($menus as $mk => $menu) {
$ids = array_column($menu['buttons'] ?? [], 'id');
foreach ($menu['sections'] ?? [] as $sec) $ids = array_merge($ids, array_column($sec['rows'] ?? [], 'id'));
foreach ($ids as $id) {
if (!isset($flows[$id]) && !isset($menus[$id])) {
$prob[] = "cat {$cat} · menú {$mk}: la opción '{$id}' no lleva a ningún flujo";
}
}
// WhatsApp corta las listas largas
foreach ($menu['sections'] ?? [] as $sec) {
if (count($sec['rows'] ?? []) > 10) {
$avis[] = "cat {$cat} · menú {$mk}: " . count($sec['rows']) . ' filas, WhatsApp muestra 10';
}
}
if (count($menu['buttons'] ?? []) > 3) {
$prob[] = "cat {$cat} · menú {$mk}: " . count($menu['buttons']) . ' botones, WhatsApp admite 3';
}
}
}
// Endpoints registrados que nadie usa
$usadosTodos = [];
foreach ($config['flows'] as $f) {
foreach (['endpoint_key', 'source_endpoint_key', 'full_endpoint_key', 'source_endpoint_key_all'] as $k) {
if (!empty($f[$k])) $usadosTodos[] = $f[$k];
}
foreach ($f['fields'] ?? [] as $c) {
foreach (['source_endpoint_key', 'full_endpoint_key', 'lookup_endpoint_key'] as $k) {
if (!empty($c[$k])) $usadosTodos[] = $c[$k];
}
}
if (!empty($f['params']['endpoint_key'])) $usadosTodos[] = $f['params']['endpoint_key'];
if (!empty($f['params']['next_endpoint_key'])) $usadosTodos[] = $f['params']['next_endpoint_key'];
}
$patrones = array_map(fn($u) => '/^' . str_replace('\{accion\}', '\w+', preg_quote($u, '/')) . '$/', $usadosTodos);
foreach ($endpoints as $k => $e) {
$usado = false;
foreach ($patrones as $p) { if (preg_match($p, $k)) { $usado = true; break; } }
if (!$usado && $k !== 'numeros_dn') $avis[] = "endpoint '{$k}' registrado pero sin usar";
}
// ─── Resultado ───────────────────────────────────────────────────────────────
echo "\nAuditoría de flujos\n";
echo str_repeat('─', 60) . "\n";
echo "ERP: " . ($hayErp ? $apiPath : 'no encontrado — se omiten esos chequeos') . "\n";
echo "Endpoints: " . count($endpoints) . " registrados, {$ok} referencias resueltas\n";
echo "Peticiones: " . count($peticiones) . " expuestas por el ERP\n\n";
foreach (array_unique($avis) as $a) echo " aviso {$a}\n";
foreach (array_unique($prob) as $p) echo " PROBLEMA {$p}\n";
$n = count(array_unique($prob));
echo "\n" . ($n ? "{$n} problema(s)\n" : "Sin problemas: todos los flujos cierran de punta a punta\n");
exit($n ? 1 : 0);
+47 -1
View File
@@ -200,7 +200,9 @@ $seedSettings = [
'openai_api_key' => '', 'openai_api_key' => '',
'openai_model' => 'gpt-4o-mini', 'openai_model' => 'gpt-4o-mini',
'gemini_api_key' => '', 'gemini_api_key' => '',
'gemini_model' => 'gemini-2.0-flash', 'gemini_model' => 'gemini-2.5-flash',
'claude_api_key' => '',
'claude_model' => 'claude-haiku-4-5',
'ai_max_tokens' => '500', 'ai_max_tokens' => '500',
'ai_default_prompt' => 'Eres un asistente virtual de Palmas360. Responde de forma amable y profesional.', 'ai_default_prompt' => 'Eres un asistente virtual de Palmas360. Responde de forma amable y profesional.',
]; ];
@@ -232,8 +234,11 @@ $db->exec("
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
company_id INT NOT NULL, company_id INT NOT NULL,
endpoint_key VARCHAR(50) NOT NULL, endpoint_key VARCHAR(50) NOT NULL,
name VARCHAR(100) DEFAULT '',
params TEXT DEFAULT NULL,
direction ENUM('upload','download') NOT NULL, direction ENUM('upload','download') NOT NULL,
url VARCHAR(500) DEFAULT '', url VARCHAR(500) DEFAULT '',
body_fields TEXT DEFAULT NULL,
method VARCHAR(10) DEFAULT 'GET', method VARCHAR(10) DEFAULT 'GET',
last_response TEXT, last_response TEXT,
last_called_at DATETIME, last_called_at DATETIME,
@@ -244,6 +249,47 @@ $db->exec("
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
"); ");
$db->exec("
CREATE TABLE IF NOT EXISTS sessions (
id VARCHAR(128) NOT NULL PRIMARY KEY,
data LONGTEXT NOT NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_sessions_updated (updated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
");
$db->exec("
CREATE TABLE IF NOT EXISTS multi_company_sessions (
wa_number VARCHAR(20) NOT NULL PRIMARY KEY,
companies_json TEXT NOT NULL,
selected_id INT DEFAULT NULL,
expires_at DATETIME NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_expires (expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
");
// ─── AI usage logs ───────────────────────────────────────────────────────────
$db->exec("
CREATE TABLE IF NOT EXISTS ai_logs (
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
company_id INT DEFAULT NULL,
phone_number VARCHAR(20) DEFAULT NULL,
provider VARCHAR(20) NOT NULL,
model VARCHAR(60) NOT NULL DEFAULT '',
call_type VARCHAR(30) NOT NULL DEFAULT 'chat',
input_preview VARCHAR(500) DEFAULT NULL,
output_preview VARCHAR(500) DEFAULT NULL,
tokens_in INT DEFAULT NULL,
tokens_out INT DEFAULT NULL,
duration_ms INT DEFAULT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_company (company_id),
INDEX idx_provider (provider),
INDEX idx_created (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
");
// ─── Campo erp_active_users en companies ────────────────────────────────────── // ─── Campo erp_active_users en companies ──────────────────────────────────────
try { try {
$db->exec("ALTER TABLE companies ADD COLUMN erp_active_users INT DEFAULT 0 AFTER is_active"); $db->exec("ALTER TABLE companies ADD COLUMN erp_active_users INT DEFAULT 0 AFTER is_active");
File diff suppressed because it is too large Load Diff
+660
View File
@@ -0,0 +1,660 @@
<?php
declare(strict_types=1);
/**
* Recorre en seco la máquina de navegación: intención pendiente, comando "atrás"
* y skip_if_set. Replica la lógica de NormalBot sin BD ni WhatsApp; si alguna
* regla cambia acá se rompe la prueba y no el bot en producción.
*
* Uso: php setup/test_navegacion.php
*/
$seed = file_get_contents(__DIR__ . '/seed_palmas.php');
$open = strpos($seed, '[', strpos($seed, '$configJson = ['));
$depth = 0; $end = $open;
for ($i = $open, $n = strlen($seed); $i < $n; $i++) {
if ($seed[$i] === '[') $depth++;
elseif ($seed[$i] === ']') { $depth--; if ($depth === 0) { $end = $i; break; } }
}
$config = eval('return ' . substr($seed, $open, $end - $open + 1) . ';');
function flowsDe(array $config, string $cat): array {
$pt = $config['per_type'][$cat] ?? [];
return array_merge($config['flows'] ?? [], $pt['flows'] ?? []);
}
function comandosDe(array $config, string $cat): array {
$pt = $config['per_type'][$cat] ?? [];
return array_merge($config['commands'] ?? [], $pt['commands'] ?? []);
}
/** Espejo de divertToResolver() + advanceAfterSelect() */
function ejecutar(array $flows, string $key, array $meta, array $seleccionaria = []): array {
$pasos = [];
for ($i = 0; $i < 5; $i++) {
$pasos[] = $key;
$flow = $flows[$key] ?? null;
if ($flow === null) break;
$falta = null;
foreach ($flow['requires'] ?? [] as $g => $k) {
if (($meta[$g][$k] ?? '') === '') { $falta = [$g, $k]; break; }
}
if ($falta === null) break;
if (($meta['__resolve_attempt'] ?? '') === $key) { $pasos[] = '*sin-resolver*'; break; }
$meta['__after_select'] = $key;
$meta['__resolve_attempt'] = $key;
$resolver = $flow['resolver'];
$pasos[] = $resolver;
// El resolver resuelve si tenemos con qué (entity o tap); si no, muestra lista
[$g, $k] = $falta;
if (!isset($seleccionaria[$g])) { $pasos[] = '*muestra-lista*'; break; }
$meta[$g][$k] = $seleccionaria[$g];
$key = $meta['__after_select'];
unset($meta['__after_select']);
}
return [$pasos, $meta];
}
$fallas = 0;
function check(string $nombre, $obtenido, $esperado): void {
global $fallas;
$ok = $obtenido === $esperado;
if (!$ok) $fallas++;
printf("%s %s\n", $ok ? ' ok ' : ' FALLA', $nombre);
if (!$ok) {
echo " esperaba: " . json_encode($esperado, JSON_UNESCAPED_UNICODE) . "\n";
echo " obtuvo: " . json_encode($obtenido, JSON_UNESCAPED_UNICODE) . "\n";
}
}
$flows = flowsDe($config, '2');
echo "\nIntención pendiente\n";
// "quiero informe de mantenimiento de plateo": el NLU rutea al informe y la
// entity resuelve el grupo, así que el submenú nunca se muestra.
[$pasos] = ejecutar($flows, 'ciclo_mantenimiento_todos', [], ['grupo_mant' => '7']);
check('con grupo por entity llega al informe',
$pasos, ['ciclo_mantenimiento_todos', 'ciclo_mantenimiento', 'ciclo_mantenimiento_todos']);
// Sin poder resolver el grupo, muestra la lista y espera al usuario
[$pasos] = ejecutar($flows, 'ciclo_mantenimiento_todos', []);
check('sin grupo pide la lista',
$pasos, ['ciclo_mantenimiento_todos', 'ciclo_mantenimiento', '*muestra-lista*']);
// Grupo ya elegido (viene del submenú): va directo, sin volver a preguntar
[$pasos] = ejecutar($flows, 'ciclo_mantenimiento_todos', ['grupo_mant' => ['grupo_id' => '7']]);
check('con grupo ya elegido no repregunta', $pasos, ['ciclo_mantenimiento_todos']);
// Un resolver que no deja el dato no puede rebotar para siempre
$roto = $flows;
$roto['resolver_roto'] = ['type' => 'menu', 'menu' => 'submenu_ciclos'];
$roto['informe_roto'] = ['type' => 'function', 'requires' => ['nada' => 'x'], 'resolver' => 'resolver_roto'];
[$pasos] = ejecutar($roto, 'informe_roto', [], ['otra_cosa' => '1']);
check('resolver mal configurado corta el bucle',
$pasos, ['informe_roto', 'resolver_roto', '*muestra-lista*']);
echo "\nComando atrás\n";
$atras = function (array $flows, ?string $nodo): string {
return ($nodo !== null ? ($flows[$nodo]['back'] ?? null) : null) ?? 'show_main_menu';
};
check('desde un ciclo de sanidad sube a sanidad', $atras($flows, 'ciclo_plagas'), 'submenu_ciclos_sanidad');
check('desde sanidad sube a ciclos', $atras($flows, 'submenu_ciclos_sanidad'), 'submenu_ciclos');
check('desde ciclos sube a la raíz', $atras($flows, 'submenu_ciclos'), 'show_main_menu');
check('desde un informe de mantenimiento al submenú',
$atras($flows, 'ciclo_mantenimiento_todos'), 'submenu_ciclo_mantenimiento');
check('sin nodo activo cae a la raíz', $atras($flows, null), 'show_main_menu');
echo "\nComandos\n";
foreach (['2', '3'] as $cat) {
$cmds = comandosDe($config, $cat);
check("cat {$cat}: 'atras' resuelve por mapa", $cmds['atras'] ?? null, '__back');
check("cat {$cat}: 'cambiar finca' existe", $cmds['cambiar finca'] ?? null, 'reset_finca');
check("cat {$cat}: 'salir' va a la raíz", $cmds['salir'] ?? null, 'show_main_menu');
}
echo "\nQuién atiende el texto libre\n";
/**
* Espejo del orden de process(): qué paso agarra un mensaje de texto que no es
* comando. Lo que importa es que el NLU llegue a opinar cuando la sesión ya
* empezó — si no, "quiero informe de plateo" rebota al menú.
*/
$atiende = function (?string $nodo, $greeting, ?string $greetingMenu, array $flows): string {
$centinelas = ['collecting', '__greeted'];
if ($nodo !== null && !in_array($nodo, $centinelas, true) && isset($flows[$nodo])) {
if (($flows[$nodo]['type'] ?? '') !== 'menu') return 'nodo-activo';
}
if ($greetingMenu !== null && $nodo === null) return 'menu-bienvenida';
if ($greeting !== null && $nodo === null) return 'flujo-bienvenida';
return 'nlu';
};
// Cat 2/3 usan greeting = '' para disparar ask_finca en el primer mensaje
check('sesión nueva pide la finca',
$atiende(null, '', null, $flows), 'flujo-bienvenida');
check('tras un informe el NLU atiende',
$atiende('__greeted', '', null, $flows), 'nlu');
check('parado en un menú el NLU atiende',
$atiende('submenu_ciclos', '', null, $flows), 'nlu');
check('en medio de una lista no interfiere',
$atiende('collecting', '', null, $flows), 'nlu');
echo "\nEntities por campo\n";
/** Espejo del matching de handleDynamicList con entity_key */
$matchear = function (array $flow, array $entities, array $etiquetas): array {
$ek = $flow['entity_key'] ?? '';
$propias = [];
if ($ek !== '') {
foreach ($entities as $k => $v) if (mb_strtolower($k) === mb_strtolower($ek)) $propias[$k] = $v;
} else {
$propias = $entities;
}
foreach ($propias as $k => $v) {
foreach ($etiquetas as $etiqueta) {
$a = mb_strtolower($v); $b = mb_strtolower($etiqueta);
if ($a === $b || str_contains($a, $b) || str_contains($b, $a)) {
unset($entities[$k]);
return ['match' => $etiqueta, 'sobran' => $entities];
}
}
}
return ['match' => null, 'sobran' => $entities];
};
$dichas = ['finca' => 'reposo', 'grupo' => 'plateo'];
$fincas = ['ROSA BLANCA', 'REPOSO', '🌐 Todas las fincas'];
$grupos = ['PLATEO', 'CORONA', 'PODA'];
// Cada lista toma lo suyo y deja el resto para la siguiente
$r1 = $matchear($flows['ask_finca'], $dichas, $fincas);
check('ask_finca toma la finca', $r1['match'], 'REPOSO');
check('y deja el grupo para después', $r1['sobran'], ['grupo' => 'plateo']);
$r2 = $matchear($flows['ciclo_mantenimiento'], $r1['sobran'], $grupos);
check('ciclo_mantenimiento toma el grupo', $r2['match'], 'PLATEO');
check('no queda nada sin consumir', $r2['sobran'], []);
// El bug que motivó todo: una finca no debe matchear contra grupos
$r3 = $matchear($flows['ciclo_mantenimiento'], ['finca' => 'reposo'], $grupos);
check('una finca no se consume como grupo', $r3['match'], null);
check('y sobrevive para ask_finca', $r3['sobran'], ['finca' => 'reposo']);
echo "\nFinca visible en el menú\n";
/** Espejo de aplicarVarsMenu() */
$footer = function (array $menu, string $finca): string {
$f = $menu['footer'] ?? '';
$f = str_replace('{finca}', $finca, $f);
if ($finca !== '' && !str_contains($f, $finca)) $f = '📍 ' . $finca;
return $f;
};
$menus = $config['menus'];
check('lista muestra la finca activa',
$footer($menus['show_menu_cat2'], 'ROSA BLANCA'), '📍 ROSA BLANCA');
check('"todas" se lee natural',
$footer($menus['show_menu_cat2'], '🌐 Todas las fincas'), '📍 🌐 Todas las fincas');
check('sin finca queda el footer de siempre',
$footer($menus['show_menu_cat2'], ''), 'Palmas360');
check('submenú de botones también la lleva',
$footer($menus['submenu_ciclo_mantenimiento'], 'REPOSO'), '📍 REPOSO');
check('entra en los 60 caracteres del footer',
mb_strlen($footer($menus['show_menu_cat2'], 'ROSA BLANCA')) <= 60, true);
echo "\nBienvenida diaria\n";
/** Espejo de saludarUnaVezAlDia(): devuelve el texto o '' si no toca saludar */
$saludar = function (array $config, string $cat, array &$meta, string $hoy, string $nombre = 'Usite'): string {
$w = $config['welcome'] ?? [];
if (empty($w['enabled'])) return '';
$texto = (string)($w['text'][$cat] ?? '');
if (trim($texto) === '') return '';
if (($meta['__saludo'] ?? '') === $hoy) return '';
$meta['__saludo'] = $hoy;
return strtr($texto, [
'{saludo}' => $nombre !== '' ? "Hola *{$nombre}*" : 'Hola',
'{empresa}' => 'Rosa Blanca',
'{finca}' => '',
'{empresas}' => '',
]);
};
$meta = [];
$primero = $saludar($config, '3', $meta, '2026-08-01');
check('saluda en el primer mensaje del día', $primero !== '', true);
check('personaliza con el nombre', str_contains($primero, 'Hola *Usite*'), true);
check('nombra la empresa', str_contains($primero, 'Rosa Blanca'), true);
check('informa los comandos', str_contains($primero, '*atrás*') && str_contains($primero, '*salir*'), true);
check('no deja tokens sin resolver', preg_match('/\{[a-z]+\}/', $primero), 0);
check('no vuelve a saludar el mismo día', $saludar($config, '3', $meta, '2026-08-01'), '');
// preservingReset conserva __saludo, así que un informe no dispara otro saludo
$trasInforme = array_intersect_key($meta, array_flip(['finca', 'grupo_mant', '__saludo']));
check('sobrevive a un informe', $saludar($config, '3', $trasInforme, '2026-08-01'), '');
check('al día siguiente saluda de nuevo', $saludar($config, '3', $meta, '2026-08-02') !== '', true);
// Cat 1 no descarga informes: no debe ofrecerle cosas que no puede usar
$m1 = [];
$cat1 = $saludar($config, '1', $m1, '2026-08-01');
check('cat 1 no ofrece descargas', str_contains($cat1, 'faltó hoy'), false);
// Cat 1 solo reporta: el saludo debe nombrar los tres flujos de carga
foreach (['ausentismo', 'ciclos', 'pluviometría'] as $f) {
check("cat 1 ofrece {$f}", str_contains(mb_strtolower($cat1), $f), true);
}
// Sin nombre no queda "Hola **,"
$m2 = [];
check('sin nombre no rompe el saludo',
str_starts_with($saludar($config, '2', $m2, '2026-08-01', ''), 'Hola,'), true);
echo "\nAusentismo (POST)\n";
$aus = $flows['registrar_ausentismo'] ?? [];
$campos = array_column($aus['fields'] ?? [], null, 'key');
// Contrato de BotEntradaProcesador::ausentismo()
$requeridos = ['empleado_id', 'novedad_id', 'fecha_inicial', 'fecha_final'];
check('recolecta los 4 campos que exige el ERP',
array_values(array_intersect($requeridos, array_keys($campos))), $requeridos);
check('el trabajador se busca por nombre',
($campos['empleado_id']['lookup_param'] ?? null), 'filtro');
check('lee id/nombre como los devuelve /empleados',
[$campos['empleado_id']['value_field'] ?? null, $campos['empleado_id']['display_field'] ?? null],
['id', 'nombre']);
check('el motivo sale del catálogo del ERP',
($campos['novedad_id']['source_endpoint_key'] ?? null), 'novedades_ausentismo_dn');
check('ambas fechas validan formato',
[$campos['fecha_inicial']['other_validate'] ?? null, $campos['fecha_final']['other_validate'] ?? null],
['date', 'date']);
check('pide confirmación antes de enviar', $aus['confirm'] ?? false, true);
// Los endpoints que usa deben estar dados de alta
preg_match_all("/'key' => '([^']+)'/", substr($seed, $end), $mEp);
$eps = array_flip($mEp[1]);
foreach (['ausentismos_up', 'empleados_buscar_dn', 'novedades_ausentismo_dn'] as $k) {
check("endpoint {$k} registrado", isset($eps[$k]), true);
}
// Accesible para quienes reportan (cat 1 y cat 3), no para cat 2
$enMenu = function (array $config, string $cat, string $id): bool {
$pt = $config['per_type'][$cat] ?? [];
$menus = array_merge($config['menus'] ?? [], $pt['menus'] ?? []);
foreach ($menus as $m) {
foreach ($m['sections'] ?? [] as $sec) {
if (in_array($id, array_column($sec['rows'] ?? [], 'id'), true)) return true;
}
}
return false;
};
check('cat 1 lo ve en su menú', $enMenu($config, '1', 'registrar_ausentismo'), true);
check('cat 3 lo ve en enviar información', $enMenu($config, '3', 'registrar_ausentismo'), true);
echo "\nPluviometría (POST)\n";
$plu = $flows['registrar_pluviometria'] ?? [];
check('pide la fecha antes del bucle', $plu['ask_date'] ?? false, true);
check('excluye "Todas las fincas"', $plu['exclude_values'] ?? [], ['0']);
check('postea a pluvio_upload', $plu['endpoint_key'] ?? null, 'pluvio_upload');
check('endpoint pluvio_upload registrado', isset($eps['pluvio_upload']), true);
// Espejo de exclude_values: el catálogo antepone id 0 = "🌐 Todas las fincas"
$catalogo = [
['id' => '0', 'label' => '🌐 Todas las fincas'],
['id' => '4', 'label' => 'REPOSO'],
['id' => '7', 'label' => 'ROSA BLANCA'],
];
$reales = array_values(array_filter($catalogo,
fn($i) => !in_array((string)$i['id'], array_map('strval', $plu['exclude_values']), true)));
check('solo pregunta por fincas reales', array_column($reales, 'label'), ['REPOSO', 'ROSA BLANCA']);
// Espejo de submitForeach(): forma que exige BotEntradaProcesador::pluviosidad()
$armarBody = function (array $fe, string $from, string $nombre): array {
$lecturas = [];
foreach ($fe['collected'] as $id => $row) {
$lecturas[] = ['id' => (string)$id, 'label' => $row['label'], 'valor' => $row['valor']];
}
return [
'fecha' => $fe['fecha'] ?? date('Y-m-d'),
'telefono' => $from,
'nombre' => $nombre,
($fe['items_key'] ?? 'fincas') => $lecturas,
];
};
$body = $armarBody([
'fecha' => '2026-08-01',
'items_key' => $plu['items_key'] ?? 'fincas',
'collected' => [
'4' => ['label' => 'REPOSO', 'valor' => '35'],
'7' => ['label' => 'ROSA BLANCA', 'valor' => '12'],
],
], '573001565293', 'Usite');
// El procesador lee $d['fincas'] y $d['fecha']; el controller, telefono y nombre
check('la clave es "fincas", no "lecturas"', array_key_exists('fincas', $body), true);
check('manda la fecha elegida', $body['fecha'], '2026-08-01');
check('manda trazabilidad', [$body['telefono'], $body['nombre']], ['573001565293', 'Usite']);
check('cada lectura lleva id, label y valor',
array_keys($body['fincas'][0]), ['id', 'label', 'valor']);
check('los ids son los de finca', array_column($body['fincas'], 'id'), ['4', '7']);
// Sin fecha elegida cae a hoy, no a null
check('sin fecha usa hoy',
$armarBody(['collected' => []], 'x', 'y')['fecha'], date('Y-m-d'));
echo "\nRuteo de respuestas interactivas en collect_for_each\n";
/** Espejo del bloque __foreach de processInteractive() */
$rutaFe = function (array $fe, string $input): string {
if ($input === '__foreach_confirm') return 'enviar';
if ($input === '__foreach_cancel') return 'cancelar';
if (!empty($fe['ask_date']) && ($fe['fecha'] ?? null) === null) return 'foreach-input';
return 'menus';
};
$esperandoFecha = ['ask_date' => true, 'fecha' => null];
$conFecha = ['ask_date' => true, 'fecha' => '2026-08-04'];
// El bug: elegir la fecha caía en la resolución de menús y moría en silencio
check('elegir la fecha llega al flujo', $rutaFe($esperandoFecha, '2026-08-04'), 'foreach-input');
check('"otra fecha" también', $rutaFe($esperandoFecha, '__fe_other'), 'foreach-input');
check('confirmar sigue enviando', $rutaFe($esperandoFecha, '__foreach_confirm'), 'enviar');
check('cancelar sigue cancelando', $rutaFe($esperandoFecha, '__foreach_cancel'), 'cancelar');
// Con la fecha resuelta no debe secuestrar la navegación por menús
check('con fecha ya elegida no interfiere', $rutaFe($conFecha, 'submenu_ciclos'), 'menus');
check('un foreach sin fecha tampoco', $rutaFe(['ask_date' => false], 'submenu_ciclos'), 'menus');
echo "\nPaginado de listas largas\n";
/** Espejo del paginado de buildDynamicListResponse() */
$paginar = function (array $items, int $pagina, int $max = 10): array {
$hayMas = count($items) > $max;
$porPagina = $hayMas ? $max - 1 : $max;
$filas = array_slice($items, $pagina * $porPagina, $porPagina);
$restantes = count($items) - (($pagina + 1) * $porPagina);
if ($hayMas && $restantes > 0) $filas[] = '__mas';
return $filas;
};
// El caso real: 17 novedades de ausentismo, de las que 7 eran inalcanzables
$novedades = array_map(fn($n) => "N{$n}", range(1, 17));
$vistas = [];
$pagina = 0;
do {
$filas = $paginar($novedades, $pagina);
$hayMas = in_array('__mas', $filas, true);
foreach ($filas as $f) if ($f !== '__mas') $vistas[] = $f;
$pagina++;
} while ($hayMas && $pagina < 20);
check('recorriendo las páginas se llega a todas', count($vistas), 17);
check('ninguna se repite', count(array_unique($vistas)), 17);
check('ninguna se pierde', array_diff($novedades, $vistas), []);
check('termina, no cicla', $pagina <= 3, true);
check('la última página no ofrece "ver más"',
in_array('__mas', $paginar($novedades, 1), true), false);
// Con 10 o menos no debe aparecer el botón
check('10 opciones entran sin paginar',
$paginar(array_slice($novedades, 0, 10), 0), array_slice($novedades, 0, 10));
echo "\nCiclos — apertura y cierre (POST)\n";
$cic = $flows['registrar_ciclo'] ?? [];
$cf = array_column($cic['fields'] ?? [], null, 'key');
check('pregunta ciclo, acción, fecha y lotes',
array_keys($cf), ['ciclo', 'accion', 'fecha', 'lote_ids']);
check('los seis ciclos, sin mantenimiento',
array_column($cf['ciclo']['options'], 'id'),
['cosecha', 'polinizacion', 'sanidad', 'censo', 'palm', 'tratamiento']);
check('apertura y cierre',
array_column($cf['accion']['options'], 'id'), ['apertura', 'cierre']);
check('los lotes se eligen de a varios', $cf['lote_ids']['type'], 'multi_select');
// El catálogo de lotes depende de la acción: todos vs solo los abiertos
$resolver = function (string $tpl, array $col): string {
foreach ($col as $k => $v) $tpl = str_replace('{' . $k . '}', (string)$v, $tpl);
return $tpl;
};
check('apertura ofrece los lotes de la finca',
$resolver($cf['lote_ids']['source_endpoint_key'], ['accion' => 'apertura']), 'lotes_apertura_dn');
check('cierre ofrece solo los abiertos',
$resolver($cf['lote_ids']['source_endpoint_key'], ['accion' => 'cierre']), 'lotes_cierre_dn');
// Un solo endpoint registrado sirve para los seis tipos
foreach (['ciclos_up', 'lotes_apertura_dn', 'lotes_cierre_dn'] as $k) {
check("endpoint {$k} registrado", isset($eps[$k]), true);
}
$urlCiclos = $BASE_TEST = '?peticion=ciclos_{ciclo}_up';
check('la URL del POST resuelve por ciclo',
$resolver($urlCiclos, ['ciclo' => 'censo']), '?peticion=ciclos_censo_up');
check('y coincide con los tipos del ERP',
array_map(fn($o) => $resolver($urlCiclos, ['ciclo' => $o['id']]), $cf['ciclo']['options']),
['?peticion=ciclos_cosecha_up', '?peticion=ciclos_polinizacion_up', '?peticion=ciclos_sanidad_up',
'?peticion=ciclos_censo_up', '?peticion=ciclos_palm_up', '?peticion=ciclos_tratamiento_up']);
// Espejo del acumulador de multi_select
$alternar = function (array $elegidos, string $id, array $catalogo): array {
if (isset($elegidos[$id])) unset($elegidos[$id]);
else $elegidos[$id] = $catalogo[$id];
return $elegidos;
};
$catalogo = ['4' => 'REPOSO 1A', '7' => 'REPOSO 1B', '9' => 'REPOSO 2A'];
$e = [];
$e = $alternar($e, '4', $catalogo);
$e = $alternar($e, '7', $catalogo);
check('acumula varios lotes', array_map('strval', array_keys($e)), ['4', '7']);
$e = $alternar($e, '4', $catalogo);
check('y desmarca al volver a tocar', array_map('strval', array_keys($e)), ['7']);
check('el POST manda un array de ids', array_map('strval', array_keys($alternar($e, '9', $catalogo))), ['7', '9']);
check('accesible desde enviar información', $enMenu($config, '3', 'registrar_ciclo'), true);
echo "\nCategorías ruteables por el NLU\n";
/** Espejo de buildFlowCatalog(): qué ve la IA según la categoría del usuario */
$catalogo = function (array $config, int $perm): array {
$out = [];
foreach (flowsDe($config, (string)$perm) as $k => $f) {
$tipo = $f['type'] ?? 'text';
$dir = $f['nlu_dir'] ?? '';
$sube = in_array($tipo, ['collect_and_post', 'collect_for_each', 'submit_form'], true) || $dir === 'subida';
$baja = ($tipo === 'function' && ($f['function'] ?? '') === 'api_report') || $dir === 'descarga';
if ($sube && !in_array($perm, [1, 3], true)) continue;
if ($baja && !in_array($perm, [2, 3], true)) continue;
if (!empty($f['nlu_skip'])) continue;
if (($f['nlu_description'] ?? '') === '') continue;
$out[] = $k;
}
return $out;
};
// Los tres casos que reporto la clienta: decir la categoria a secas
foreach (['submenu_ausentismos', 'submenu_produccion', 'submenu_ciclos_sanidad', 'submenu_ciclos'] as $k) {
check("la IA puede rutear a {$k}", in_array($k, $catalogo($config, 2), true), true);
}
// El filtro de permisos resuelve la ambiguedad en cat 1 y cat 2
$c1 = $catalogo($config, 1);
$c2 = $catalogo($config, 2);
check('cat 1 solo ve registrar ausentismo',
[in_array('registrar_ausentismo', $c1, true), in_array('submenu_ausentismos', $c1, true)], [true, false]);
check('cat 2 solo ve consultar ausentismos',
[in_array('registrar_ausentismo', $c2, true), in_array('submenu_ausentismos', $c2, true)], [true === false, true]);
// Cat 3 ve las dos, por eso necesita desambiguar
$c3 = $catalogo($config, 3);
check('cat 3 ve consultar y registrar',
[in_array('registrar_ausentismo', $c3, true), in_array('submenu_ausentismos', $c3, true)], [true, true]);
check('y tiene el menu que pregunta cual', in_array('ausentismo', $c3, true), true);
// La navegacion pura sigue oculta: rutear ahi no aporta nada
foreach (['show_main_menu', 'descargar_informes', 'enviar_informacion'] as $k) {
check("{$k} sigue oculto al NLU", in_array($k, $catalogo($config, 3), true), false);
}
// El orden por uso lo resuelve Palmas360, no el bot
check('el bot ya no reordena por su cuenta',
isset(flowsDe($config, '3')['registrar_ausentismo']['fields'][1]['frecuentes']), false);
echo "\nLotes para abrir y cerrar ciclos\n";
$cfl = array_column($flows['registrar_ciclo']['fields'], null, 'key')['lote_ids'];
check('apertura y cierre usan catálogos distintos',
$cfl['source_endpoint_key'], 'lotes_{accion}_dn');
check('y "Otro lote" trae el listado completo',
$cfl['full_endpoint_key'], 'lotes_{accion}_todos_dn');
$resolver = function (string $t, array $c): string {
foreach ($c as $k => $v) $t = str_replace('{' . $k . '}', (string)$v, $t);
return $t;
};
foreach (['apertura', 'cierre'] as $acc) {
check("{$acc}: catálogo corto", $resolver($cfl['source_endpoint_key'], ['accion' => $acc]), "lotes_{$acc}_dn");
check("{$acc}: catálogo completo", $resolver($cfl['full_endpoint_key'], ['accion' => $acc]), "lotes_{$acc}_todos_dn");
foreach ([$acc . '_dn', $acc . '_todos_dn'] as $sufijo) {
check("endpoint lotes_{$sufijo} registrado", isset($eps['lotes_' . $sufijo]), true);
}
}
// Con "Otro" visible se reservan tres filas: paginar, ver todos y listo
$filas = function (int $total, bool $verTodos): int {
$max = 10;
$porPagina = $max - ($verTodos ? 3 : 2);
$hayMas = $total > $porPagina;
$n = min($total, $porPagina);
if ($hayMas) $n++;
if ($verTodos) $n++;
return $n + 1;
};
check('la lista corta entra en el límite de WhatsApp', $filas(10, true) <= 10, true);
check('el listado completo también', $filas(40, false) <= 10, true);
echo "\nMantenimiento (POST)\n";
$mto = $flows['registrar_mantenimiento'] ?? [];
$mf = array_column($mto['fields'] ?? [], null, 'key');
// labores_up exige novedad y empleados: sin ellos el ERP rechazaba siempre
check('pide fecha, lote, labor, cuadrilla y cantidad',
array_keys($mf), ['fecha', 'lote_id', 'novedad_id', 'empleados', 'cantidad']);
check('los lotes salen de los que tienen pendiente',
$mf['lote_id']['source_endpoint_key'] ?? null, 'lotes_mantenimiento_dn');
check('endpoint registrado', isset($eps['lotes_mantenimiento_dn']), true);
check('postea como labor diaria', $mto['endpoint_key'] ?? null, 'labores_up');
// El grupo se pide antes, con la misma intencion pendiente de los informes
check('pide el grupo si falta', $mto['requires'] ?? null, ['grupo_mant' => 'grupo_id']);
check('y lo resuelve con el selector', $mto['resolver'] ?? null, 'ciclo_mantenimiento');
// No va en el flujo de ciclos: no tiene apertura ni cierre
$cic = array_column($flows['registrar_ciclo']['fields'], null, 'key');
check('mantenimiento no está entre los ciclos',
in_array('mantenimiento', array_column($cic['ciclo']['options'], 'id'), true), false);
echo "\nLabores diarias (POST)\n";
$lab = $flows['registrar_labor'] ?? [];
$lf = array_column($lab['fields'] ?? [], null, 'key');
check('pide fecha, labor, lote, trabajadores y cantidad',
array_keys($lf), ['fecha', 'novedad_id', 'lote_id', 'empleados', 'cantidad']);
check('el catálogo lo filtra el ERP por fase',
$lf['novedad_id']['source_endpoint_key'] ?? null, 'novedades_labor_dn');
check('los trabajadores se eligen de a varios',
$lf['empleados']['type'] ?? null, 'multi_select');
check('y el vinculado se pre-llena',
$lf['empleados']['from_phone'] ?? null, 'tercero_id');
check('postea a labores_up', $lab['endpoint_key'] ?? null, 'labores_up');
foreach (['novedades_labor_dn', 'lotes_finca_dn', 'empleados_labor_dn', 'labores_up'] as $k) {
check("endpoint {$k} registrado", isset($eps[$k]), true);
}
echo "\nFase 3 — campos según el tipo\n";
$f3 = $flows['registrar_labor_validada'] ?? [];
$c3 = array_column($f3['fields'] ?? [], null, 'key');
check('ofrece los tres tipos',
array_column($c3['tipo']['options'], 'id'), ['cosecha', 'polinizacion', 'fertilizacion']);
check('el producto sale de la fertilización autorizada del lote',
$c3['producto_id']['source_endpoint_key'] ?? null, 'productos_fertilizacion_dn');
/** Espejo de capShouldSkip() */
$saltear = function (array $campo, array $col): bool {
$si = $campo['skip_if'] ?? null;
if (!$si) return false;
$v = (string)($col[$si['field']] ?? '');
if (isset($si['not_in'])) return !in_array($v, array_map('strval', $si['not_in']), true);
if (isset($si['equals'])) return $v !== (string)$si['equals'];
return false;
};
$pedidos = function (array $campos, string $tipo) use ($saltear): array {
$col = ['tipo' => $tipo];
return array_values(array_filter(array_keys($campos),
fn($k) => !$saltear($campos[$k], $col)));
};
// Los tres formularios del pedido, sin escribir tres flujos
check('cosecha: sin producto ni pase',
$pedidos($c3, 'cosecha'), ['tipo', 'novedad_id', 'fecha', 'lote_id', 'empleados', 'cantidad']);
check('fertilización: con producto, sin pase',
$pedidos($c3, 'fertilizacion'), ['tipo', 'novedad_id', 'fecha', 'lote_id', 'producto_id', 'empleados', 'cantidad']);
check('polinización: con producto y pase',
$pedidos($c3, 'polinizacion'), ['tipo', 'novedad_id', 'fecha', 'lote_id', 'producto_id', 'empleados', 'cantidad', 'fase_polinizacion']);
// El producto se pregunta despues del lote: la dosis depende de los dos
$orden = array_keys($c3);
check('el lote se pregunta antes que el producto',
array_search('lote_id', $orden) < array_search('producto_id', $orden), true);
foreach (['novedades_labor3_dn', 'productos_fertilizacion_dn'] as $k) {
check("endpoint {$k} registrado", isset($eps[$k]), true);
}
echo "\nAlcance de fincas por número\n";
/**
* Espejo del endpoint fincas: acota al alcance del numero y antepone "Todas"
* solo a quien no tiene restriccion. Con dos de cinco asignadas, "todas" no
* significa nada claro, asi que no se ofrece.
*/
$catalogoFincas = function (array $activas, array $alcance): array {
if ($alcance) {
$activas = array_values(array_filter($activas, fn($f) => in_array($f['id'], $alcance, true)));
}
$lista = array_column($activas, 'id');
if (!$alcance) array_unshift($lista, '0');
return $lista;
};
$activas = [['id' => '4'], ['id' => '7'], ['id' => '9']];
check('sin restricción ve todas y el "Todas"', $catalogoFincas($activas, []), ['0', '4', '7', '9']);
check('con dos asignadas ve solo esas', $catalogoFincas($activas, ['4', '9']), ['4', '9']);
check('y sin el "Todas"', in_array('0', $catalogoFincas($activas, ['4', '9']), true), false);
// Una sola finca: un unico item, y handleDynamicList auto-selecciona en ese caso
check('con una sola no hay nada que preguntar', $catalogoFincas($activas, ['7']), ['7']);
check('el bot ya no filtra por su cuenta', isset($flows['ask_finca']['scope_from']), false);
echo "\nask_finca\n";
$af = $flows['ask_finca'];
check('no repregunta si ya hay finca', $af['skip_if_set'] ?? false, true);
check('reset_finca la vuelve a pedir', isset($flows['reset_finca']), true);
echo "\n";
if ($fallas) { echo "{$fallas} falla(s)\n"; exit(1); }
echo "OK — navegación correcta\n";
+245
View File
@@ -0,0 +1,245 @@
<?php
declare(strict_types=1);
/**
* Arnés para ejecutar NormalBot de verdad, sin MySQL ni WhatsApp:
*
* db() -> falso en PHP puro (sin drivers), con los endpoints
* del seed reescritos hacia el servidor de fixtures
* ConversationContext -> estado en memoria, un contexto por teléfono
* WhatsAppSender -> captura los envíos directos en ::$enviados
* AiBot -> respuestas guionadas en ::$respuestas
*
* El curl es real: pega contra fixtures_api.php servido con `php -S`.
*/
const FIXTURE_BASE = 'http://127.0.0.1:8973/fixtures_api.php';
// ── db(): falso en PHP puro ──────────────────────────────────────────────────
// Sin SQLite: los servidores no siempre traen pdo_sqlite. NormalBot solo hace
// dos consultas —el endpoint por clave y el perfil por número— así que un par
// de mapas en memoria alcanza y no dependemos de ningún driver.
class FakeStmt
{
private $row = false;
public function __construct(private string $sql) {}
public function execute(array $p = []): bool
{
if (str_contains($this->sql, 'company_endpoints')) {
$key = $p[1] ?? '';
$url = FakeDb::$endpoints[$key] ?? null;
$this->row = $url === null ? false
: ['url' => $url, 'method' => 'GET', 'params' => null];
} elseif (str_contains($this->sql, 'company_phones')) {
$this->row = FakeDb::$phones[$p[1] ?? ''] ?? false;
} else {
$this->row = false;
}
return true;
}
public function fetch() { return $this->row; }
public function fetchColumn() { return $this->row ? array_values($this->row)[0] : false; }
public function fetchAll(): array { return $this->row ? [$this->row] : []; }
}
class FakeDb
{
/** endpoint_key => url absoluta hacia el servidor de fixtures */
public static array $endpoints = [];
/** wa_number => fila de company_phones */
public static array $phones = [];
public function prepare(string $sql): FakeStmt { return new FakeStmt($sql); }
public function query(string $sql): FakeStmt { $s = new FakeStmt($sql); $s->execute(); return $s; }
public function exec(string $sql): int { return 0; }
}
function db(): FakeDb
{
static $db = null;
if ($db !== null) return $db;
$db = new FakeDb();
// Los endpoints salen del seed real: mismas claves y placeholders, otra base.
$seed = file_get_contents(dirname(__DIR__) . '/seed_palmas.php');
preg_match_all("/\['key' => '([^']+)',\s*'dir' => '([^']+)',\s*'url' => \\\$BASE \. '([^']*)'/", $seed, $m);
foreach ($m[1] as $i => $key) {
FakeDb::$endpoints[$key] = FIXTURE_BASE . $m[3][$i];
}
return $db;
}
/** Registra un número con perfil para los escenarios de trabajador/supervisor. */
function fakePhone(string $wa, ?int $tercero, int $esSupervisor): void
{
FakeDb::$phones[$wa] = [
'tercero_id' => $tercero,
'modulos_json' => null,
'es_supervisor' => $esSupervisor,
];
}
// ── ConversationContext en memoria ───────────────────────────────────────────
class ConversationContext
{
public static array $store = [];
private static function &ctx(int $id): array
{
if (!isset(self::$store[$id])) {
self::$store[$id] = ['current_node' => null, 'metadata' => []];
}
return self::$store[$id];
}
public static function getOrCreate(int $companyId, string $phone, string $botType = 'normal'): array
{
// Un id estable por teléfono para aislar escenarios
$id = crc32($companyId . '|' . $phone) % 100000;
$c = self::ctx($id);
return ['id' => $id, 'current_node' => $c['current_node'], 'metadata' => json_encode($c['metadata'])];
}
public static function updateNode(int $id, ?string $node): void
{
self::ctx($id)['current_node'] = $node;
}
public static function getMetadata(int $id): array
{
return self::ctx($id)['metadata'];
}
public static function updateMetadata(int $id, array $meta): void
{
self::ctx($id)['metadata'] = $meta;
}
public static function reset(int $id): void
{
self::$store[$id] = ['current_node' => null, 'metadata' => []];
}
}
// ── WhatsAppSender: captura ──────────────────────────────────────────────────
class WhatsAppSender
{
public static array $enviados = [];
public static function sendText(string $to, string $text, string $phoneNumberId): array
{
self::$enviados[] = ['to' => $to, 'text' => $text];
return ['success' => true];
}
public static function uploadMedia(string $file, string $mime, string $phoneNumberId): array
{
return ['success' => true, 'media_id' => 'test-media'];
}
public static function sendDocument(string $to, string $mediaId, string $phoneNumberId, string $caption, string $name): array
{
self::$enviados[] = ['to' => $to, 'document' => $name];
return ['success' => true];
}
}
// ── AiBot guionado ───────────────────────────────────────────────────────────
class AiBot
{
/** Cola de respuestas para routeOrChat; vacía = chat vacío (cae al fallback). */
public static array $respuestas = [];
public static array $modulosRecibidos = [];
public static function routeOrChat(array $company, array $context, string $input, array $modulosPermitidos = []): array
{
self::$modulosRecibidos = $modulosPermitidos;
return array_shift(self::$respuestas) ?? ['action' => 'chat', 'text' => ''];
}
}
require_once dirname(__DIR__, 2) . '/services/NormalBot.php';
// ── Utilidades del test ──────────────────────────────────────────────────────
function empresa(): array
{
static $config = null;
if ($config === null) {
$seed = file_get_contents(dirname(__DIR__) . '/seed_palmas.php');
$open = strpos($seed, '[', strpos($seed, '$configJson = ['));
$d = 0; $end = $open;
for ($i = $open, $n = strlen($seed); $i < $n; $i++) {
if ($seed[$i] === '[') $d++;
elseif ($seed[$i] === ']') { $d--; if (!$d) { $end = $i; break; } }
}
$config = eval('return ' . substr($seed, $open, $end - $open + 1) . ';');
// Sin saludo diario: mete un envío extra en cada primer mensaje
$config['welcome']['enabled'] = false;
}
return [
'id' => 1,
'name' => 'test',
'display_name' => 'Empresa Test',
'api_key' => 'k',
'config_json' => json_encode($config),
'_permission_type' => 3,
];
}
function contexto(string $phone): array
{
return ['from' => $phone, 'name' => 'Tester', 'phone_number_id' => 'pn', 'permission_type' => 3];
}
/** Texto plano de la respuesta del bot, venga como texto o interactivo. */
function textoDe(?array $resp): string
{
if (!$resp) return '';
$p = json_decode($resp['payload'] ?? '{}', true);
if (isset($p['text'])) return $p['text'];
$i = $p['interactive'] ?? [];
return ($i['header']['text'] ?? '') . ' ' . ($i['body']['text'] ?? '');
}
/** Filas/botones de una respuesta interactiva, como id => title. */
function filasDe(?array $resp): array
{
$p = json_decode($resp['payload'] ?? '{}', true);
$i = $p['interactive'] ?? [];
$out = [];
foreach ($i['action']['sections'] ?? [] as $sec) {
foreach ($sec['rows'] ?? [] as $r) $out[$r['id']] = $r['title'];
}
foreach ($i['action']['buttons'] ?? [] as $b) {
$out[$b['reply']['id']] = $b['reply']['title'];
}
return $out;
}
/** Las capturas del servidor de fixtures desde la última llamada. */
function capturas(): array
{
$log = getenv('CAPTURE_LOG');
if (!is_file($log)) return [];
$out = [];
foreach (file($log, FILE_IGNORE_NEW_LINES) as $l) $out[] = json_decode($l, true);
file_put_contents($log, '');
return $out;
}
$GLOBALS['fallas'] = 0;
function check(string $nombre, $obtenido, $esperado = true): void
{
$ok = $obtenido === $esperado;
if (!$ok) $GLOBALS['fallas']++;
printf("%s %s\n", $ok ? ' ok ' : ' FALLA', $nombre);
if (!$ok) {
echo " esperaba: " . json_encode($esperado, JSON_UNESCAPED_UNICODE) . "\n";
echo " obtuvo: " . json_encode($obtenido, JSON_UNESCAPED_UNICODE) . "\n";
}
}
+115
View File
@@ -0,0 +1,115 @@
<?php
/**
* ERP falso para los tests unitarios. Se sirve con `php -S` y responde los
* catálogos con datos fijos; los POST se capturan en un log JSONL para que el
* test verifique el payload exacto que armó el bot.
*/
$peticion = $_GET['peticion'] ?? '';
$capture = getenv('CAPTURE_LOG') ?: sys_get_temp_dir() . '/bot_test_capture.jsonl';
header('Content-Type: application/json; charset=utf-8');
// ── POST: capturar y responder como el ERP real ──────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$body = json_decode(file_get_contents('php://input'), true);
file_put_contents($capture, json_encode([
'peticion' => $peticion,
'query' => $_GET,
'body' => $body,
], JSON_UNESCAPED_UNICODE) . "\n", FILE_APPEND);
// Mismo shape que BotEntradaProcesador para que el bot repita el detalle
echo json_encode([
'status' => 'ok',
'mensaje' => 'capturado:' . $peticion,
], JSON_UNESCAPED_UNICODE);
exit;
}
// ── GET: catálogos fijos ──────────────────────────────────────────────────────
$fincas = [
['id' => '0', 'label' => '🌐 Todas las fincas'],
['id' => '4', 'label' => 'REPOSO'],
['id' => '7', 'label' => 'ROSA BLANCA'],
];
// El ERP acota por número: este wa solo tiene una finca asignada
if (($_GET['wa'] ?? '') === '57300RESTRINGIDO') {
$fincas = [['id' => '4', 'label' => 'REPOSO']];
}
$novedades17 = [];
foreach (range(1, 17) as $i) $novedades17[] = ['id' => (string)(100 + $i), 'nombre' => "NOVEDAD {$i}"];
$lotes12 = [];
foreach (range(1, 12) as $i) $lotes12[] = ['id' => (string)$i, 'label' => "REPOSO {$i}A (" . (50 - $i) . "d)"];
// Como el ERP real: sin finca_id valida, el catalogo mezcla fincas
if (intval($_GET['finca_id'] ?? 0) !== 4) {
array_unshift($lotes12, ['id' => '99', 'label' => 'ROSA BLANCA 9Z (60d)']);
}
$fixtures = [
'fincas' => $fincas,
'empleados' => [
['id' => '412', 'nombre' => 'JULIO PEREZ GOMEZ'],
['id' => '415', 'nombre' => 'JULIO CESAR RAMIREZ'],
['id' => '418', 'nombre' => 'MARIA GOMEZ RUIZ'],
],
'novedades_ausentismo_dn' => (function () {
// Como el ERP: ?grupos=1 devuelve los grupos, ?grupo_novedad=X los suyos
$porGrupo = [
'Incapacidad' => [['id'=>'46','nombre'=>'ENFERMEDAD < 3 DIAS','descripcion'=>'INCAPACIDAD ENFERMEDAD < 3 DIAS'],
['id'=>'47','nombre'=>'ACCIDENTE DE TRANSITO','descripcion'=>'INCAPACIDAD ACCIDENTE DE TRANSITO']],
'Permiso' => [['id'=>'50','nombre'=>'CITA MEDICA','descripcion'=>'PERMISO CITA MEDICA']],
'Vacaciones' => [['id'=>'57','nombre'=>'DISFRUTADAS','descripcion'=>'VACACIONES DISFRUTADAS']],
];
if (!empty($_GET['grupos'])) {
$out = [];
foreach ($porGrupo as $g => $i) $out[] = ['id'=>$g,'nombre'=>$g,'descripcion'=>count($i).' opcion(es)'];
return $out;
}
$g = trim((string)($_GET['grupo_novedad'] ?? ''));
return $g !== '' ? ($porGrupo[$g] ?? []) : array_merge(...array_values($porGrupo));
})(),
'novedades_labor_dn' => !empty($_GET['grupo'])
? [['id' => '77', 'nombre' => 'PLATEO MANUAL']]
: [
['id' => '42', 'nombre' => 'TRACTORISTA'],
['id' => '43', 'nombre' => 'HORAS EXTRA'],
],
'lotes_apertura_dn' => $lotes12,
'lotes_abiertos_dn' => array_slice($lotes12, 0, 3),
'lotes_x_finca' => intval($_GET['finca_id'] ?? 0) === 4
? [['id' => '4', 'label' => 'REPOSO 1A'], ['id' => '9', 'label' => 'REPOSO 2A']]
: [['id' => '99', 'label' => 'ROSA BLANCA 9Z']],
'grupos_mantenimiento' => [
['id' => '5', 'label' => 'PLATEO'],
['id' => '6', 'label' => 'CORONA'],
],
'lotes_mantenimiento_dn' => [
['id' => '4', 'label' => 'REPOSO 1A (falta 12)', 'faltante' => 12],
],
'productos_fertilizacion_dn' => [
['id' => '30', 'nombre' => 'UREA (falta 120)', 'dosis' => 120],
],
];
// Los informes de texto responden {status, message}
if (str_contains($peticion, '_texto_bot')) {
file_put_contents($capture, json_encode(['peticion' => $peticion, 'query' => $_GET]) . "\n", FILE_APPEND);
echo json_encode(['status' => '1', 'message' => 'informe-de-prueba']);
exit;
}
if (isset($fixtures[$peticion])) {
// La paginación del ERP: los catálogos cortos devuelven 10 salvo &todos=1
$datos = $fixtures[$peticion];
if (in_array($peticion, ['lotes_apertura_dn', 'lotes_abiertos_dn'], true) && empty($_GET['todos'])) {
$datos = array_slice($datos, 0, 10);
}
echo json_encode(['status' => '1', 'datos' => $datos], JSON_UNESCAPED_UNICODE);
exit;
}
http_response_code(404);
echo json_encode(['status' => 'error', 'mensaje' => "sin fixture: {$peticion}"]);
+10
View File
@@ -0,0 +1,10 @@
#!/bin/bash
# Levanta el ERP falso, corre los tests de flujo y apaga el servidor.
cd "$(dirname "$0")/../.."
export CAPTURE_LOG="${TMPDIR:-/tmp}/bot_test_capture.jsonl"
: > "$CAPTURE_LOG"
CAPTURE_LOG="$CAPTURE_LOG" php -S 127.0.0.1:8973 setup/tests/fixtures_api.php >/dev/null 2>&1 &
SERVER=$!
trap "kill $SERVER 2>/dev/null" EXIT
sleep 0.4
php setup/tests/test_flujos.php
+342
View File
@@ -0,0 +1,342 @@
<?php
declare(strict_types=1);
/**
* Tests unitarios de los flujos de carga, ejecutando NormalBot de verdad.
*
* A diferencia de test_navegacion.php (que espeja la lógica), acá corre
* process()/processInteractive() reales: el curl pega contra fixtures_api.php
* y los POST capturados se comparan contra el contrato del ERP.
*
* Uso: php setup/tests/run.sh (levanta el servidor de fixtures y corre esto)
*/
require __DIR__ . '/bootstrap.php';
$co = empresa();
// ════ 1. Finca restringida: una sola → entra directo ═════════════════════════
echo "\nFinca — alcance por número\n";
$ctx = contexto('57300RESTRINGIDO');
$r = NormalBot::process($co, $ctx, 'hola');
check('con una sola finca no pregunta: cae al menú',
str_contains(textoDe($r), '¿Qué deseas hacer?'));
$meta = ConversationContext::getMetadata(ConversationContext::getOrCreate(1, '57300RESTRINGIDO')['id']);
check('la finca quedó elegida sola', $meta['finca']['finca_id'] ?? null, '4');
check('con su nombre para el footer', $meta['finca']['finca_label'] ?? null, 'REPOSO');
$p = json_decode($r['payload'], true);
check('y el footer la muestra', $p['interactive']['footer']['text'] ?? '', '📍 REPOSO');
$ctx = contexto('57300LIBRE');
$r = NormalBot::process($co, $ctx, 'hola');
check('sin restricción sí pregunta, con "Todas" primero',
(string)array_key_first(filasDe($r)), '0');
capturas();
// ════ 2. Ausentismo completo ═════════════════════════════════════════════════
echo "\nAusentismo — de la primera pregunta al POST\n";
$ctx = contexto('57300AUSEN');
$r = NormalBot::processInteractive($co, $ctx, 'registrar_ausentismo');
check('arranca pidiendo el trabajador', str_contains(textoDe($r), 'nombre o documento'));
$r = NormalBot::process($co, $ctx, 'julio');
check('varios homónimos: ofrece elegir', str_contains(textoDe($r), 'coincidencias'));
check('con los tres del catálogo', count(filasDe($r)), 3);
$r = NormalBot::processInteractive($co, $ctx, '412');
$filas = filasDe($r);
check('sigue el tipo, no los 17 motivos de una',
array_keys($filas), ['Incapacidad', 'Permiso', 'Vacaciones']);
$r = NormalBot::processInteractive($co, $ctx, 'Incapacidad');
$filas = filasDe($r);
check('el motivo se acota al tipo elegido', array_map('strval', array_keys($filas)), ['46', '47']);
check('sin el prefijo del grupo, entra en los 24 de WhatsApp',
max(array_map('mb_strlen', $filas)) <= 24);
$p = json_decode($r['payload'], true);
$fila0 = $p['interactive']['action']['sections'][0]['rows'][0];
check('y el nombre completo va en la descripción',
$fila0['description'] ?? null, 'INCAPACIDAD ENFERMEDAD < 3 DIAS');
$r = NormalBot::processInteractive($co, $ctx, '46');
check('sigue la fecha inicial', str_contains(textoDe($r), 'Desde qué fecha'));
check('la fecha inicial sí mira hacia atrás',
isset(filasDe($r)[date('Y-m-d', strtotime('-1 day'))]));
$hoy = date('Y-m-d'); $ayer = date('Y-m-d', strtotime('-1 day'));
$manana = date('Y-m-d', strtotime('+1 day'));
$r = NormalBot::processInteractive($co, $ctx, $hoy);
check('sigue la fecha final', str_contains(textoDe($r), 'Hasta qué fecha'));
// Una novedad termina hoy o despues: los atajos van hacia adelante
$atajos = filasDe($r);
check('ofrece hoy, mañana y pasado — no ayer',
array_map('strval', array_keys($atajos)),
[$hoy, $manana, date('Y-m-d', strtotime('+2 days')), '__cap_other']);
check('sin atajos hacia atrás', !isset($atajos[$ayer]));
WhatsAppSender::$enviados = [];
$r = NormalBot::processInteractive($co, $ctx, $ayer);
check('rechaza el rango invertido',
str_contains(WhatsAppSender::$enviados[0]['text'] ?? '', 'no puede ser anterior'));
check('y vuelve a mostrar el selector de fecha', str_contains(textoDe($r), 'Hasta qué fecha'));
$r = NormalBot::processInteractive($co, $ctx, $hoy);
check('resumen con confirmación', isset(filasDe($r)['__cap_confirm']));
capturas(); // limpia GETs de catálogos
NormalBot::processInteractive($co, $ctx, '__cap_confirm');
$post = array_values(array_filter(capturas(), fn($c) => ($c['peticion'] ?? '') === 'ausentismos_up'))[0] ?? null;
check('el POST llegó al ERP', $post !== null);
check('con el contrato exacto del procesador',
[$post['body']['empleado_id'] ?? null, $post['body']['novedad_id'] ?? null,
$post['body']['fecha_inicial'] ?? null, $post['body']['fecha_final'] ?? null],
['412', '46', $hoy, $hoy]);
check('y la trazabilidad', $post['body']['telefono'] ?? null, '57300AUSEN');
// ════ 3. Ausentismo según el perfil del número ═══════════════════════════════
echo "\nPerfil — trabajador vinculado y supervisor\n";
fakePhone('57300TRABAJADOR', 412, 0);
fakePhone('57300JEFE', 412, 1);
$ctx = contexto('57300TRABAJADOR');
$r = NormalBot::processInteractive($co, $ctx, 'registrar_ausentismo');
check('el trabajador vinculado no se pregunta: arranca por el tipo',
str_contains(textoDe($r), 'tipo de ausentismo'));
$ctx = contexto('57300JEFE');
$r = NormalBot::processInteractive($co, $ctx, 'registrar_ausentismo');
check('el supervisor elige aunque tenga tercero',
str_contains(textoDe($r), 'nombre o documento'));
capturas();
// ════ 4. Ciclos: apertura con selección múltiple ═════════════════════════════
echo "\nCiclos — apertura de varios lotes\n";
$ctx = contexto('57300CICLOS');
$id = ConversationContext::getOrCreate(1, '57300CICLOS')['id'];
ConversationContext::updateMetadata($id, ['finca' => ['finca_id' => '4', 'finca_label' => 'REPOSO']]);
$r = NormalBot::processInteractive($co, $ctx, 'registrar_ciclo');
check('pregunta el tipo de ciclo', str_contains(textoDe($r), 'Qué ciclo'));
$r = NormalBot::processInteractive($co, $ctx, 'cosecha');
check('después la acción', isset(filasDe($r)['apertura']));
$r = NormalBot::processInteractive($co, $ctx, 'apertura');
$r = NormalBot::processInteractive($co, $ctx, $hoy);
$filas = filasDe($r);
check('lista de lotes con "Otro lote" y "Listo"',
isset($filas['__todos']) && isset($filas['__listo']));
check('solo lotes de la finca elegida, ninguno ajeno',
!isset($filas['99']) && !str_contains(implode(' ', $filas), 'ROSA BLANCA'));
$r = NormalBot::processInteractive($co, $ctx, '1');
check('marcar redibuja con el check', str_contains(filasDe($r)['1'] ?? '', '✅'));
$r = NormalBot::processInteractive($co, $ctx, '2');
check('el contador acumula', str_contains(filasDe($r)['__listo'] ?? '', '(2)'));
$r = NormalBot::processInteractive($co, $ctx, '__todos');
check('"Otro lote" pasa al catálogo completo, paginado', isset(filasDe($r)['__mas']));
check('sin perder lo marcado', str_contains(filasDe($r)['1'] ?? '', '✅'));
$r = NormalBot::processInteractive($co, $ctx, '__mas');
check('y la página 2 alcanza el lote 12', isset(filasDe($r)['12']));
$r = NormalBot::processInteractive($co, $ctx, '__mas'); // vuelve: no hay página 3
$r = NormalBot::processInteractive($co, $ctx, '__mas');
// Escribir varios de una: seis toques eran seis mensajes
$r = NormalBot::process($co, $ctx, '3a, 4a 5a');
$filas = filasDe($r);
check('escribir marca varios en un solo mensaje',
str_contains($filas['__listo'] ?? '', '(5)'));
// El contador no depende de la pagina que se este viendo
check('suma a lo tocado en vez de reemplazarlo: 2 + 3 = 5',
str_contains($filas['__listo'] ?? '', '(5)'));
WhatsAppSender::$enviados = [];
$r = NormalBot::process($co, $ctx, '6a, 9z9z');
check('lo que no reconoce lo avisa, no lo ignora',
str_contains(WhatsAppSender::$enviados[0]['text'] ?? '', '"9z9z"'));
check('y marca igual lo que sí entendió',
str_contains(filasDe($r)['__listo'] ?? '', '(6)'));
$r = NormalBot::processInteractive($co, $ctx, '__listo');
check('resumen final', isset(filasDe($r)['__cap_confirm']));
capturas();
NormalBot::processInteractive($co, $ctx, '__cap_confirm');
$post = array_values(array_filter(capturas(), fn($c) => str_contains($c['peticion'] ?? '', 'ciclos_')))[0] ?? null;
check('la URL resolvió el tipo elegido', $post['peticion'] ?? null, 'ciclos_cosecha_up');
check('lote_ids lleva los tocados y los escritos',
$post['body']['lote_ids'] ?? null, ['1', '2', '3', '4', '5', '6']);
check('con la acción y la fecha',
[$post['body']['accion'] ?? null, $post['body']['fecha'] ?? null], ['apertura', $hoy]);
// ════ 5. Mantenimiento: requires/resolver + payload completo ═════════════════
echo "\nMantenimiento — el grupo se pide primero\n";
$ctx = contexto('57300MANT');
$r = NormalBot::processInteractive($co, $ctx, 'registrar_mantenimiento');
check('sin grupo elegido desvía al selector', isset(filasDe($r)['5']));
$r = NormalBot::processInteractive($co, $ctx, '5');
check('y vuelve al flujo: pide la fecha', str_contains(textoDe($r), 'De qué fecha'));
$r = NormalBot::processInteractive($co, $ctx, $hoy);
check('el lote muestra la cantidad faltante', str_contains(filasDe($r)['4'] ?? '', 'falta 12'));
$r = NormalBot::processInteractive($co, $ctx, '4');
check('la labor sale del grupo elegido', filasDe($r), ['77' => 'PLATEO MANUAL']);
$r = NormalBot::processInteractive($co, $ctx, '77');
$r = NormalBot::processInteractive($co, $ctx, '412');
$r = NormalBot::processInteractive($co, $ctx, '__listo');
$r = NormalBot::process($co, $ctx, '12');
check('resumen', isset(filasDe($r)['__cap_confirm']));
capturas();
NormalBot::processInteractive($co, $ctx, '__cap_confirm');
$post = array_values(array_filter(capturas(), fn($c) => ($c['peticion'] ?? '') === 'labores_up'))[0] ?? null;
check('postea todo lo que labores_up exige',
[$post['body']['novedad_id'] ?? null, $post['body']['lote_id'] ?? null,
$post['body']['empleados'] ?? null, $post['body']['cantidad'] ?? null],
['77', '4', ['412'], '12']);
// ════ 6. Labores fase 2 ═══════════════════════════════════════════════════════
echo "\nLabores diarias — cuadrilla completa\n";
$ctx = contexto('57300LABOR');
$id = ConversationContext::getOrCreate(1, '57300LABOR')['id'];
ConversationContext::updateMetadata($id, ['finca' => ['finca_id' => '4', 'finca_label' => 'REPOSO']]);
$r = NormalBot::processInteractive($co, $ctx, 'registrar_labor');
$r = NormalBot::processInteractive($co, $ctx, $hoy);
check('el catálogo viene filtrado por fase', filasDe($r), ['42' => 'TRACTORISTA', '43' => 'HORAS EXTRA']);
$r = NormalBot::processInteractive($co, $ctx, '42');
check('los lotes son los de su finca', isset(filasDe($r)['4']) && isset(filasDe($r)['9']));
check('sin lotes de otras fincas', !isset(filasDe($r)['99']));
$r = NormalBot::processInteractive($co, $ctx, '4');
$r = NormalBot::processInteractive($co, $ctx, '412');
$r = NormalBot::processInteractive($co, $ctx, '415');
$r = NormalBot::processInteractive($co, $ctx, '__listo');
$r = NormalBot::process($co, $ctx, '1');
capturas();
NormalBot::processInteractive($co, $ctx, '__cap_confirm');
$post = array_values(array_filter(capturas(), fn($c) => ($c['peticion'] ?? '') === 'labores_up'))[0] ?? null;
check('la cuadrilla viaja como arreglo', $post['body']['empleados'] ?? null, ['412', '415']);
// ════ 7. NLU: entity resuelve el grupo y ejecuta el informe ═══════════════════
echo "\nNLU — \"informe de mantenimiento de plateo\" en un mensaje\n";
$ctx = contexto('57300NLU');
$id = ConversationContext::getOrCreate(1, '57300NLU')['id'];
ConversationContext::updateMetadata($id, ['finca' => ['finca_id' => '4', 'finca_label' => 'REPOSO']]);
ConversationContext::updateNode($id, '__greeted');
AiBot::$respuestas = [[
'action' => 'route', 'key' => 'ciclo_mantenimiento_top',
'entities' => ['grupo' => 'plateo'],
]];
capturas();
$r = NormalBot::process($co, $ctx, 'quiero informe de mantenimiento de plateo');
check('el informe llegó sin menús intermedios', str_contains(textoDe($r), 'informe-de-prueba'));
$get = array_values(array_filter(capturas(), fn($c) => str_contains($c['peticion'] ?? '', 'mantenimiento_top')))[0] ?? null;
check('la entity resolvió el grupo en la URL', $get['query']['grupo'] ?? null, '5');
check('y la consulta dice quién pregunta', $get['query']['wa'] ?? null, '57300NLU');
// ════ 8. NLU coherente con los módulos del perfil ═════════════════════════════
echo "\nNLU — respeta los módulos habilitados\n";
// Este número solo puede cargar pluviometría
fakePhone('57300SOLOPLUVIO', null, 0);
FakeDb::$phones['57300SOLOPLUVIO']['modulos_json'] = json_encode(['carga' => ['pluviometria']]);
$ctx = contexto('57300SOLOPLUVIO');
$id = ConversationContext::getOrCreate(1, '57300SOLOPLUVIO')['id'];
ConversationContext::updateMetadata($id, ['finca' => ['finca_id' => '4', 'finca_label' => 'REPOSO']]);
ConversationContext::updateNode($id, '__greeted');
// "subir pluviometría" → directo a la fecha, un solo mensaje
AiBot::$respuestas = [['action' => 'route', 'key' => 'registrar_pluviometria']];
$r = NormalBot::process($co, $ctx, 'subir pluviometría');
check('"subir pluviometría" va de una a la fecha', str_contains(textoDe($r), 'De qué fecha'));
check('el NLU recibió su alcance de módulos', AiBot::$modulosRecibidos, ['pluviometria']);
// El modelo devuelve una clave fuera del alcance: el guard la corta
ConversationContext::updateNode($id, '__greeted');
ConversationContext::updateMetadata($id, ['finca' => ['finca_id' => '4', 'finca_label' => 'REPOSO']]);
WhatsAppSender::$enviados = [];
AiBot::$respuestas = [['action' => 'route', 'key' => 'registrar_ausentismo']];
$r = NormalBot::process($co, $ctx, 'registrar ausentismo');
check('un módulo vetado no ejecuta el flujo', !str_contains(textoDe($r), 'nombre o documento'));
check('avisa que no entendió',
str_contains(WhatsAppSender::$enviados[0]['text'] ?? '', 'No estoy seguro'));
// Sin restricción, "subir labores diarias" también entra directo
$ctx = contexto('57300LABORNLU');
$id = ConversationContext::getOrCreate(1, '57300LABORNLU')['id'];
ConversationContext::updateMetadata($id, ['finca' => ['finca_id' => '4', 'finca_label' => 'REPOSO']]);
ConversationContext::updateNode($id, '__greeted');
AiBot::$respuestas = [['action' => 'route', 'key' => 'registrar_labor']];
$r = NormalBot::process($co, $ctx, 'subir labores diarias');
check('"subir labores diarias" arranca el flujo directo', str_contains(textoDe($r), 'De qué fecha es la labor'));
capturas();
// ════ 9. Editar desde el resumen ══════════════════════════════════════════════
echo "\nEditar — corregir sin rehacer el formulario\n";
$ctx = contexto('57300EDITAR');
NormalBot::processInteractive($co, $ctx, 'registrar_ausentismo');
NormalBot::process($co, $ctx, 'julio');
NormalBot::processInteractive($co, $ctx, '412');
NormalBot::processInteractive($co, $ctx, 'Incapacidad');
NormalBot::processInteractive($co, $ctx, '46');
NormalBot::processInteractive($co, $ctx, $hoy);
$r = NormalBot::processInteractive($co, $ctx, $manana);
check('el resumen ofrece editar', isset(filasDe($r)['__cap_edit']));
$r = NormalBot::processInteractive($co, $ctx, '__cap_edit');
$campos = filasDe($r);
check('lista los campos ya cargados',
array_keys($campos),
['__cap_ed:empleado_id', '__cap_ed:grupo_novedad', '__cap_ed:novedad_id',
'__cap_ed:fecha_inicial', '__cap_ed:fecha_final', '__cap_ed_volver']);
// Un campo hoja: se corrige y vuelve derecho al resumen
$r = NormalBot::processInteractive($co, $ctx, '__cap_ed:fecha_final');
check('pregunta solo ese campo', str_contains(textoDe($r), 'Hasta qué fecha'));
$pasado = date('Y-m-d', strtotime('+2 days'));
$r = NormalBot::processInteractive($co, $ctx, $pasado);
check('y vuelve al resumen, no al siguiente campo', isset(filasDe($r)['__cap_confirm']));
check('con el valor corregido', str_contains(textoDe($r), $pasado));
// Un campo del que otro depende: se re-pregunta en cadena
$r = NormalBot::processInteractive($co, $ctx, '__cap_edit');
$r = NormalBot::processInteractive($co, $ctx, '__cap_ed:grupo_novedad');
check('editar el tipo vuelve a preguntar el tipo', str_contains(textoDe($r), 'tipo de ausentismo'));
$r = NormalBot::processInteractive($co, $ctx, 'Permiso');
check('y encadena el motivo, que quedó inválido', str_contains(textoDe($r), 'Cuál es el motivo'));
check('acotado al tipo nuevo', array_map('strval', array_keys(filasDe($r))), ['50']);
$r = NormalBot::processInteractive($co, $ctx, '50');
check('recién ahí vuelve al resumen', isset(filasDe($r)['__cap_confirm']));
// Salir sin tocar nada
$r = NormalBot::processInteractive($co, $ctx, '__cap_edit');
$r = NormalBot::processInteractive($co, $ctx, '__cap_ed_volver');
check('"Volver" deja todo como estaba', isset(filasDe($r)['__cap_confirm']));
capturas();
NormalBot::processInteractive($co, $ctx, '__cap_confirm');
$post = array_values(array_filter(capturas(), fn($c) => ($c['peticion'] ?? '') === 'ausentismos_up'))[0] ?? null;
check('el POST lleva lo editado, sin restos del valor viejo',
[$post['body']['novedad_id'] ?? null, $post['body']['fecha_final'] ?? null],
['50', $pasado]);
// ════ Resultado ═══════════════════════════════════════════════════════════════
echo "\n" . ($GLOBALS['fallas'] ? "{$GLOBALS['fallas']} falla(s)\n" : "Todos los flujos ejecutan de punta a punta\n");
exit($GLOBALS['fallas'] ? 1 : 0);
+91
View File
@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
/**
* Valida el grafo de navegación de seed_palmas.php sin tocar la base de datos.
* Un 'back', 'resolver' o id de botón que apunte a un flow inexistente deja al
* usuario en un callejón sin salida, y eso no se nota hasta que alguien lo pisa.
*
* Uso: php setup/validate_config.php
*/
$seed = file_get_contents(__DIR__ . '/seed_palmas.php');
$ini = strpos($seed, '$configJson = [');
if ($ini === false) { fwrite(STDERR, "No se encontró \$configJson\n"); exit(1); }
// Recorte por balance de corchetes desde la apertura del array
$open = strpos($seed, '[', $ini);
$depth = 0; $end = $open;
for ($i = $open, $n = strlen($seed); $i < $n; $i++) {
if ($seed[$i] === '[') $depth++;
elseif ($seed[$i] === ']') { $depth--; if ($depth === 0) { $end = $i; break; } }
}
$config = eval('return ' . substr($seed, $open, $end - $open + 1) . ';');
$errores = [];
$avisos = [];
// Menús raíz: no suben a ningún lado, es correcto que no tengan 'back'
$esRaiz = fn(string $k): bool => $k === 'show_main_menu' || str_starts_with($k, 'show_menu_');
$categorias = ['global' => []] + ($config['per_type'] ?? []);
foreach ($categorias as $cat => $pt) {
$flows = array_merge($config['flows'] ?? [], $pt['flows'] ?? []);
$menus = array_merge($config['menus'] ?? [], $pt['menus'] ?? []);
$cmds = array_merge($config['commands'] ?? [], $pt['commands'] ?? []);
$donde = "cat {$cat}";
$existe = fn(string $k): bool => isset($flows[$k]) || isset($menus[$k]);
foreach ($cmds as $palabra => $destino) {
if ($destino === '__back') continue; // se resuelve en runtime
if (!$existe($destino)) $errores[] = "{$donde}: comando '{$palabra}' → '{$destino}' no existe";
}
foreach ($flows as $key => $flow) {
foreach (['back', 'resolver', 'next_node'] as $campo) {
$destino = $flow[$campo] ?? null;
if ($destino !== null && !$existe($destino)) {
$errores[] = "{$donde}: flow '{$key}'.{$campo} → '{$destino}' no existe";
}
}
if (!empty($flow['requires']) && empty($flow['resolver'])) {
$errores[] = "{$donde}: flow '{$key}' declara requires sin resolver";
}
if (($flow['type'] ?? '') === 'menu' && !isset($menus[$flow['menu'] ?? ''])) {
$errores[] = "{$donde}: flow '{$key}' apunta al menú '{$flow['menu']}' que no existe";
}
if (($flow['type'] ?? '') === 'menu' && !isset($flow['back']) && !$esRaiz($key)) {
$avisos[] = "{$donde}: flow '{$key}' es menú y no define 'back'";
}
}
// Cada id de botón/fila debe resolver a un flow
foreach ($menus as $mKey => $menu) {
$ids = array_column($menu['buttons'] ?? [], 'id');
foreach ($menu['sections'] ?? [] as $sec) {
$ids = array_merge($ids, array_column($sec['rows'] ?? [], 'id'));
}
foreach ($ids as $id) {
if (!$existe($id)) $errores[] = "{$donde}: menú '{$mKey}' tiene opción '{$id}' sin flow";
}
}
}
// Endpoints referenciados por los flows
preg_match_all("/'key'\s*=>\s*'([^']+)'/", substr($seed, $end), $m);
$endpoints = array_flip($m[1]);
foreach ($config['flows'] ?? [] as $key => $flow) {
foreach (['source_endpoint_key', 'source_endpoint_key_all'] as $campo) {
$ep = $flow[$campo] ?? null;
if ($ep !== null && !isset($endpoints[$ep])) $errores[] = "flow '{$key}'.{$campo} → endpoint '{$ep}' no registrado";
}
$ep = $flow['params']['endpoint_key'] ?? null;
if ($ep !== null && !isset($endpoints[$ep])) $errores[] = "flow '{$key}' → endpoint '{$ep}' no registrado";
}
foreach (array_unique($avisos) as $a) echo "aviso: {$a}\n";
foreach (array_unique($errores) as $e) echo "ERROR: {$e}\n";
if ($errores) { echo "\n" . count(array_unique($errores)) . " error(es)\n"; exit(1); }
echo "\nOK — grafo de navegación consistente\n";