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>
382 lines
17 KiB
PHP
382 lines
17 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);
|
|
check('cat 1 sí ofrece subir', str_contains($cat1, 'subir ciclo'), 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 "\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";
|