$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'); check('pide fecha, lote y cantidad', array_keys($mf), ['fecha', 'lote_id', '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";