Compare commits

...
59 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
16 changed files with 3761 additions and 229 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}"
}
}
}
]
}
]
}
}
]
}
+83 -20
View File
@@ -3193,28 +3193,81 @@ HTML;
// Per-category config HTML // Per-category config HTML
$perTypeConfig = $config['per_type'] ?? []; $perTypeConfig = $config['per_type'] ?? [];
$menuKeysList = array_keys($menus); $menuKeysList = array_keys($menus);
$catLabels = ['1' => 'Categoría 1 — Solo reporta', '2' => 'Categoría 2 — Solo recibe informes', '3' => 'Categoría 3 — Reporta y recibe']; $flowKeysList = array_keys($config['flows'] ?? []);
// Include per_type menus/flows in the option lists
foreach ($config['per_type'] ?? [] as $pt) {
foreach (array_keys($pt['menus'] ?? []) as $k) if (!in_array($k, $menuKeysList)) $menuKeysList[] = $k;
foreach (array_keys($pt['flows'] ?? []) as $k) if (!in_array($k, $flowKeysList)) $flowKeysList[] = $k;
}
$catMeta = [
'1' => ['label' => 'Categoría 1 — Solo reporta', 'icon' => '📤', 'bg' => '#f0fdf4', 'bd' => '#86efac', 'clr' => '#166534'],
'2' => ['label' => 'Categoría 2 — Solo recibe informes', 'icon' => '📥', 'bg' => '#eff6ff', 'bd' => '#93c5fd', 'clr' => '#1e40af'],
'3' => ['label' => 'Categoría 3 — Reporta y recibe', 'icon' => '🔄', 'bg' => '#fdf4ff', 'bd' => '#e879f9', 'clr' => '#701a75'],
];
$categoryTabHtml = ''; $categoryTabHtml = '';
// Build shared option lists
$menuOptsBase = '<option value="">— Sin menú —</option>';
foreach ($menuKeysList as $mk) $menuOptsBase .= '<option value="' . self::h($mk) . '">' . self::h($mk) . '</option>';
$flowOptsBase = '<option value="">— Ninguno —</option>';
foreach ($menuKeysList as $mk) $flowOptsBase .= '<option value="' . self::h($mk) . '">📑 ' . self::h($mk) . '</option>';
foreach ($flowKeysList as $fk) $flowOptsBase .= '<option value="' . self::h($fk) . '">🔀 ' . self::h($fk) . '</option>';
foreach ([1, 2, 3] as $cat) { foreach ([1, 2, 3] as $cat) {
$catCfg = $perTypeConfig[(string)$cat] ?? []; $catCfg = $perTypeConfig[(string)$cat] ?? [];
$selMenu = $catCfg['greeting_menu'] ?? ''; $m = $catMeta[(string)$cat];
$opts = '<option value="">— Usa configuración global —</option>'; $greetFlow = self::h($catCfg['greeting_flow'] ?? '');
foreach ($menuKeysList as $mk) { $fallback = $catCfg['fallback_flow'] ?? '';
$s = $selMenu === $mk ? ' selected' : ''; $selMenu = $catCfg['greeting_menu'] ?? '';
$opts .= '<option value="' . self::h($mk) . '"' . $s . '>' . self::h($mk) . '</option>';
} // greeting_menu selector (insert selected attr)
$label = $catLabels[(string)$cat]; $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 $categoryTabHtml .= <<<CATHTML
<div style="background:#f8f9fd;border:1px solid #eef1f5;border-radius:10px;padding:18px;margin-bottom:16px"> <div style="background:{$bg};border:1px solid {$bd};border-radius:10px;padding:18px;margin-bottom:16px">
<div style="font-weight:700;font-size:13px;color:#0b3d91;margin-bottom:12px">{$label}</div> <div style="display:flex;align-items:center;gap:8px;margin-bottom:14px">
<div class="form-group" style="margin-bottom:0"> <span style="font-size:20px">{$icon}</span>
<label>Menú de bienvenida (greeting menu)</label> <span style="font-weight:700;font-size:13px;color:{$clr}">{$label}</span>
<select name="per_type_menu[{$cat}]" style="width:100%;padding:9px 12px;border:1px solid #e2e5ea;border-radius:8px;font-size:13px"> </div>
{$opts} <div class="form-group" style="{$greetFlowStyle};margin-bottom:12px">
</select> <label>Flujo de bienvenida</label>
<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> <input type="text" name="per_type_greeting_flow[{$cat}]" value="{$greetFlow}"
</div> list="ptFlowsDatalist" placeholder="ej: ask_finca (se auto-selecciona si hay 1 sola opción)"
</div> 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; CATHTML;
} }
$aiModelMini = $aiModelVal === 'gpt-4o-mini' ? ' selected' : ''; $aiModelMini = $aiModelVal === 'gpt-4o-mini' ? ' selected' : '';
@@ -3643,7 +3696,13 @@ HTML;
<!-- ─── POR CATEGORÍA ────────────────────────────────────────────────── --> <!-- ─── POR CATEGORÍA ────────────────────────────────────────────────── -->
<div class="tab-content" id="tab-categories"> <div class="tab-content" id="tab-categories">
<div class="card-h">👥 Configuración por Categoría</div> <div class="card-h">👥 Configuración por Categoría</div>
<p style="font-size:12px;color:#7a8291;margin-bottom:16px">Define qué menú de bienvenida ve cada categoría de usuario cuando escribe por primera vez o sin contexto activo.</p> <p style="font-size:12px;color:#7a8291;margin-bottom:16px">Define el comportamiento inicial de cada categoría de usuario: si pide finca, qué menú muestra y qué hacer ante texto no reconocido.</p>
<datalist id="ptFlowsDatalist">
HTML;
foreach ($menuKeysList as $mk) echo '<option value="' . self::h($mk) . '">📑 Menú</option>' . "\n";
foreach ($flowKeysList as $fk) echo '<option value="' . self::h($fk) . '">🔀 Flow</option>' . "\n";
echo <<<HTML
</datalist>
{$categoryTabHtml} {$categoryTabHtml}
<button type="button" id="savePerTypeBtn" onclick="savePerType()" class="btn-primary" style="margin-top:8px">💾 Guardar categorías</button> <button type="button" id="savePerTypeBtn" onclick="savePerType()" class="btn-primary" style="margin-top:8px">💾 Guardar categorías</button>
</div> </div>
@@ -3851,8 +3910,12 @@ function savePerType() {
const fd = new FormData(); const fd = new FormData();
fd.set('company_id', cid); fd.set('company_id', cid);
[1, 2, 3].forEach(function(cat) { [1, 2, 3].forEach(function(cat) {
var gf = document.querySelector('[name="per_type_greeting_flow[' + cat + ']"]');
var sel = document.querySelector('[name="per_type_menu[' + cat + ']"]'); var sel = document.querySelector('[name="per_type_menu[' + cat + ']"]');
var fb = document.querySelector('[name="per_type_fallback[' + cat + ']"]');
if (gf) fd.append('per_type_greeting_flow[' + cat + ']', gf.value);
if (sel) fd.append('per_type_menu[' + cat + ']', sel.value); if (sel) fd.append('per_type_menu[' + cat + ']', sel.value);
if (fb) fd.append('per_type_fallback[' + cat + ']', fb.value);
}); });
var btn = document.getElementById('savePerTypeBtn'); var btn = document.getElementById('savePerTypeBtn');
if (btn) { btn.textContent = 'Guardando...'; btn.disabled = true; } if (btn) { btn.textContent = 'Guardando...'; btn.disabled = true; }
+7 -1
View File
@@ -308,11 +308,17 @@ class WpWebhook
self::log('INFO', "Pre-menú de empresa enviado a {$to} (" . count($items) . " opciones)"); 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 private static function isResetKeyword(string $text): bool
{ {
$n = mb_strtolower(trim($text)); $n = mb_strtolower(trim($text));
$n = str_replace(['á','é','í','ó','ú','ü','ñ'], ['a','e','i','o','u','u','n'], $n); $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 private static function getPermissionType(array $companies, int $companyId): int
+36 -9
View File
@@ -1,6 +1,8 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
date_default_timezone_set('America/Bogota');
require_once __DIR__ . '/../config/env.php'; require_once __DIR__ . '/../config/env.php';
require_once __DIR__ . '/../config/db.php'; require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../services/Settings.php'; require_once __DIR__ . '/../services/Settings.php';
@@ -658,22 +660,47 @@ $routes = [
$config = json_decode($company['config_json'] ?? '{}', true) ?: []; $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) $perType = (is_array($rawPt) && count(array_filter(array_keys($rawPt), 'is_string')) > 0)
? $rawPt : []; ? $rawPt : [];
$perTypeMenus = $_POST['per_type_menu'] ?? []; $ptGreetFlow = $_POST['per_type_greeting_flow'] ?? [];
$ptMenus = $_POST['per_type_menu'] ?? [];
$ptFallback = $_POST['per_type_fallback'] ?? [];
foreach ([1, 2, 3] as $cat) { foreach ([1, 2, 3] as $cat) {
$mk = trim($perTypeMenus[$cat] ?? ''); $key = (string)$cat;
$key = (string)$cat; $current = $perType[$key] ?? []; // preserve menus/flows/commands from seed
if ($mk !== '') {
$perType[$key] = array_merge($perType[$key] ?? [], ['greeting_menu' => $mk]); $gf = trim($ptGreetFlow[$cat] ?? '');
if ($gf !== '') {
// Si tiene greeting_flow → greeting='' para que el flow se dispare al primer mensaje
$current['greeting'] = '';
$current['greeting_flow'] = $gf;
unset($current['greeting_menu']);
} else { } else {
unset($perType[$key]['greeting_menu']); // Sin greeting_flow → usa greeting_menu (cat 1)
if (empty($perType[$key])) unset($perType[$key]); $current['greeting'] = null;
unset($current['greeting_flow']);
$mk = trim($ptMenus[$cat] ?? '');
if ($mk !== '') {
$current['greeting_menu'] = $mk;
} else {
unset($current['greeting_menu']);
}
} }
$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)]); CompanyRepository::save(['id' => $companyId, 'config_json' => json_encode($config, JSON_UNESCAPED_UNICODE)]);
echo json_encode(['ok' => true]); echo json_encode(['ok' => true]);
+63 -8
View File
@@ -334,11 +334,11 @@ PROMPT;
* Dado un mensaje de texto, decide si enrutar a un flow existente o responder en chat libre. * 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'=>'...'] * 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); $config = self::getConfig($company);
$permType = (int)($context['permission_type'] ?? 1); $permType = (int)($context['permission_type'] ?? 1);
$catalog = self::buildFlowCatalog($config, $permType); $catalog = self::buildFlowCatalog($config, $permType, $modulosPermitidos);
if (empty($catalog)) { if (empty($catalog)) {
return ['action' => 'chat', 'text' => '']; return ['action' => 'chat', 'text' => ''];
@@ -360,7 +360,10 @@ PROMPT;
$json = json_decode($clean, true); $json = json_decode($clean, true);
if (!is_array($json) || !isset($json['action'])) { 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'])) { if ($json['action'] === 'route' && isset($json['key'])) {
@@ -377,10 +380,32 @@ PROMPT;
} }
} }
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'] ?? []; $flows = $config['flows'] ?? [];
$menus = $config['menus'] ?? []; $menus = $config['menus'] ?? [];
@@ -403,8 +428,15 @@ PROMPT;
$type = $flow['type'] ?? 'text'; $type = $flow['type'] ?? 'text';
$fn = $flow['function'] ?? ''; $fn = $flow['function'] ?? '';
$isUpload = in_array($type, ['collect_and_post', 'collect_for_each', 'submit_form'], true); // nlu_dir deja que un menú de categoría declare su dirección: por tipo
$isDownload = ($type === 'function' && $fn === 'api_report'); // 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: // Permission filter:
// type 1 = solo reporta (upload), type 2 = solo recibe (download), type 3 = ambos // type 1 = solo reporta (upload), type 2 = solo recibe (download), type 3 = ambos
@@ -414,6 +446,13 @@ PROMPT;
// Flujos marcados como internos — no exponer al NLU // Flujos marcados como internos — no exponer al NLU
if (!empty($flow['nlu_skip'])) continue; 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 // Skip internal/navigation flows unless they have an explicit NLU description
$hasNluDesc = isset($flow['nlu_description']) && $flow['nlu_description'] !== ''; $hasNluDesc = isset($flow['nlu_description']) && $flow['nlu_description'] !== '';
if (in_array($type, ['text', 'image'], true) && !$isUpload && !$isDownload && !$hasNluDesc) continue; if (in_array($type, ['text', 'image'], true) && !$isUpload && !$isDownload && !$hasNluDesc) continue;
@@ -448,7 +487,23 @@ PROMPT;
. "{\"action\":\"route\",\"key\":\"<key_exacto>\",\"entities\":{\"campo\":\"valor\",...}}\n\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" . "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" . "Si no mencionó datos extra, omite entities o usa {}.\n\n"
. "Si no está claro o no hay opción correspondiente → responde SOLO:\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" . "{\"action\":\"chat\",\"text\":\"<respuesta corta en español, máx 2 oraciones>\"}\n\n"
. "No inventes keys. Usa exactamente los keys de la lista."; . "No inventes keys. Usa exactamente los keys de la lista.";
} }
+19 -3
View File
@@ -17,9 +17,24 @@ class ErpMonitor
public static function check(array $company): array public static function check(array $company): array
{ {
$baseUrl = rtrim($company['api_base_url'] ?? '', '/'); // El ERP no expone /health sino ?peticion=ping, y la URL sale de
$healthUrl = $baseUrl !== '' ? $baseUrl . '/health' : ''; // company_endpoints como el resto de las llamadas: api_base_url no
// sirve para esto. Sin esto el panel mostraba el ERP siempre caído.
$apiKey = $company['api_key'] ?? ''; $apiKey = $company['api_key'] ?? '';
$healthUrl = '';
try {
$stmt = db()->prepare(
"SELECT url FROM company_endpoints WHERE company_id = ? AND endpoint_key = ? AND is_active = 1 LIMIT 1"
);
$stmt->execute([(int)$company['id'], 'numeros_dn']);
$url = (string)($stmt->fetchColumn() ?: '');
if ($url !== '') {
// Cualquier endpoint sirve de referencia; se reemplaza por ping
$healthUrl = preg_replace('/peticion=[^&]*/', 'peticion=ping', $url);
}
} catch (\PDOException $e) {
$healthUrl = '';
}
if ($healthUrl === '') { if ($healthUrl === '') {
return [ return [
@@ -27,7 +42,7 @@ class ErpMonitor
'company_name' => $company['name'] ?? '', 'company_name' => $company['name'] ?? '',
'status' => 'unknown', 'status' => 'unknown',
'latency_ms' => null, 'latency_ms' => null,
'error' => 'Sin URL configurada', 'error' => 'Sin endpoint registrado para esta empresa',
'last_check' => date('Y-m-d H:i:s'), 'last_check' => date('Y-m-d H:i:s'),
]; ];
} }
@@ -40,6 +55,7 @@ class ErpMonitor
CURLOPT_TIMEOUT => 10, CURLOPT_TIMEOUT => 10,
CURLOPT_CONNECTTIMEOUT => 5, CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_HTTPHEADER => [ CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $apiKey,
'X-API-Key: ' . $apiKey, 'X-API-Key: ' . $apiKey,
'User-Agent: bot-palmas360-monitor/1.0', 'User-Agent: bot-palmas360-monitor/1.0',
], ],
+1080 -100
View File
File diff suppressed because it is too large Load Diff
+26 -7
View File
@@ -3,6 +3,9 @@ declare(strict_types=1);
class PhoneSync class PhoneSync
{ {
/** Endpoint registrado en company_endpoints que devuelve los números habilitados */
private const ENDPOINT_KEY = 'numeros_dn';
public static function syncAll(): array public static function syncAll(): array
{ {
$companies = CompanyRepository::findAll(); $companies = CompanyRepository::findAll();
@@ -19,14 +22,19 @@ class PhoneSync
{ {
$companyId = (int)$company['id']; $companyId = (int)$company['id'];
$name = $company['name'] ?? "company {$companyId}"; $name = $company['name'] ?? "company {$companyId}";
$baseUrl = rtrim($company['api_base_url'] ?? '', '/');
$apiKey = $company['api_key'] ?? ''; $apiKey = $company['api_key'] ?? '';
if ($baseUrl === '') { // La URL sale de company_endpoints, igual que el resto de llamadas al ERP.
return ['company' => $name, 'status' => 'skip', 'reason' => 'sin api_base_url']; // 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() ?: '');
$url = $baseUrl . '/php/controller/controller_api_externa.php?peticion=whatsapp_numeros'; if ($url === '') {
return ['company' => $name, 'status' => 'skip', 'reason' => 'sin endpoint ' . self::ENDPOINT_KEY];
}
$ch = curl_init($url); $ch = curl_init($url);
curl_setopt_array($ch, [ curl_setopt_array($ch, [
@@ -63,11 +71,14 @@ class PhoneSync
$db = db(); $db = db();
$upsert = $db->prepare(" $upsert = $db->prepare("
INSERT INTO company_phones (company_id, wa_number, label, permission_type, is_active) INSERT INTO company_phones (company_id, wa_number, label, permission_type, tercero_id, modulos_json, es_supervisor, is_active)
VALUES (?, ?, ?, ?, 1) VALUES (?, ?, ?, ?, ?, ?, ?, 1)
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
label = VALUES(label), label = VALUES(label),
permission_type = VALUES(permission_type), permission_type = VALUES(permission_type),
tercero_id = VALUES(tercero_id),
modulos_json = VALUES(modulos_json),
es_supervisor = VALUES(es_supervisor),
is_active = 1 is_active = 1
"); ");
@@ -76,11 +87,19 @@ class PhoneSync
$waNumber = trim($n['wa_number'] ?? ''); $waNumber = trim($n['wa_number'] ?? '');
if ($waNumber === '') continue; 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([ $upsert->execute([
$companyId, $companyId,
$waNumber, $waNumber,
trim($n['nombre'] ?? ''), trim($n['nombre'] ?? ''),
(int)($n['permiso'] ?? 1), (int)($n['permiso'] ?? 1),
$tercero,
$modulos ? json_encode($modulos) : null,
!empty($n['es_supervisor']) ? 1 : 0,
]); ]);
$activeNumbers[] = $waNumber; $activeNumbers[] = $waNumber;
} }
+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);
+622 -81
View File
@@ -6,17 +6,80 @@ require_once dirname(__DIR__) . '/config/db.php';
$db = db(); $db = db();
// ─── Columnas que el perfil por número necesita ───────────────────────────────
// Se crean acá y no con un ALTER a mano porque el seed ya se corre en cada
// despliegue. Es idempotente: si la columna existe, no hace nada.
foreach ([
'tercero_id' => 'INT NULL',
'modulos_json' => 'TEXT NULL',
'es_supervisor' => 'TINYINT(1) NOT NULL DEFAULT 0',
] as $columna => $tipo) {
$existe = $db->query("SHOW COLUMNS FROM company_phones LIKE '{$columna}'")->fetch();
if (!$existe) {
$db->exec("ALTER TABLE company_phones ADD COLUMN {$columna} {$tipo}");
echo "✔ company_phones.{$columna} creada\n";
}
}
// ─── CONFIG JSON completo (Company 2 y 3 comparten la misma base) ──────────── // ─── CONFIG JSON completo (Company 2 y 3 comparten la misma base) ────────────
$configJson = [ $configJson = [
'greeting' => '¡Bienvenido a Palmas360! Escribe *menu* para ver las opciones disponibles.', 'greeting' => '¡Bienvenido a Palmas360! Escribe *menu* para ver las opciones disponibles.',
'fallback' => 'No entendí. Escribe *menu* para ver las opciones disponibles.', 'fallback' => 'No entendí. Escribe *menu* para ver las opciones disponibles.',
'commands' => [ 'nlu_enabled' => true,
// ── Bienvenida: una vez por día y por empresa ───────────────────────────
// Tokens: {saludo} {empresa} {finca} {empresas}
// No menciona la finca porque llega antes de elegirla; la finca activa se
// ve en el footer de cada menú.
'welcome' => [
'enabled' => true,
'text' => [
// Cat 1 — solo reporta: sin ejemplos de descarga, que no puede usar
'1' => "{saludo}, bienvenido a {empresa}.\n\n"
. "Desde acá enviás los datos de campo:\n"
. " • _\"registrar un ausentismo\"_\n"
. " • _\"abrir ciclos de cosecha\"_\n"
. " • _\"registrar pluviometría\"_\n\n"
. "Cuando quieras:\n"
. " *menú* ver las opciones · *salir* terminar{empresas}",
'2' => "{saludo}, bienvenido a {empresa}.\n\n"
. "Pedime lo que necesites con tus palabras:\n"
. " • _\"informe de mantenimiento de plateo\"_\n"
. " • _\"quién faltó hoy\"_\n"
. " • _\"producción de este mes\"_\n\n"
. "O navegá con los menús. Cuando quieras:\n"
. " *menú* volver al inicio · *atrás* subir un nivel · *salir* terminar\n"
. " *cambiar finca* para consultar otra finca{empresas}",
'3' => "{saludo}, bienvenido a {empresa}.\n\n"
. "Pedime lo que necesites con tus palabras:\n"
. " • _\"informe de mantenimiento de plateo\"_\n"
. " • _\"quién faltó hoy\"_\n"
. " • _\"registrar un ausentismo\"_\n\n"
. "O navegá con los menús. Cuando quieras:\n"
. " *menú* volver al inicio · *atrás* subir un nivel · *salir* terminar\n"
. " *cambiar finca* para consultar otra finca{empresas}",
],
],
'commands' => [
// Al menú principal
'menu' => 'show_main_menu', 'menu' => 'show_main_menu',
'inicio' => 'show_main_menu',
'salir' => 'show_main_menu',
'cancelar' => 'show_main_menu',
// Un nivel arriba (__back lo resuelve con el mapa 'back' del nodo actual)
'atras' => '__back',
'volver' => '__back',
'regresar' => '__back',
// Atajos
'informes' => 'descargar_informes', 'informes' => 'descargar_informes',
'info' => 'enviar_informacion', 'info' => 'enviar_informacion',
'inicio' => 'show_main_menu', 'finca' => 'reset_finca',
'volver' => 'show_main_menu', 'cambiar finca' => 'reset_finca',
], ],
// ── Menus globales ────────────────────────────────────────────────────── // ── Menus globales ──────────────────────────────────────────────────────
'menus' => [ 'menus' => [
@@ -50,9 +113,9 @@ $configJson = [
[ [
'title' => 'Informes', 'title' => 'Informes',
'rows' => [ 'rows' => [
['id' => 'submenu_produccion', 'title' => '🌿 Producción', 'description' => 'Informes de producción'], ['id' => 'submenu_produccion', 'modulo' => 'produccion', 'title' => '🌿 Producción', 'description' => 'Informes de producción'],
['id' => 'submenu_ciclos', 'title' => '🔄 Ciclos', 'description' => 'Ciclos de campo'], ['id' => 'submenu_ciclos', 'modulo' => 'ciclos', 'title' => '🔄 Ciclos', 'description' => 'Ciclos de campo'],
['id' => 'submenu_ausentismos', 'title' => '👥 Ausentismos', 'description' => 'Faltas y novedades'], ['id' => 'submenu_ausentismos', 'modulo' => 'ausentismos', 'title' => '👥 Ausentismos', 'description' => 'Faltas y novedades'],
], ],
], ],
], ],
@@ -69,7 +132,7 @@ $configJson = [
[ [
'title' => 'Producción', 'title' => 'Producción',
'rows' => [ 'rows' => [
['id' => 'produccion_total', 'title' => '📊 Total del mes', 'description' => 'Resumen y PDF del mes'], ['id' => 'produccion_total', 'title' => '📊 Totales facturados', 'description' => 'Resumen y PDF del año'],
['id' => 'produccion_lotes', 'title' => '📋 Por lotes (Excel)', 'description' => 'Detalle por lote'], ['id' => 'produccion_lotes', 'title' => '📋 Por lotes (Excel)', 'description' => 'Detalle por lote'],
], ],
], ],
@@ -117,7 +180,7 @@ $configJson = [
['id'=>'ciclo_censo_todos', 'title'=>'📋 Todos los lotes'], ['id'=>'ciclo_censo_todos', 'title'=>'📋 Todos los lotes'],
['id'=>'ciclo_censo_historico','title'=>'📅 Histórico 30d'], ['id'=>'ciclo_censo_historico','title'=>'📅 Histórico 30d'],
]], ]],
'submenu_ciclo_palm' => ['type'=>'button','body'=>'Ciclos de Rincofurus — ¿Qué deseas ver?','buttons'=>[ 'submenu_ciclo_palm' => ['type'=>'button','body'=>'Ciclos de Palmarum — ¿Qué deseas ver?','buttons'=>[
['id'=>'ciclo_palm_top', 'title'=>'🏆 Más largos'], ['id'=>'ciclo_palm_top', 'title'=>'🏆 Más largos'],
['id'=>'ciclo_palm_todos', 'title'=>'📋 Todos los lotes'], ['id'=>'ciclo_palm_todos', 'title'=>'📋 Todos los lotes'],
['id'=>'ciclo_palm_historico','title'=>'📅 Histórico 30d'], ['id'=>'ciclo_palm_historico','title'=>'📅 Histórico 30d'],
@@ -128,9 +191,9 @@ $configJson = [
['id'=>'ciclo_tratamiento_historico','title'=>'📅 Histórico 30d'], ['id'=>'ciclo_tratamiento_historico','title'=>'📅 Histórico 30d'],
]], ]],
'submenu_ciclo_mantenimiento' => ['type'=>'button','body'=>'Ciclos de Mantenimiento — ¿Qué deseas ver?','buttons'=>[ 'submenu_ciclo_mantenimiento' => ['type'=>'button','body'=>'Ciclos de Mantenimiento — ¿Qué deseas ver?','buttons'=>[
['id'=>'ciclo_mantenimiento_top', 'title'=>'🏆 Más largos'], ['id'=>'ciclo_mantenimiento_top', 'title'=>'🏆 Más largos'],
['id'=>'ciclo_mantenimiento_todos', 'title'=>'📋 Todos los lotes'], ['id'=>'ciclo_mantenimiento_todos', 'title'=>'📋 Todos los lotes'],
['id'=>'ciclo_mantenimiento_historico', 'title'=>'📅 Histórico 30d'], ['id'=>'reset_grupo_mant', 'title'=>'🔄 Otro grupo'],
]], ]],
// Submenú sanidad vegetal (orden: Censo, Plagas, Rincofurus, Tratamiento) // Submenú sanidad vegetal (orden: Censo, Plagas, Rincofurus, Tratamiento)
@@ -146,7 +209,7 @@ $configJson = [
'rows' => [ 'rows' => [
['id' => 'ciclo_censo', 'title' => '📝 Censo', 'description' => 'Ciclos de censo'], ['id' => 'ciclo_censo', 'title' => '📝 Censo', 'description' => 'Ciclos de censo'],
['id' => 'ciclo_plagas', 'title' => '🐛 Plagas', 'description' => 'Ciclos de plagas'], ['id' => 'ciclo_plagas', 'title' => '🐛 Plagas', 'description' => 'Ciclos de plagas'],
['id' => 'ciclo_palm', 'title' => '🌴 Rincofurus', 'description' => 'Ciclos Rhynchophorus Palmarum'], ['id' => 'ciclo_palm', 'title' => '🌴 Palmarum', 'description' => 'Rhynchophorus Palmarum'],
['id' => 'ciclo_tratamiento', 'title' => '💊 Tratamiento', 'description' => 'Ciclos de tratamientos'], ['id' => 'ciclo_tratamiento', 'title' => '💊 Tratamiento', 'description' => 'Ciclos de tratamientos'],
], ],
], ],
@@ -183,7 +246,12 @@ $configJson = [
[ [
'title' => 'Opciones', 'title' => 'Opciones',
'rows' => [ 'rows' => [
['id' => 'subir_ciclo_cosecha', 'title' => '🌾 Ciclo cosecha', 'description' => 'Subir ciclo de cosecha'], ['id' => 'registrar_ausentismo', 'modulo' => 'ausentismos', 'title' => '👥 Ausentismo', 'description' => 'Registrar falta, incapacidad o permiso'],
['id' => 'registrar_pluviometria','modulo' => 'pluviometria', 'title' => '🌧️ Pluviometría', 'description' => 'Milímetros de lluvia por finca'],
['id' => 'registrar_ciclo', 'modulo' => 'ciclos', 'title' => '🔄 Ciclos', 'description' => 'Abrir o cerrar lotes'],
['id' => 'registrar_mantenimiento','modulo' => 'mantenimiento', 'title' => '🔧 Mantenimiento', 'description' => 'Avance sobre lotes pendientes'],
['id' => 'registrar_labor', 'modulo' => 'labores', 'title' => '💪 Labor diaria', 'description' => 'Tractorista, transporte, horas extra'],
['id' => 'registrar_labor_validada','modulo' => 'labores', 'title' => '🌾 Cosecha y otros','description' => 'Cosecha, polinización, fertilización'],
], ],
], ],
], ],
@@ -203,88 +271,109 @@ $configJson = [
'section_title' => 'Fincas disponibles', 'section_title' => 'Fincas disponibles',
'meta_group' => 'finca', 'meta_group' => 'finca',
'meta_key' => 'finca_id', 'meta_key' => 'finca_id',
'entity_key' => 'finca',
'next_node' => 'show_main_menu', 'next_node' => 'show_main_menu',
'skip_if_set' => true,
'nlu_description' => 'Seleccionar o cambiar la finca de trabajo',
],
// Acción NLU: limpiar finca y volver a ask_finca
'reset_finca' => [
'type' => 'function',
'function' => 'reset_finca',
'nlu_description' => 'Cambiar de finca o seleccionar una finca diferente',
],
// Vuelve a pedir el grupo de mantenimiento (el grupo persiste entre informes)
'reset_grupo_mant' => [
'type' => 'function',
'function' => 'reset_grupo_mant',
'nlu_description' => 'Cambiar el grupo de mantenimiento (plateo, corona, poda, etc.)',
], ],
// Producción (texto del mes + PDF encadenado) // Producción (texto del mes + PDF encadenado)
'produccion_total' => ['type' => 'function', 'function' => 'api_report', 'params' => [ 'produccion_total' => ['type' => 'function', 'function' => 'api_report', 'nlu_description' => 'Producción total facturada del mes actual con PDF del año', 'params' => [
'endpoint_key' => 'produccion_total_bot_dn', 'endpoint_key' => 'produccion_total_bot_dn',
'date_mode' => 'current_month', 'date_mode' => 'current_month',
'next_endpoint_key' => 'produccion_total_pdf_bot_dn', 'next_endpoint_key' => 'produccion_total_pdf_bot_dn',
'next_caption' => '📊 Producción del mes (PDF)', 'next_caption' => '📊 Producción del año (PDF)',
'next_filename' => 'produccion_mes.pdf', 'next_filename' => 'produccion_año.pdf',
]], ]],
'produccion_lotes' => ['type' => 'function', 'function' => 'api_report', 'params' => [ 'produccion_lotes' => ['type' => 'function', 'function' => 'api_report', 'nlu_description' => 'Producción detallada por lotes en Excel', 'params' => [
'endpoint_key' => 'produccion_lotes_bot_dn', 'endpoint_key' => 'produccion_lotes_bot_dn',
'filename' => 'produccion_lotes.xlsx', 'filename' => 'produccion_lotes.xlsx',
'caption' => 'Producción por lotes del año', 'caption' => 'Producción por lotes del año',
]], ]],
// Ausentismos // Ausentismos
'ausentismos_hoy' => ['type' => 'function', 'function' => 'api_report', 'params' => [ 'ausentismos_hoy' => ['type' => 'function', 'function' => 'api_report', 'nlu_description' => 'Ausentismos de hoy, quién faltó hoy', 'params' => [
'endpoint_key' => 'ausentismos_bot_texto_dn', 'endpoint_key' => 'ausentismos_bot_texto_dn',
'date_mode' => 'today', 'date_mode' => 'today',
]], ]],
'ausentismos_semana' => ['type' => 'function', 'function' => 'api_report', 'params' => [ 'ausentismos_semana' => ['type' => 'function', 'function' => 'api_report', 'nlu_description' => 'Ausentismos de esta semana', 'params' => [
'endpoint_key' => 'ausentismos_bot_texto_dn', 'endpoint_key' => 'ausentismos_bot_texto_dn',
'date_mode' => 'last_7', 'date_mode' => 'last_7',
]], ]],
'ausentismos_mes' => ['type' => 'function', 'function' => 'api_report', 'params' => [ 'ausentismos_mes' => ['type' => 'function', 'function' => 'api_report', 'nlu_description' => 'Reporte PDF de ausentismos del mes', 'params' => [
'endpoint_key' => 'ausentismos_bot_pdf_dn', 'endpoint_key' => 'ausentismos_bot_pdf_dn',
'date_mode' => 'current_month', 'date_mode' => 'current_month',
'filename' => 'ausentismos_mes.pdf', 'filename' => 'ausentismos_mes.pdf',
'caption' => 'Ausentismos del mes', 'caption' => 'Ausentismos del mes',
]], ]],
// Menus de sección // Menus de sección (nlu_skip: navegación interna, no exponer al NLU)
'submenu_produccion' => ['type' => 'menu', 'menu' => 'submenu_produccion'], // 'back' = a dónde sube el comando "atrás" desde cada nivel
'submenu_ciclos' => ['type' => 'menu', 'menu' => 'submenu_ciclos'], 'submenu_produccion' => ['type' => 'menu', 'menu' => 'submenu_produccion', 'back' => 'show_main_menu', 'nlu_dir' => 'descarga', 'nlu_description' => 'Producción en general: totales facturados o detalle por lotes'],
'submenu_ciclos_sanidad' => ['type' => 'menu', 'menu' => 'submenu_ciclos_sanidad'], 'submenu_ciclos' => ['type' => 'menu', 'menu' => 'submenu_ciclos', 'back' => 'show_main_menu', 'nlu_dir' => 'descarga', 'nlu_description' => 'Consultar ciclos de campo en general, sin decir cuál'],
'submenu_ausentismos' => ['type' => 'menu', 'menu' => 'submenu_ausentismos'], 'submenu_ciclos_sanidad' => ['type' => 'menu', 'menu' => 'submenu_ciclos_sanidad', 'back' => 'submenu_ciclos', 'nlu_dir' => 'descarga', 'nlu_description' => 'Ciclos de sanidad en general: censo, plagas, palmarum, tratamiento'],
'submenu_ausentismos' => ['type' => 'menu', 'menu' => 'submenu_ausentismos', 'back' => 'show_main_menu', 'nlu_dir' => 'descarga', 'nlu_description' => 'Consultar ausentismos: quién faltó hoy, esta semana o el mes'],
// Ciclos — cada uno abre un sub-menú de 3 opciones // Ciclos — cada uno abre un sub-menú de 3 opciones
'ciclo_cosecha' => ['type' => 'menu', 'menu' => 'submenu_ciclo_cosecha'], 'ciclo_cosecha' => ['type' => 'menu', 'menu' => 'submenu_ciclo_cosecha', 'back' => 'submenu_ciclos', 'nlu_description' => 'Ver ciclos de cosecha (lotes más largos o todos)'],
'ciclo_polinizacion' => ['type' => 'menu', 'menu' => 'submenu_ciclo_polinizacion'], 'ciclo_polinizacion' => ['type' => 'menu', 'menu' => 'submenu_ciclo_polinizacion', 'back' => 'submenu_ciclos', 'nlu_description' => 'Ver ciclos de polinización'],
'ciclo_plagas' => ['type' => 'menu', 'menu' => 'submenu_ciclo_plagas'], 'ciclo_plagas' => ['type' => 'menu', 'menu' => 'submenu_ciclo_plagas', 'back' => 'submenu_ciclos_sanidad', 'nlu_description' => 'Ver ciclos de plagas'],
'ciclo_censo' => ['type' => 'menu', 'menu' => 'submenu_ciclo_censo'], 'ciclo_censo' => ['type' => 'menu', 'menu' => 'submenu_ciclo_censo', 'back' => 'submenu_ciclos_sanidad', 'nlu_description' => 'Ver ciclos de censo'],
'ciclo_palm' => ['type' => 'menu', 'menu' => 'submenu_ciclo_palm'], 'ciclo_palm' => ['type' => 'menu', 'menu' => 'submenu_ciclo_palm', 'back' => 'submenu_ciclos_sanidad', 'nlu_description' => 'Ver ciclos de Palmarum (Rhynchophorus)'],
'ciclo_tratamiento' => ['type' => 'menu', 'menu' => 'submenu_ciclo_tratamiento'], 'ciclo_tratamiento' => ['type' => 'menu', 'menu' => 'submenu_ciclo_tratamiento', 'back' => 'submenu_ciclos_sanidad', 'nlu_description' => 'Ver ciclos de tratamientos'],
'ciclo_mantenimiento'=> ['type' => 'menu', 'menu' => 'submenu_ciclo_mantenimiento'],
// Sub-opciones: cosecha // Sub-opciones: cosecha
'ciclo_cosecha_top' => ['type'=>'function','function'=>'api_report','params'=>['endpoint_key'=>'cosecha_top_texto_bot_dn','date_mode'=>'last_30']], 'ciclo_cosecha_top' => ['type'=>'function','function'=>'api_report','nlu_description'=>'Lotes de cosecha con ciclos más largos','params'=>['endpoint_key'=>'cosecha_top_texto_bot_dn','date_mode'=>'last_30']],
'ciclo_cosecha_todos' => ['type'=>'function','function'=>'api_report','params'=>['endpoint_key'=>'cosecha_todos_dn','date_mode'=>'current_month','filename'=>'ciclos_cosecha.pdf','caption'=>'Ciclos de Cosecha — todos los lotes']], 'ciclo_cosecha_todos' => ['type'=>'function','function'=>'api_report','nlu_description'=>'PDF de ciclos de cosecha todos los lotes','params'=>['endpoint_key'=>'cosecha_todos_dn','date_mode'=>'today','filename'=>'ciclos_cosecha.pdf','caption'=>'Ciclos de Cosecha — todos los lotes']],
'ciclo_cosecha_historico'=> ['type'=>'function','function'=>'api_report','params'=>['endpoint_key'=>'cosecha_historico_bot_dn','date_mode'=>'last_30','filename'=>'historico_cosecha.pdf','caption'=>'Histórico Cosecha — últimos 30 días']], 'ciclo_cosecha_historico'=> ['type'=>'function','function'=>'api_report','nlu_description'=>'Histórico de cosecha últimos 30 días','params'=>['endpoint_key'=>'cosecha_historico_bot_dn','date_mode'=>'last_30','filename'=>'historico_cosecha.pdf','caption'=>'Histórico Cosecha — últimos 30 días']],
// Sub-opciones: polinizacion // Sub-opciones: polinizacion
'ciclo_polinizacion_top' => ['type'=>'function','function'=>'api_report','params'=>['endpoint_key'=>'polinizacion_top_texto_bot_dn','date_mode'=>'last_30']], 'ciclo_polinizacion_top' => ['type'=>'function','function'=>'api_report','nlu_description'=>'Lotes de polinización con ciclos más largos','params'=>['endpoint_key'=>'polinizacion_top_texto_bot_dn','date_mode'=>'last_30']],
'ciclo_polinizacion_todos' => ['type'=>'function','function'=>'api_report','params'=>['endpoint_key'=>'polinizacion_todos_dn','date_mode'=>'current_month','filename'=>'ciclos_polinizacion.pdf','caption'=>'Ciclos de Polinización — todos los lotes']], 'ciclo_polinizacion_todos' => ['type'=>'function','function'=>'api_report','nlu_description'=>'PDF de ciclos de polinización todos los lotes','params'=>['endpoint_key'=>'polinizacion_todos_dn','date_mode'=>'today','filename'=>'ciclos_polinizacion.pdf','caption'=>'Ciclos de Polinización — todos los lotes']],
'ciclo_polinizacion_historico'=> ['type'=>'function','function'=>'api_report','params'=>['endpoint_key'=>'polinizacion_historico_bot_dn','date_mode'=>'last_30','filename'=>'historico_polinizacion.pdf','caption'=>'Histórico Polinización — últimos 30 días']], 'ciclo_polinizacion_historico'=> ['type'=>'function','function'=>'api_report','nlu_description'=>'Histórico de polinización últimos 30 días','params'=>['endpoint_key'=>'polinizacion_historico_bot_dn','date_mode'=>'last_30','filename'=>'historico_polinizacion.pdf','caption'=>'Histórico Polinización — últimos 30 días']],
// Sub-opciones: plagas // Sub-opciones: plagas
'ciclo_plagas_top' => ['type'=>'function','function'=>'api_report','params'=>['endpoint_key'=>'plagas_top_texto_bot_dn','date_mode'=>'last_30']], 'ciclo_plagas_top' => ['type'=>'function','function'=>'api_report','nlu_description'=>'Lotes de plagas con ciclos más largos','params'=>['endpoint_key'=>'plagas_top_texto_bot_dn','date_mode'=>'last_30']],
'ciclo_plagas_todos' => ['type'=>'function','function'=>'api_report','params'=>['endpoint_key'=>'plagas_todos_dn','date_mode'=>'current_month','filename'=>'ciclos_plagas.pdf','caption'=>'Ciclos de Plagas — todos los lotes']], 'ciclo_plagas_todos' => ['type'=>'function','function'=>'api_report','nlu_description'=>'PDF de ciclos de plagas todos los lotes','params'=>['endpoint_key'=>'plagas_todos_dn','date_mode'=>'today','filename'=>'ciclos_plagas.pdf','caption'=>'Ciclos de Plagas — todos los lotes']],
'ciclo_plagas_historico'=> ['type'=>'function','function'=>'api_report','params'=>['endpoint_key'=>'plagas_historico_bot_dn','date_mode'=>'last_30','filename'=>'historico_plagas.pdf','caption'=>'Histórico Plagas — últimos 30 días']], 'ciclo_plagas_historico'=> ['type'=>'function','function'=>'api_report','nlu_description'=>'Histórico de plagas últimos 30 días','params'=>['endpoint_key'=>'plagas_historico_bot_dn','date_mode'=>'last_30','filename'=>'historico_plagas.pdf','caption'=>'Histórico Plagas — últimos 30 días']],
// Sub-opciones: censo // Sub-opciones: censo
'ciclo_censo_top' => ['type'=>'function','function'=>'api_report','params'=>['endpoint_key'=>'censo_top_texto_bot_dn','date_mode'=>'last_30']], 'ciclo_censo_top' => ['type'=>'function','function'=>'api_report','nlu_description'=>'Lotes de censo con ciclos más largos','params'=>['endpoint_key'=>'censo_top_texto_bot_dn','date_mode'=>'last_30']],
'ciclo_censo_todos' => ['type'=>'function','function'=>'api_report','params'=>['endpoint_key'=>'censo_todos_dn','date_mode'=>'current_month','filename'=>'ciclos_censo.pdf','caption'=>'Ciclos de Censo — todos los lotes']], 'ciclo_censo_todos' => ['type'=>'function','function'=>'api_report','nlu_description'=>'PDF de ciclos de censo todos los lotes','params'=>['endpoint_key'=>'censo_todos_dn','date_mode'=>'today','filename'=>'ciclos_censo.pdf','caption'=>'Ciclos de Censo — todos los lotes']],
'ciclo_censo_historico'=> ['type'=>'function','function'=>'api_report','params'=>['endpoint_key'=>'censo_historico_bot_dn','date_mode'=>'last_30','filename'=>'historico_censo.pdf','caption'=>'Histórico Censo — últimos 30 días']], 'ciclo_censo_historico'=> ['type'=>'function','function'=>'api_report','nlu_description'=>'Histórico de censo últimos 30 días','params'=>['endpoint_key'=>'censo_historico_bot_dn','date_mode'=>'last_30','filename'=>'historico_censo.pdf','caption'=>'Histórico Censo — últimos 30 días']],
// Sub-opciones: palm // Sub-opciones: palm
'ciclo_palm_top' => ['type'=>'function','function'=>'api_report','params'=>['endpoint_key'=>'palm_top_texto_bot_dn','date_mode'=>'last_30']], 'ciclo_palm_top' => ['type'=>'function','function'=>'api_report','nlu_description'=>'Lotes de Palmarum con ciclos más largos','params'=>['endpoint_key'=>'palm_top_texto_bot_dn','date_mode'=>'last_30']],
'ciclo_palm_todos' => ['type'=>'function','function'=>'api_report','params'=>['endpoint_key'=>'palm_todos_dn','date_mode'=>'current_month','filename'=>'ciclos_palmarum.pdf','caption'=>'Ciclos de Palmarum — todos los lotes']], 'ciclo_palm_todos' => ['type'=>'function','function'=>'api_report','nlu_description'=>'PDF de ciclos de Palmarum todos los lotes','params'=>['endpoint_key'=>'palm_todos_dn','date_mode'=>'today','filename'=>'ciclos_palmarum.pdf','caption'=>'Ciclos de Palmarum — todos los lotes']],
'ciclo_palm_historico'=> ['type'=>'function','function'=>'api_report','params'=>['endpoint_key'=>'palm_historico_bot_dn','date_mode'=>'last_30','filename'=>'historico_palmarum.pdf','caption'=>'Histórico Palmarum — últimos 30 días']], 'ciclo_palm_historico'=> ['type'=>'function','function'=>'api_report','nlu_description'=>'Histórico de Palmarum últimos 30 días','params'=>['endpoint_key'=>'palm_historico_bot_dn','date_mode'=>'last_30','filename'=>'historico_palmarum.pdf','caption'=>'Histórico Palmarum — últimos 30 días']],
// Sub-opciones: tratamiento // Sub-opciones: tratamiento
'ciclo_tratamiento_top' => ['type'=>'function','function'=>'api_report','params'=>['endpoint_key'=>'tratamiento_top_texto_bot_dn','date_mode'=>'last_30']], 'ciclo_tratamiento_top' => ['type'=>'function','function'=>'api_report','nlu_description'=>'Lotes de tratamiento con ciclos más largos','params'=>['endpoint_key'=>'tratamiento_top_texto_bot_dn','date_mode'=>'last_30']],
'ciclo_tratamiento_todos' => ['type'=>'function','function'=>'api_report','params'=>['endpoint_key'=>'tratamiento_todos_dn','date_mode'=>'current_month','filename'=>'ciclos_tratamientos.pdf','caption'=>'Ciclos de Tratamientos — todos los lotes']], 'ciclo_tratamiento_todos' => ['type'=>'function','function'=>'api_report','nlu_description'=>'PDF de ciclos de tratamientos todos los lotes','params'=>['endpoint_key'=>'tratamiento_todos_dn','date_mode'=>'today','filename'=>'ciclos_tratamientos.pdf','caption'=>'Ciclos de Tratamientos — todos los lotes']],
'ciclo_tratamiento_historico'=> ['type'=>'function','function'=>'api_report','params'=>['endpoint_key'=>'tratamiento_historico_bot_dn','date_mode'=>'last_30','filename'=>'historico_tratamientos.pdf','caption'=>'Histórico Tratamientos — últimos 30 días']], 'ciclo_tratamiento_historico'=> ['type'=>'function','function'=>'api_report','nlu_description'=>'Histórico de tratamientos últimos 30 días','params'=>['endpoint_key'=>'tratamiento_historico_bot_dn','date_mode'=>'last_30','filename'=>'historico_tratamientos.pdf','caption'=>'Histórico Tratamientos — últimos 30 días']],
// ciclo_mantenimiento → selección dinámica de grupo (top3 + "Otro") // ciclo_mantenimiento → selección dinámica de grupo (top3 + "Otro")
'ciclo_mantenimiento' => [ 'ciclo_mantenimiento' => [
'type' => 'dynamic_list', 'type' => 'dynamic_list',
'source_endpoint_key' => 'mantenimiento_grupos_top3_dn', 'source_endpoint_key' => 'mantenimiento_grupos_top3_dn',
// Con entities del NLU se busca en el catálogo completo: "plateo" puede
// no estar entre los 3 grupos más frecuentes.
'source_endpoint_key_all' => 'mantenimiento_grupos_all_dn',
'back' => 'submenu_ciclos',
'value_field' => 'id', 'value_field' => 'id',
'label_field' => 'label', 'label_field' => 'label',
'header' => '🔧 Mantenimiento', 'header' => '🔧 Mantenimiento',
@@ -292,8 +381,10 @@ $configJson = [
'section_title' => 'Grupos frecuentes', 'section_title' => 'Grupos frecuentes',
'meta_group' => 'grupo_mant', 'meta_group' => 'grupo_mant',
'meta_key' => 'grupo_id', 'meta_key' => 'grupo_id',
'entity_key' => 'grupo',
'next_node' => 'submenu_ciclo_mantenimiento', 'next_node' => 'submenu_ciclo_mantenimiento',
'append_options' => [['id' => 'select_grupo_mant_otros', 'title' => ' Otro grupo']], 'append_options' => [['id' => 'select_grupo_mant_otros', 'title' => ' Otro grupo']],
'nlu_description' => 'Informes de mantenimiento de un grupo (plateo, corona, poda...) — usar cuando NO dice qué informe quiere',
], ],
'select_grupo_mant_otros' => [ 'select_grupo_mant_otros' => [
'type' => 'dynamic_list', 'type' => 'dynamic_list',
@@ -306,20 +397,403 @@ $configJson = [
'meta_group' => 'grupo_mant', 'meta_group' => 'grupo_mant',
'meta_key' => 'grupo_id', 'meta_key' => 'grupo_id',
'next_node' => 'submenu_ciclo_mantenimiento', 'next_node' => 'submenu_ciclo_mantenimiento',
'nlu_skip' => true,
], ],
// Sub-opciones: mantenimiento // Sub-opciones: mantenimiento.
'ciclo_mantenimiento_top' => ['type'=>'function','function'=>'api_report','params'=>['endpoint_key'=>'mantenimiento_top_bot_dn','date_mode'=>'today','filename'=>'mantenimiento_top.pdf','caption'=>'Mantenimiento — lotes con ciclos más largos']], // requires/resolver: si el grupo aún no está elegido, el bot lo pide primero
'ciclo_mantenimiento_todos' => ['type'=>'function','function'=>'api_report','params'=>['endpoint_key'=>'mantenimiento_todos_bot_dn','date_mode'=>'today','filename'=>'mantenimiento_todos.pdf','caption'=>'Mantenimiento — todos los lotes']], // y al resolverlo vuelve a este informe (no al submenú).
'ciclo_mantenimiento_historico'=> ['type'=>'function','function'=>'api_report','params'=>['endpoint_key'=>'mantenimiento_todos_bot_dn','date_mode'=>'last_30','filename'=>'mantenimiento_historico.pdf','caption'=>'Mantenimiento — histórico 30 días']], 'ciclo_mantenimiento_top' => [
'type'=>'function','function'=>'api_report',
'requires'=>['grupo_mant'=>'grupo_id'], 'resolver'=>'ciclo_mantenimiento',
'back'=>'submenu_ciclo_mantenimiento',
'nlu_description'=>'Lotes MÁS ATRASADOS de un grupo de mantenimiento — solo si pide los más largos o atrasados',
'params'=>['endpoint_key'=>'mantenimiento_top_texto_bot_dn','date_mode'=>'today'],
],
'ciclo_mantenimiento_todos' => [
'type'=>'function','function'=>'api_report',
'requires'=>['grupo_mant'=>'grupo_id'], 'resolver'=>'ciclo_mantenimiento',
'back'=>'submenu_ciclo_mantenimiento',
'nlu_description'=>'PDF con TODOS los lotes de un grupo de mantenimiento — solo si pide explícitamente todos',
'params'=>['endpoint_key'=>'mantenimiento_todos_bot_dn','date_mode'=>'today','filename'=>'mantenimiento_todos.pdf','caption'=>'Mantenimiento — todos los lotes'],
],
// ── Carga de datos (POST) — Fase 1 ──────────────────────────────────
// Palmas360 decide si auto-aprueba o encola para revisión; el bot solo
// envía. Campos opcionales del contrato (eps, diagnostico_id) quedan
// fuera hasta confirmar qué novedades son incapacidad.
'registrar_ausentismo' => [
'type' => 'collect_and_post',
'endpoint_key' => 'ausentismos_up',
'header' => '👥 Registrar ausentismo',
'confirm' => true,
'success_text' => '✅ Ausentismo enviado a Palmas360.',
'back' => 'enviar_informacion',
'nlu_description' => 'Registrar un ausentismo, incapacidad o permiso de un trabajador',
'fields' => [
[
'key' => 'empleado_id',
'label' => 'Trabajador',
'type' => 'lookup',
// Numero vinculado a un trabajador: no se le pregunta
'from_phone' => 'tercero_id',
'prompt' => '👤 Escribe el nombre o documento del trabajador:',
'lookup_endpoint_key' => 'empleados_buscar_dn',
'lookup_param' => 'filtro',
'value_field' => 'id',
'display_field' => 'nombre',
'not_found_text' => '⚠️ No encontré ese trabajador. Escribe el nombre de nuevo:',
],
[
// Primero el grupo general: 17 motivos en una sola lista
// llegaban con el nombre cortado y costaban de entender
'key' => 'grupo_novedad',
// Al cambiarlo se vuelve a preguntar: el motivo pertenece al tipo elegido
'invalida' => ['novedad_id'],
'label' => 'Tipo',
'type' => 'select',
'prompt' => '¿Qué tipo de ausentismo?',
'source_endpoint_key' => 'novedades_grupos_dn',
'value_field' => 'id',
'label_field' => 'nombre',
'desc_field' => 'descripcion',
],
[
'key' => 'novedad_id',
'label' => 'Motivo',
'type' => 'select',
'prompt' => '¿Cuál es el motivo?',
'source_endpoint_key' => 'novedades_ausentismo_dn',
'desc_field' => 'descripcion',
'value_field' => 'id',
'label_field' => 'nombre',
],
[
'key' => 'fecha_inicial',
'label' => 'Desde',
'type' => 'date_quick',
'prompt' => '📅 ¿Desde qué fecha?',
'other_validate' => 'date',
'other_prompt' => 'Escribe la fecha de inicio (YYYY-MM-DD):',
],
[
'key' => 'fecha_final',
'label' => 'Hasta',
'type' => 'date_quick',
// Una novedad termina hoy o mas adelante, nunca anteayer
'quick' => 'futuro',
'prompt' => '📅 ¿Hasta qué fecha? (si es un solo día, elige la misma)',
'other_validate' => 'date',
'other_prompt' => 'Escribe la fecha final (YYYY-MM-DD):',
'min_field' => 'fecha_inicial',
'min_error' => '⚠️ La fecha final no puede ser anterior a la inicial. Elige otra:',
],
],
],
// Pluviometría: un valor de mm por finca, todas contra la misma fecha.
// exclude_values saca el "🌐 Todas las fincas" (id 0) que el catálogo
// antepone para los informes y que acá no es una finca real.
'registrar_pluviometria' => [
'type' => 'collect_for_each',
'source_endpoint_key' => 'fincas_dn',
'endpoint_key' => 'pluvio_upload',
'value_field' => 'id',
'label_field' => 'label',
'exclude_values' => ['0'],
'ask_date' => true,
'date_question' => '📅 ¿De qué fecha es la lectura?',
'question' => '🌧️ ¿Cuántos milímetros?',
'header' => '🌧️ Pluviometría',
'items_key' => 'fincas',
'success_text' => '✅ Pluviometría enviada a Palmas360.',
'back' => 'enviar_informacion',
'nlu_description' => 'Registrar pluviometría, lluvia o milímetros por finca',
],
// Apertura y cierre de lotes, para los seis tipos de ciclo.
// Mantenimiento no va acá: no tiene tabla de ciclos ni apertura/cierre,
// es una labor. El orden sigue el de la pantalla web.
'registrar_ciclo' => [
'type' => 'collect_and_post',
'endpoint_key' => 'ciclos_up',
'header' => '🔄 Ciclos',
'confirm' => true,
'success_text' => '✅ Enviado a Palmas360.',
'back' => 'enviar_informacion',
'nlu_description' => 'Abrir o cerrar ciclos de lotes (cosecha, sanidad, censo, palmarum, tratamiento, polinización)',
'fields' => [
[
'key' => 'ciclo',
'label' => 'Ciclo',
'type' => 'static_select',
'prompt' => '¿Qué ciclo vas a registrar?',
'options' => [
['id' => 'cosecha', 'label' => '🌾 Cosecha'],
['id' => 'polinizacion', 'label' => '🌸 Polinización'],
['id' => 'sanidad', 'label' => '🐛 Plagas'],
['id' => 'censo', 'label' => '📝 Censo'],
['id' => 'palm', 'label' => '🌴 Palmarum'],
['id' => 'tratamiento', 'label' => '💊 Tratamiento'],
],
],
[
'key' => 'accion',
'label' => 'Acción',
'type' => 'static_select',
'prompt' => '¿Qué vas a hacer?',
'options' => [
['id' => 'apertura', 'label' => '📂 Abrir lotes'],
['id' => 'cierre', 'label' => '📁 Cerrar lotes'],
],
],
[
'key' => 'fecha',
'label' => 'Fecha',
'type' => 'date_quick',
'prompt' => '📅 ¿De qué fecha?',
'other_validate' => 'date',
'other_prompt' => 'Escribe la fecha (YYYY-MM-DD):',
],
[
'key' => 'lote_ids',
'label' => 'Lotes',
'type' => 'multi_select',
'prompt' => 'Elige los lotes (puedes marcar varios):',
// Apertura ofrece los lotes de la finca; cierre, solo los abiertos
'source_endpoint_key' => 'lotes_{accion}_dn',
// "Otro lote" cambia al listado completo
'full_endpoint_key' => 'lotes_{accion}_todos_dn',
'value_field' => 'id',
'label_field' => 'label',
'empty_text' => '⚠️ No hay lotes disponibles para esta combinación.',
],
],
],
// Mantenimiento no abre ni cierra ciclos —no tiene tabla de ciclos—:
// se registra avance sobre lotes que ya tienen cantidad pendiente.
'registrar_mantenimiento' => [
'type' => 'collect_and_post',
'endpoint_key' => 'labores_up',
'header' => '🔧 Mantenimiento',
'confirm' => true,
'success_text' => '✅ Enviado a Palmas360.',
'back' => 'enviar_informacion',
'requires' => ['grupo_mant' => 'grupo_id'],
'resolver' => 'ciclo_mantenimiento',
'nlu_description' => 'Registrar avance de mantenimiento: plateo, corona, poda',
'fields' => [
[
'key' => 'fecha',
'label' => 'Fecha',
'type' => 'date_quick',
'prompt' => '📅 ¿De qué fecha?',
'other_validate' => 'date',
'other_prompt' => 'Escribe la fecha (YYYY-MM-DD):',
],
[
'key' => 'lote_id',
'label' => 'Lote',
'type' => 'select',
'prompt' => 'Elige el lote (entre paréntesis va lo que falta):',
'source_endpoint_key' => 'lotes_mantenimiento_dn',
'value_field' => 'id',
'label_field' => 'label',
'empty_text' => '⚠️ No hay lotes con cantidad pendiente para ese grupo.',
],
[
// labores_up exige la novedad concreta, no el grupo: se
// ofrecen solo las del grupo ya elegido
'key' => 'novedad_id',
'label' => 'Labor',
'type' => 'select',
'prompt' => '¿Qué labor de mantenimiento?',
'source_endpoint_key' => 'novedades_mant_dn',
'value_field' => 'id',
'label_field' => 'nombre',
],
[
'key' => 'empleados',
'label' => 'Trabajadores',
'type' => 'multi_select',
'prompt' => '¿Quiénes lo hicieron? (puedes marcar varios)',
'source_endpoint_key' => 'empleados_labor_dn',
'value_field' => 'id',
'label_field' => 'nombre',
'from_phone' => 'tercero_id',
],
[
'key' => 'cantidad',
'label' => 'Cantidad',
'type' => 'text',
'prompt' => '¿Qué cantidad avanzaste?',
],
],
],
// Labores diarias sin validaciones: tractorista, transporte, horas
// extra. El ERP filtra el catalogo de novedades, el bot no decide cual
// corresponde. A quien ya tiene jornal ese dia se le registra con
// jornal 0 y el ERP lo avisa en la respuesta.
'registrar_labor' => [
'type' => 'collect_and_post',
'endpoint_key' => 'labores_up',
'header' => '💪 Labor diaria',
'confirm' => true,
'success_text' => '✅ Enviado a Palmas360.',
'back' => 'enviar_informacion',
'nlu_description' => 'Registrar una labor diaria: tractorista, transporte, horas extra, labores varias',
'fields' => [
[
'key' => 'fecha',
'label' => 'Fecha',
'type' => 'date_quick',
'prompt' => '📅 ¿De qué fecha es la labor?',
'other_validate' => 'date',
'other_prompt' => 'Escribe la fecha (YYYY-MM-DD):',
],
[
'key' => 'novedad_id',
'label' => 'Labor',
'type' => 'select',
'prompt' => '¿Qué labor se hizo?',
'source_endpoint_key' => 'novedades_labor_dn',
'value_field' => 'id',
'label_field' => 'nombre',
],
[
'key' => 'lote_id',
'label' => 'Lote',
'type' => 'select',
'prompt' => '¿En qué lote?',
'source_endpoint_key' => 'lotes_finca_dn',
'value_field' => 'id',
'label_field' => 'label',
],
[
'key' => 'empleados',
'label' => 'Trabajadores',
'type' => 'multi_select',
'prompt' => '¿Quiénes la hicieron? (puedes marcar varios)',
'source_endpoint_key' => 'empleados_labor_dn',
'value_field' => 'id',
'label_field' => 'nombre',
// El trabajador vinculado se pre-llena; el supervisor elige
'from_phone' => 'tercero_id',
],
[
'key' => 'cantidad',
'label' => 'Cantidad',
'type' => 'text',
'prompt' => '¿Qué cantidad? (jornales, horas o unidades según la labor)',
],
],
],
// Fase 3: labores con validacion previa. Un solo flujo con campos
// condicionales en vez de tres: cosecha no lleva producto, y el pase
// es solo de polinizacion. El ERP valida inventario y autorizaciones,
// y si rechaza algo lo dice en la respuesta.
'registrar_labor_validada' => [
'type' => 'collect_and_post',
'endpoint_key' => 'labores_up',
'header' => '🌾 Cosecha, polinización y fertilización',
'confirm' => true,
'success_text' => '✅ Enviado a Palmas360.',
'back' => 'enviar_informacion',
'nlu_description' => 'Registrar labor de cosecha, polinización o fertilización',
'fields' => [
[
'key' => 'tipo',
// Al cambiarlo se vuelve a preguntar: el catálogo de labores
// y el producto dependen del tipo elegido
'invalida' => ['novedad_id', 'producto_id'],
'label' => 'Tipo',
'type' => 'static_select',
'prompt' => '¿Qué vas a registrar?',
'options' => [
['id' => 'cosecha', 'label' => '🌾 Cosecha'],
['id' => 'polinizacion', 'label' => '🌸 Polinización'],
['id' => 'fertilizacion','label' => '🧪 Fertilización'],
],
],
[
'key' => 'novedad_id',
'label' => 'Labor',
'type' => 'select',
'prompt' => '¿Qué labor se hizo?',
'source_endpoint_key' => 'novedades_labor3_dn',
'value_field' => 'id',
'label_field' => 'nombre',
],
[
'key' => 'fecha',
'label' => 'Fecha',
'type' => 'date_quick',
'prompt' => '📅 ¿De qué fecha?',
'other_validate' => 'date',
'other_prompt' => 'Escribe la fecha (YYYY-MM-DD):',
],
[
'key' => 'lote_id',
// Al cambiarlo se vuelve a preguntar: la dosis sale de la fertilizacion de ese lote
'invalida' => ['producto_id'],
'label' => 'Lote',
'type' => 'select',
'prompt' => '¿En qué lote?',
'source_endpoint_key' => 'lotes_finca_dn',
'value_field' => 'id',
'label_field' => 'label',
],
[
// Cosecha no lleva producto. La dosis sale de la
// fertilizacion autorizada para ese lote, por eso se
// pregunta despues del lote y no antes.
'key' => 'producto_id',
'label' => 'Producto',
'type' => 'select',
'prompt' => '¿Qué producto se aplicó?',
'source_endpoint_key' => 'productos_fertilizacion_dn',
'value_field' => 'id',
'label_field' => 'nombre',
'skip_if' => ['field' => 'tipo', 'not_in' => ['polinizacion', 'fertilizacion']],
'empty_text' => '⚠️ Ese lote no tiene fertilización autorizada pendiente.',
],
[
'key' => 'empleados',
'label' => 'Trabajadores',
'type' => 'multi_select',
'prompt' => '¿Quiénes la hicieron? (puedes marcar varios)',
'source_endpoint_key' => 'empleados_labor_dn',
'value_field' => 'id',
'label_field' => 'nombre',
'from_phone' => 'tercero_id',
],
[
'key' => 'cantidad',
'label' => 'Cantidad',
'type' => 'text',
'prompt' => '¿Qué cantidad?',
],
[
'key' => 'fase_polinizacion',
'label' => 'Pase',
'type' => 'text',
'prompt' => '¿Qué pase de polinización?',
'skip_if' => ['field' => 'tipo', 'equals' => 'polinizacion'],
],
],
],
// Upload // Upload
'enviar_informacion' => ['type' => 'menu', 'menu' => 'submenu_subir_ciclos'], 'enviar_informacion' => ['type' => 'menu', 'menu' => 'submenu_subir_ciclos', 'nlu_skip' => true, 'back' => 'show_main_menu'],
'subir_ciclo_cosecha' => ['type' => 'function', 'function' => 'upload_ciclo', 'params' => ['ciclo' => 'cosecha']],
// Alias globales // Alias globales (nlu_skip: son alias de navegación)
'show_main_menu' => ['type' => 'menu', 'menu' => 'show_menu_cat2'], 'show_main_menu' => ['type' => 'menu', 'menu' => 'show_menu_cat2', 'nlu_skip' => true],
'descargar_informes' => ['type' => 'menu', 'menu' => 'show_menu_cat2'], 'descargar_informes' => ['type' => 'menu', 'menu' => 'show_menu_cat2', 'nlu_skip' => true, 'back' => 'show_main_menu'],
], ],
// ── Per-type config ───────────────────────────────────────────────────── // ── Per-type config ─────────────────────────────────────────────────────
@@ -343,7 +817,12 @@ $configJson = [
[ [
'title' => 'Opciones', 'title' => 'Opciones',
'rows' => [ 'rows' => [
['id' => 'subir_ciclo_cosecha', 'title' => '🌾 Subir cosecha', 'description' => 'Enviar ciclo de cosecha'], ['id' => 'registrar_ausentismo', 'modulo' => 'ausentismos', 'title' => '👥 Ausentismo', 'description' => 'Registrar falta, incapacidad o permiso'],
['id' => 'registrar_pluviometria','modulo' => 'pluviometria', 'title' => '🌧️ Pluviometría', 'description' => 'Milímetros de lluvia por finca'],
['id' => 'registrar_ciclo', 'modulo' => 'ciclos', 'title' => '🔄 Ciclos', 'description' => 'Abrir o cerrar lotes'],
['id' => 'registrar_mantenimiento','modulo' => 'mantenimiento', 'title' => '🔧 Mantenimiento', 'description' => 'Avance sobre lotes pendientes'],
['id' => 'registrar_labor', 'modulo' => 'labores', 'title' => '💪 Labor diaria', 'description' => 'Tractorista, transporte, horas extra'],
['id' => 'registrar_labor_validada','modulo' => 'labores', 'title' => '🌾 Cosecha y otros','description' => 'Cosecha, polinización, fertilización'],
], ],
], ],
], ],
@@ -352,7 +831,6 @@ $configJson = [
'flows' => [ 'flows' => [
'show_menu_cat1' => ['type' => 'menu', 'menu' => 'show_menu_cat1'], 'show_menu_cat1' => ['type' => 'menu', 'menu' => 'show_menu_cat1'],
'show_main_menu' => ['type' => 'menu', 'menu' => 'show_menu_cat1'], 'show_main_menu' => ['type' => 'menu', 'menu' => 'show_menu_cat1'],
'subir_ciclo_cosecha' => ['type' => 'function', 'function' => 'upload_ciclo', 'params' => ['ciclo' => 'cosecha']],
], ],
], ],
@@ -360,7 +838,7 @@ $configJson = [
'2' => [ '2' => [
'greeting_flow' => 'ask_finca', 'greeting_flow' => 'ask_finca',
'fallback_flow' => 'show_menu_cat2', 'fallback_flow' => 'show_menu_cat2',
'greeting' => null, 'greeting' => '',
'fallback' => null, 'fallback' => null,
'commands' => ['menu' => 'show_menu_cat2'], 'commands' => ['menu' => 'show_menu_cat2'],
'menus' => [ 'menus' => [
@@ -374,9 +852,9 @@ $configJson = [
[ [
'title' => 'Informes', 'title' => 'Informes',
'rows' => [ 'rows' => [
['id' => 'submenu_produccion', 'title' => '🌿 Producción', 'description' => 'Informes de producción'], ['id' => 'submenu_produccion', 'modulo' => 'produccion', 'title' => '🌿 Producción', 'description' => 'Informes de producción'],
['id' => 'submenu_ciclos', 'title' => '🔄 Ciclos', 'description' => 'Ciclos de campo'], ['id' => 'submenu_ciclos', 'modulo' => 'ciclos', 'title' => '🔄 Ciclos', 'description' => 'Ciclos de campo'],
['id' => 'submenu_ausentismos', 'title' => '👥 Ausentismos', 'description' => 'Faltas y novedades'], ['id' => 'submenu_ausentismos', 'modulo' => 'ausentismos', 'title' => '👥 Ausentismos', 'description' => 'Faltas y novedades'],
], ],
], ],
], ],
@@ -385,7 +863,7 @@ $configJson = [
'flows' => [ 'flows' => [
'show_menu_cat2' => ['type' => 'menu', 'menu' => 'show_menu_cat2'], 'show_menu_cat2' => ['type' => 'menu', 'menu' => 'show_menu_cat2'],
'show_main_menu' => ['type' => 'menu', 'menu' => 'show_menu_cat2'], 'show_main_menu' => ['type' => 'menu', 'menu' => 'show_menu_cat2'],
'descargar_informes' => ['type' => 'menu', 'menu' => 'show_menu_cat2'], 'descargar_informes' => ['type' => 'menu', 'menu' => 'show_menu_cat2', 'back' => 'show_main_menu'],
], ],
], ],
@@ -393,14 +871,28 @@ $configJson = [
'3' => [ '3' => [
'greeting_flow' => 'ask_finca', 'greeting_flow' => 'ask_finca',
'fallback_flow' => 'show_main_menu', 'fallback_flow' => 'show_main_menu',
'greeting' => null, 'greeting' => '',
'fallback' => null, 'fallback' => null,
'commands' => ['menu' => 'show_main_menu'], 'commands' => ['menu' => 'show_main_menu'],
'menus' => [], 'menus' => [
// Solo cat 3 puede consultar Y registrar, asi que "ausentismo" a
// secas es ambiguo unicamente para el. Cat 1 y cat 2 lo resuelve
// el filtro de permisos: cada uno ve una sola opcion.
'menu_ausentismo_que' => ['type' => 'button', 'body' => '¿Qué necesitas con los ausentismos?', 'buttons' => [
['id' => 'submenu_ausentismos', 'title' => '📊 Consultar'],
['id' => 'registrar_ausentismo', 'title' => '📝 Registrar'],
]],
],
'flows' => [ 'flows' => [
'ausentismo' => [
'type' => 'menu',
'menu' => 'menu_ausentismo_que',
'back' => 'show_main_menu',
'nlu_description' => 'Ausentismos cuando no dice si quiere consultarlos o registrar uno',
],
'show_main_menu' => ['type' => 'menu', 'menu' => 'menu_modo_trabajo'], 'show_main_menu' => ['type' => 'menu', 'menu' => 'menu_modo_trabajo'],
'descargar_informes' => ['type' => 'menu', 'menu' => 'show_menu_cat2'], 'descargar_informes' => ['type' => 'menu', 'menu' => 'show_menu_cat2', 'back' => 'show_main_menu'],
'enviar_informacion' => ['type' => 'menu', 'menu' => 'submenu_subir_ciclos'], 'enviar_informacion' => ['type' => 'menu', 'menu' => 'submenu_subir_ciclos', 'back' => 'show_main_menu'],
], ],
], ],
], ],
@@ -422,7 +914,6 @@ $endpoints = [
['key' => 'plagas_top_texto_bot_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=plagas_top_texto_bot&finca_id={finca_id}'], ['key' => 'plagas_top_texto_bot_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=plagas_top_texto_bot&finca_id={finca_id}'],
['key' => 'palm_top_texto_bot_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=palm_top_texto_bot&finca_id={finca_id}'], ['key' => 'palm_top_texto_bot_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=palm_top_texto_bot&finca_id={finca_id}'],
['key' => 'tratamiento_top_texto_bot_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=tratamiento_top_texto_bot&finca_id={finca_id}'], ['key' => 'tratamiento_top_texto_bot_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=tratamiento_top_texto_bot&finca_id={finca_id}'],
['key' => 'mantenimiento_top_texto_bot_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=mantenimiento_top_texto_bot&grupo=0&finca_id={finca_id}'],
['key' => 'cosecha_historico_bot_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=cosecha_historico_bot&finca_id={finca_id}'], ['key' => 'cosecha_historico_bot_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=cosecha_historico_bot&finca_id={finca_id}'],
['key' => 'polinizacion_historico_bot_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=polinizacion_historico_bot&finca_id={finca_id}'], ['key' => 'polinizacion_historico_bot_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=polinizacion_historico_bot&finca_id={finca_id}'],
['key' => 'censo_historico_bot_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=censo_historico_bot&finca_id={finca_id}'], ['key' => 'censo_historico_bot_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=censo_historico_bot&finca_id={finca_id}'],
@@ -431,6 +922,36 @@ $endpoints = [
['key' => 'tratamiento_historico_bot_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=tratamiento_historico_bot&finca_id={finca_id}'], ['key' => 'tratamiento_historico_bot_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=tratamiento_historico_bot&finca_id={finca_id}'],
['key' => 'fincas_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=fincas'], ['key' => 'fincas_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=fincas'],
['key' => 'numeros_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=whatsapp_numeros&estado=1'], ['key' => 'numeros_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=whatsapp_numeros&estado=1'],
// ── Carga de datos (POST) — Fase 1: Ausentismo ──────────────────────────
// El lookup agrega &filtro=<texto> a esta URL; estado=1 restringe a activos.
['key' => 'empleados_buscar_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=empleados&estado=1&items=10'],
['key' => 'novedades_grupos_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=novedades_ausentismo_dn&grupos=1'],
['key' => 'novedades_ausentismo_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=novedades_ausentismo_dn&grupo_novedad={grupo_novedad}'],
['key' => 'ausentismos_up', 'dir' => 'upload', 'url' => $BASE . '?peticion=ausentismos_up'],
['key' => 'pluvio_upload', 'dir' => 'upload', 'url' => $BASE . '?peticion=pluvio_upload'],
// ── Carga de ciclos ─────────────────────────────────────────────────────
// {ciclo} y {accion} se resuelven con lo que el usuario ya eligió, así los
// seis tipos y las dos acciones comparten estas tres entradas.
// Los 10 mas atrasados; con todos=1, el listado completo para "Otro lote"
['key' => 'lotes_apertura_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=lotes_apertura_dn&ciclo={ciclo}&finca_id={finca_id}'],
['key' => 'lotes_cierre_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=lotes_abiertos_dn&ciclo={ciclo}&finca_id={finca_id}'],
['key' => 'lotes_apertura_todos_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=lotes_apertura_dn&ciclo={ciclo}&finca_id={finca_id}&todos=1'],
['key' => 'lotes_cierre_todos_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=lotes_abiertos_dn&ciclo={ciclo}&finca_id={finca_id}&todos=1'],
['key' => 'ciclos_up', 'dir' => 'upload', 'url' => $BASE . '?peticion=ciclos_{ciclo}_up'],
// Mantenimiento: lotes con cantidad pendiente de asignar, no apertura/cierre
// Labores diarias — fase 2: las que no arrastran inventario ni autorizacion
['key' => 'novedades_labor_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=novedades_labor_dn&fase=2'],
['key' => 'lotes_finca_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=lotes_x_finca&finca_id={finca_id}'],
['key' => 'empleados_labor_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=empleados&estado=1&items=200'],
['key' => 'labores_up', 'dir' => 'upload', 'url' => $BASE . '?peticion=labores_up'],
// Fase 3: las que arrastran inventario o autorizacion previa
['key' => 'novedades_labor3_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=novedades_labor_dn&fase=3'],
['key' => 'productos_fertilizacion_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=productos_fertilizacion_dn&lote_id={lote_id}'],
['key' => 'novedades_mant_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=novedades_labor_dn&fase=3&grupo={grupo_id}'],
['key' => 'lotes_mantenimiento_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=lotes_mantenimiento_dn&grupo={grupo_id}&finca_id={finca_id}'],
['key' => 'lotes_mantenimiento_todos_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=lotes_mantenimiento_dn&grupo={grupo_id}&finca_id={finca_id}&todos=1'],
// Todos los lotes (PDF) por ciclo // Todos los lotes (PDF) por ciclo
['key' => 'cosecha_todos_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=ciclos_cosecha_dn&finca_id={finca_id}'], ['key' => 'cosecha_todos_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=ciclos_cosecha_dn&finca_id={finca_id}'],
['key' => 'polinizacion_todos_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=ciclos_polinizacion_dn&finca_id={finca_id}'], ['key' => 'polinizacion_todos_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=ciclos_polinizacion_dn&finca_id={finca_id}'],
@@ -440,9 +961,10 @@ $endpoints = [
['key' => 'tratamiento_todos_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=ciclos_tratamientos_dn&finca_id={finca_id}'], ['key' => 'tratamiento_todos_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=ciclos_tratamientos_dn&finca_id={finca_id}'],
['key' => 'mantenimiento_grupos_top3_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=grupos_mantenimiento&limit=3'], ['key' => 'mantenimiento_grupos_top3_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=grupos_mantenimiento&limit=3'],
['key' => 'mantenimiento_grupos_otros_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=grupos_mantenimiento&limit=10&offset=3'], ['key' => 'mantenimiento_grupos_otros_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=grupos_mantenimiento&limit=10&offset=3'],
['key' => 'mantenimiento_grupos_all_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=grupos_mantenimiento&limit=200'],
['key' => 'mantenimiento_top_bot_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=mantenimiento_dn&top_lotes=10&grupo={grupo_id}&finca_id={finca_id}'], ['key' => 'mantenimiento_top_bot_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=mantenimiento_dn&top_lotes=10&grupo={grupo_id}&finca_id={finca_id}'],
['key' => 'mantenimiento_top_texto_bot_dn','dir' => 'download', 'url' => $BASE . '?peticion=mantenimiento_top_texto_bot&grupo={grupo_id}&finca_id={finca_id}'],
['key' => 'mantenimiento_todos_bot_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=mantenimiento_dn&grupo={grupo_id}&finca_id={finca_id}'], ['key' => 'mantenimiento_todos_bot_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=mantenimiento_dn&grupo={grupo_id}&finca_id={finca_id}'],
['key' => 'mantenimiento_todos_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=mantenimiento_dn&grupo={grupo_id}&finca_id={finca_id}'],
]; ];
// ─── Companies ───────────────────────────────────────────────────────────────── // ─── Companies ─────────────────────────────────────────────────────────────────
@@ -454,7 +976,7 @@ $companies = [
'display_name' => 'Rosa Blanca', 'display_name' => 'Rosa Blanca',
'phone_number_id' => '1168511569675820', 'phone_number_id' => '1168511569675820',
'display_phone' => '', 'display_phone' => '',
'api_base_url' => 'https://graph.facebook.com/v22.0', 'api_base_url' => 'https://app.palmas360.com/rosablanca', // ERP, no el Graph API (WhatsAppSender lo tiene hardcodeado)
'api_key' => 'EAAqbNQQekwUBRm8ZAAfCvXZAsxcM1BTkiHjVJYeOfXYCg5t4qYtBL7ap2IdHGa1iXclZBO47gZBshYZBAlQRuUvgSib2Owh7gPnAWfNlHXZAT61ccElXoihuXuEuy0qLiB95tDhLMWPqoKYuDwAHCU78RQxoi1nzz1OsOY40EK7U2tKZBVuZABQZCJctZBrLK7rRiC7AZDZD', 'api_key' => 'EAAqbNQQekwUBRm8ZAAfCvXZAsxcM1BTkiHjVJYeOfXYCg5t4qYtBL7ap2IdHGa1iXclZBO47gZBshYZBAlQRuUvgSib2Owh7gPnAWfNlHXZAT61ccElXoihuXuEuy0qLiB95tDhLMWPqoKYuDwAHCU78RQxoi1nzz1OsOY40EK7U2tKZBVuZABQZCJctZBrLK7rRiC7AZDZD',
'bot_type' => 'normal', 'bot_type' => 'normal',
'requires_approval' => 0, 'requires_approval' => 0,
@@ -466,7 +988,7 @@ $companies = [
'display_name' => 'Palmas360 Test', 'display_name' => 'Palmas360 Test',
'phone_number_id' => '1168511569675820', 'phone_number_id' => '1168511569675820',
'display_phone' => '', 'display_phone' => '',
'api_base_url' => 'https://graph.facebook.com/v22.0', 'api_base_url' => 'https://app.palmas360.com/rosablanca', // ERP, no el Graph API (WhatsAppSender lo tiene hardcodeado)
'api_key' => 'EAAqbNQQekwUBRm8ZAAfCvXZAsxcM1BTkiHjVJYeOfXYCg5t4qYtBL7ap2IdHGa1iXclZBO47gZBshYZBAlQRuUvgSib2Owh7gPnAWfNlHXZAT61ccElXoihuXuEuy0qLiB95tDhLMWPqoKYuDwAHCU78RQxoi1nzz1OsOY40EK7U2tKZBVuZABQZCJctZBrLK7rRiC7AZDZD', 'api_key' => 'EAAqbNQQekwUBRm8ZAAfCvXZAsxcM1BTkiHjVJYeOfXYCg5t4qYtBL7ap2IdHGa1iXclZBO47gZBshYZBAlQRuUvgSib2Owh7gPnAWfNlHXZAT61ccElXoihuXuEuy0qLiB95tDhLMWPqoKYuDwAHCU78RQxoi1nzz1OsOY40EK7U2tKZBVuZABQZCJctZBrLK7rRiC7AZDZD',
'bot_type' => 'normal', 'bot_type' => 'normal',
'requires_approval' => 0, 'requires_approval' => 0,
@@ -474,10 +996,29 @@ $companies = [
], ],
]; ];
$configJsonStr = json_encode($configJson, JSON_UNESCAPED_UNICODE);
// ─── Insert / update companies ──────────────────────────────────────────────── // ─── Insert / update companies ────────────────────────────────────────────────
/**
* El seed solo es dueño de la estructura del bot (menús, flujos, comandos...).
* Todo lo demás que viva en config_json —modelos de IA, temperatura, prompt,
* whisper— lo configura el admin y debe sobrevivir a cada corrida.
*/
function configPreservando(PDO $db, int $companyId, array $configSeed): string
{
$stmt = $db->prepare("SELECT config_json FROM companies WHERE id = ? LIMIT 1");
$stmt->execute([$companyId]);
$actual = json_decode((string)$stmt->fetchColumn(), true);
if (is_array($actual)) {
$conservadas = array_diff_key($actual, $configSeed);
if ($conservadas) {
$configSeed = array_merge($configSeed, $conservadas);
echo " ↳ conservadas: " . implode(', ', array_keys($conservadas)) . "\n";
}
}
return json_encode($configSeed, JSON_UNESCAPED_UNICODE);
}
$stmtCo = $db->prepare(" $stmtCo = $db->prepare("
INSERT INTO companies (id, name, display_name, phone_number_id, display_phone, api_base_url, api_key, bot_type, requires_approval, is_active, config_json) INSERT INTO companies (id, name, display_name, phone_number_id, display_phone, api_base_url, api_key, bot_type, requires_approval, is_active, config_json)
VALUES (:id, :name, :display_name, :phone_number_id, :display_phone, :api_base_url, :api_key, :bot_type, :requires_approval, :is_active, :config_json) VALUES (:id, :name, :display_name, :phone_number_id, :display_phone, :api_base_url, :api_key, :bot_type, :requires_approval, :is_active, :config_json)
@@ -485,13 +1026,14 @@ $stmtCo = $db->prepare("
display_name = VALUES(display_name), display_name = VALUES(display_name),
phone_number_id = VALUES(phone_number_id), phone_number_id = VALUES(phone_number_id),
api_base_url = VALUES(api_base_url), api_base_url = VALUES(api_base_url),
api_key = VALUES(api_key),
bot_type = VALUES(bot_type), bot_type = VALUES(bot_type),
is_active = VALUES(is_active), is_active = VALUES(is_active),
config_json = VALUES(config_json) config_json = VALUES(config_json)
"); ");
foreach ($companies as $co) { foreach ($companies as $co) {
echo "✔ Company {$co['id']} ({$co['display_name']})\n";
$cfg = configPreservando($db, (int)$co['id'], $configJson);
$stmtCo->execute([ $stmtCo->execute([
':id' => $co['id'], ':id' => $co['id'],
':name' => $co['name'], ':name' => $co['name'],
@@ -503,9 +1045,8 @@ foreach ($companies as $co) {
':bot_type' => $co['bot_type'], ':bot_type' => $co['bot_type'],
':requires_approval'=> $co['requires_approval'], ':requires_approval'=> $co['requires_approval'],
':is_active' => $co['is_active'], ':is_active' => $co['is_active'],
':config_json' => $configJsonStr, ':config_json' => $cfg,
]); ]);
echo "✔ Company {$co['id']} ({$co['display_name']}) OK\n";
} }
// ─── Insert / update endpoints ──────────────────────────────────────────────── // ─── Insert / update endpoints ────────────────────────────────────────────────
+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";