Files
bot_palmas/setup/test_navegacion.php
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

579 lines
26 KiB
PHP

<?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 "\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";