Compare commits

...
90 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
22 changed files with 4879 additions and 142 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}"
}
}
}
]
}
]
}
}
]
}
+116 -25
View File
@@ -3170,6 +3170,9 @@ HTML;
$aiModel35 = $aiModelVal === 'gpt-3.5-turbo'? ' selected' : '';
$geminiApiKeyVal = self::h($config['gemini_api_key'] ?? '');
$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' : '';
$geminiModelF15 = $geminiModelVal === 'gemini-1.5-flash' ? ' selected' : '';
@@ -3190,28 +3193,81 @@ HTML;
// Per-category config HTML
$perTypeConfig = $config['per_type'] ?? [];
$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 = '';
// 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) {
$catCfg = $perTypeConfig[(string)$cat] ?? [];
$selMenu = $catCfg['greeting_menu'] ?? '';
$opts = '<option value="">— Usa configuración global —</option>';
foreach ($menuKeysList as $mk) {
$s = $selMenu === $mk ? ' selected' : '';
$opts .= '<option value="' . self::h($mk) . '"' . $s . '>' . self::h($mk) . '</option>';
}
$label = $catLabels[(string)$cat];
$catCfg = $perTypeConfig[(string)$cat] ?? [];
$m = $catMeta[(string)$cat];
$greetFlow = self::h($catCfg['greeting_flow'] ?? '');
$fallback = $catCfg['fallback_flow'] ?? '';
$selMenu = $catCfg['greeting_menu'] ?? '';
// greeting_menu selector (insert selected attr)
$menuOpts = str_replace(
'value="' . self::h($selMenu) . '"',
'value="' . self::h($selMenu) . '" selected',
$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
<div style="background:#f8f9fd;border:1px solid #eef1f5;border-radius:10px;padding:18px;margin-bottom:16px">
<div style="font-weight:700;font-size:13px;color:#0b3d91;margin-bottom:12px">{$label}</div>
<div class="form-group" style="margin-bottom:0">
<label>Menú de bienvenida (greeting menu)</label>
<select name="per_type_menu[{$cat}]" style="width:100%;padding:9px 12px;border:1px solid #e2e5ea;border-radius:8px;font-size:13px">
{$opts}
</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>
</div>
<div style="background:{$bg};border:1px solid {$bd};border-radius:10px;padding:18px;margin-bottom:16px">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:14px">
<span style="font-size:20px">{$icon}</span>
<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">
{$menuOpts}
</select>
<div style="font-size:11px;color:#7a8291;margin-top:3px">Menú que se muestra cuando no hay flujo de bienvenida configurado</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>
CATHTML;
}
$aiModelMini = $aiModelVal === 'gpt-4o-mini' ? ' selected' : '';
@@ -3640,7 +3696,13 @@ HTML;
<!-- ─── POR CATEGORÍA ────────────────────────────────────────────────── -->
<div class="tab-content" id="tab-categories">
<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}
<button type="button" id="savePerTypeBtn" onclick="savePerType()" class="btn-primary" style="margin-top:8px">💾 Guardar categorías</button>
</div>
@@ -3717,6 +3779,26 @@ HTML;
</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-group">
<label>Temperatura ({$aiTempVal})</label>
@@ -3828,8 +3910,12 @@ function savePerType() {
const fd = new FormData();
fd.set('company_id', cid);
[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 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 (fb) fd.append('per_type_fallback[' + cat + ']', fb.value);
});
var btn = document.getElementById('savePerTypeBtn');
if (btn) { btn.textContent = 'Guardando...'; btn.disabled = true; }
@@ -3862,6 +3948,10 @@ function saveAiConfig() {
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');
@@ -5729,7 +5819,7 @@ HTML;
}
$providerOptions = '';
foreach (['', 'openai', 'gemini', 'claude'] as $p) {
foreach (['', 'openai', 'gemini', 'claude', 'whisper-own'] as $p) {
$label = $p === '' ? 'Todos' : ucfirst($p);
$sel = $provider === $p ? ' selected' : '';
$providerOptions .= "<option value=\"{$p}\"{$sel}>{$label}</option>";
@@ -5740,10 +5830,11 @@ HTML;
$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',
default => 'background:#6b7280;color:#fff',
'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'])
+11 -4
View File
@@ -187,7 +187,7 @@ class WpWebhook
}
// Pre-menú multi-empresa: retorna true si la empresa quedó resuelta, false si hay que esperar al usuario
private static function handleMultiCompany(string $from, string $name, array $companies, string $rawText, string $phoneNumberId): bool
private static function handleMultiCompany(string $from, string $name, array $companies, string $rawText, string $phoneNumberId, string $msgType = 'text'): bool
{
// 1. El usuario tocó un botón de selección de empresa
if (str_starts_with($rawText, '__co_')) {
@@ -233,7 +233,8 @@ class WpWebhook
}
// 2. Keyword de reinicio → borrar sesión y mostrar pre-menú
if (self::isResetKeyword($rawText)) {
// 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;
@@ -307,11 +308,17 @@ class WpWebhook
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, ['salir','inicio','menu','reiniciar','volver','reset','0','atras','regresar'], true);
return in_array($n, ['cambiar empresa', 'cambiar de empresa', 'empresas', 'reiniciar', 'reset'], true);
}
private static function getPermissionType(array $companies, int $companyId): int
@@ -418,7 +425,7 @@ class WpWebhook
'button' => $msg['button']['payload'] ?? $msg['button']['text'] ?? '',
default => '',
};
if (!self::handleMultiCompany($from, $name, $matchedCompanies, $rawText, $phoneNumberId)) {
if (!self::handleMultiCompany($from, $name, $matchedCompanies, $rawText, $phoneNumberId, $type)) {
continue;
}
self::log('INFO', "Número {$from} → empresa (multi-sel): " . (self::$currentCompany['name'] ?? '?'));
+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";
+43 -9
View File
@@ -1,6 +1,8 @@
<?php
declare(strict_types=1);
date_default_timezone_set('America/Bogota');
require_once __DIR__ . '/../config/env.php';
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../services/Settings.php';
@@ -634,6 +636,13 @@ $routes = [
$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;
@@ -651,22 +660,47 @@ $routes = [
$config = json_decode($company['config_json'] ?? '{}', true) ?: [];
$rawPt = $config['per_type'] ?? [];
$rawPt = $config['per_type'] ?? [];
$perType = (is_array($rawPt) && count(array_filter(array_keys($rawPt), 'is_string')) > 0)
? $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) {
$mk = trim($perTypeMenus[$cat] ?? '');
$key = (string)$cat;
if ($mk !== '') {
$perType[$key] = array_merge($perType[$key] ?? [], ['greeting_menu' => $mk]);
$key = (string)$cat;
$current = $perType[$key] ?? []; // preserve menus/flows/commands from seed
$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 {
unset($perType[$key]['greeting_menu']);
if (empty($perType[$key])) unset($perType[$key]);
// Sin greeting_flow → usa greeting_menu (cat 1)
$current['greeting'] = null;
unset($current['greeting_flow']);
$mk = trim($ptMenus[$cat] ?? '');
if ($mk !== '') {
$current['greeting_menu'] = $mk;
} else {
unset($current['greeting_menu']);
}
}
$fb = trim($ptFallback[$cat] ?? '');
if ($fb !== '') {
$current['fallback_flow'] = $fb;
} else {
unset($current['fallback_flow']);
}
$perType[$key] = $current;
}
$config['per_type'] = empty($perType) ? new stdClass() : $perType;
$config['per_type'] = $perType;
CompanyRepository::save(['id' => $companyId, 'config_json' => json_encode($config, JSON_UNESCAPED_UNICODE)]);
echo json_encode(['ok' => true]);
+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);
$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'];
foreach ($preserveKeys as $key) {
if (array_key_exists($key, $existing)) {
$menuConfig[$key] = $existing[$key];
// Partir del config existente para preservar TODO lo custom (nlu, whisper_*, gemini_*, etc.)
$merged = $existing;
// 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'])) {
$menuConfig['greeting'] = '¡Bienvenido! Escribe *menu* para ver las opciones disponibles.';
// Aplicar defaults solo si no existen
if (!isset($merged['greeting'])) {
$merged['greeting'] = '¡Bienvenido! Escribe *menu* para ver las opciones disponibles.';
}
if (!isset($menuConfig['fallback'])) {
$menuConfig['fallback'] = 'No entendí. Escribe *menu* para ver las opciones disponibles.';
if (!isset($merged['fallback'])) {
$merged['fallback'] = 'No entendí. Escribe *menu* para ver las opciones disponibles.';
}
CompanyRepository::save([
'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'];
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++;
}
+74 -11
View File
@@ -334,11 +334,11 @@ PROMPT;
* 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
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);
$catalog = self::buildFlowCatalog($config, $permType, $modulosPermitidos);
if (empty($catalog)) {
return ['action' => 'chat', 'text' => ''];
@@ -360,24 +360,52 @@ PROMPT;
$json = json_decode($clean, true);
if (!is_array($json) || !isset($json['action'])) {
return ['action' => 'chat', 'text' => $clean];
// 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'])) {
// Validate key exists in flows
$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']];
return [
'action' => 'route',
'key' => $json['key'],
'entities' => is_array($json['entities'] ?? null) ? $json['entities'] : [],
];
}
}
return ['action' => 'chat', 'text' => $json['text'] ?? $clean];
return ['action' => 'chat', 'text' => self::rescatarTexto((string)($json['text'] ?? $clean))];
}
private static function buildFlowCatalog(array $config, int $permType): array
/**
* 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'] ?? [];
@@ -400,14 +428,31 @@ PROMPT;
$type = $flow['type'] ?? 'text';
$fn = $flow['function'] ?? '';
$isUpload = in_array($type, ['collect_and_post', 'collect_for_each', 'submit_form'], true);
$isDownload = ($type === 'function' && $fn === 'api_report');
// 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;
@@ -439,8 +484,26 @@ PROMPT;
. "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>\"}\n\n"
. "Si no está claro o no hay opción correspondiente → responde SOLO:\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.";
}
+27 -6
View File
@@ -125,13 +125,20 @@ class BotRouter
if ($text !== null && trim($text) !== '') {
self::log("NLU media [{$inputType}]: texto extraído → \"{$text}\"");
// Confirmar al usuario lo que entendió
$prefix = $inputType === 'audio' ? '🎤 Transcripción' : '🖼️ Imagen analizada';
$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);
}
}
@@ -155,16 +162,30 @@ class BotRouter
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');
ConversationContext::updateNode((int)$botCtx['id'], $key);
$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}]");
// Ejecutar con input vacío — NormalBot retoma current_node
return NormalBot::process($company, $context, '');
}
+19 -3
View File
@@ -17,9 +17,24 @@ class ErpMonitor
public static function check(array $company): array
{
$baseUrl = rtrim($company['api_base_url'] ?? '', '/');
$healthUrl = $baseUrl !== '' ? $baseUrl . '/health' : '';
// El ERP no expone /health sino ?peticion=ping, y la URL sale de
// 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'] ?? '';
$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 === '') {
return [
@@ -27,7 +42,7 @@ class ErpMonitor
'company_name' => $company['name'] ?? '',
'status' => 'unknown',
'latency_ms' => null,
'error' => 'Sin URL configurada',
'error' => 'Sin endpoint registrado para esta empresa',
'last_check' => date('Y-m-d H:i:s'),
];
}
@@ -40,6 +55,7 @@ class ErpMonitor
CURLOPT_TIMEOUT => 10,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $apiKey,
'X-API-Key: ' . $apiKey,
'User-Agent: bot-palmas360-monitor/1.0',
],
+46 -3
View File
@@ -60,6 +60,11 @@ class MediaTranscriber
$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),
@@ -69,13 +74,51 @@ class MediaTranscriber
private static function whisper(string $bytes, string $mime, array $cfg): ?string
{
$apiKey = $cfg['openai_api_key'] ?? env('OPENAI_API_KEY', '');
if ($apiKey === '') return null;
$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, [
+1322 -71
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);
+12
View File
@@ -234,8 +234,11 @@ $db->exec("
id INT AUTO_INCREMENT PRIMARY KEY,
company_id INT NOT NULL,
endpoint_key VARCHAR(50) NOT NULL,
name VARCHAR(100) DEFAULT '',
params TEXT DEFAULT NULL,
direction ENUM('upload','download') NOT NULL,
url VARCHAR(500) DEFAULT '',
body_fields TEXT DEFAULT NULL,
method VARCHAR(10) DEFAULT 'GET',
last_response TEXT,
last_called_at DATETIME,
@@ -246,6 +249,15 @@ $db->exec("
) 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,
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";