diff --git a/services/NormalBot.php b/services/NormalBot.php index be0a8d3..94d0582 100644 --- a/services/NormalBot.php +++ b/services/NormalBot.php @@ -50,7 +50,7 @@ class NormalBot } // Commands that match a menu if (isset($menus[$action])) { - return self::buildMenuResponse($menus[$action], $context['from'], $company); + return self::buildMenuResponse($menus[$action], $context['from'], $company, $ctxId); } return null; } @@ -91,14 +91,14 @@ class NormalBot $welcomeMenuKey = $config['welcome_menu'] ?? null; if ($welcomeMenuKey && $currentNode === null && isset($menus[$welcomeMenuKey])) { ConversationContext::updateNode($ctxId, '__greeted'); - return self::buildMenuResponse($menus[$welcomeMenuKey], $context['from'], $company); + return self::buildMenuResponse($menus[$welcomeMenuKey], $context['from'], $company, $ctxId); } // 4b. Per-type greeting menu — only for brand-new sessions (currentNode === null) $greetingMenuKey = $perType['greeting_menu'] ?? null; if ($greetingMenuKey !== null && $currentNode === null && isset($menus[$greetingMenuKey])) { ConversationContext::updateNode($ctxId, '__greeted'); // sentinel: evita re-envío del menú - return self::buildMenuResponse($menus[$greetingMenuKey], $context['from'], $company); + return self::buildMenuResponse($menus[$greetingMenuKey], $context['from'], $company, $ctxId); } // 5. Greeting text @@ -127,19 +127,22 @@ class NormalBot ConversationContext::updateMetadata($ctxId, $m); $key = 'ask_finca'; } - // Inyectar entities para auto-select en dynamic_list + // Inyectar entities para auto-select en dynamic_list. + // Se guarda el mapa completo: los formularios las buscan por nombre + // de campo, y aplanarlas dejaba ese pre-llenado sin funcionar. + $m = ConversationContext::getMetadata($ctxId); + unset($m['__nlu_entities']); // descartar sobras de un ruteo anterior if (!empty($route['entities'])) { - $m = ConversationContext::getMetadata($ctxId); - $m['__nlu_entities'] = array_values($route['entities']); - ConversationContext::updateMetadata($ctxId, $m); + $m['__nlu_entities'] = $route['entities']; } + ConversationContext::updateMetadata($ctxId, $m); if (isset($flows[$key])) { ConversationContext::updateNode($ctxId, $key); return self::handleFlow($flows[$key], $context, $company, $ctxId, $menus, $key); } if (isset($menus[$key])) { ConversationContext::updateNode($ctxId, $key); - return self::buildMenuResponse($menus[$key], $context['from'], $company); + return self::buildMenuResponse($menus[$key], $context['from'], $company, $ctxId); } } if ($route['action'] === 'chat' && ($route['text'] ?? '') !== '') { @@ -166,7 +169,7 @@ class NormalBot // 8. Greeting menu as fallback (texto no reconocido después de estar en __greeted) if (!$suppressFallback && $greetingMenuKey !== null && isset($menus[$greetingMenuKey])) { ConversationContext::updateNode($ctxId, '__greeted'); - return self::buildMenuResponse($menus[$greetingMenuKey], $context['from'], $company); + return self::buildMenuResponse($menus[$greetingMenuKey], $context['from'], $company, $ctxId); } // 9. Static fallback text @@ -227,9 +230,12 @@ class NormalBot return null; } - $formData = $meta[$group] ?? []; - $formData[$key] = $resolved; - $meta[$group] = $formData; + // Se recupera la etiqueta del id elegido para poder mostrarla después + $etiqueta = ''; + foreach ($labelMap as $label => $id) { + if ((string)$id === $resolved) { $etiqueta = (string)$label; break; } + } + $meta = self::guardarSeleccion($meta, $group, $key, $resolved, $etiqueta); unset($meta['collecting']); ConversationContext::updateMetadata($ctxId, $meta); @@ -239,7 +245,7 @@ class NormalBot return self::handleFlow($flows[$nextNode], $context, $company, $ctxId, $menus, $nextNode); } if (isset($menus[$nextNode])) { - return self::buildMenuResponse($menus[$nextNode], $context['from'], $company); + return self::buildMenuResponse($menus[$nextNode], $context['from'], $company, $ctxId); } } else { ConversationContext::updateNode($ctxId, null); @@ -301,39 +307,53 @@ class NormalBot // Auto-select cuando hay exactamente 1 opción — no mostrar lista if (count($items) === 1) { - $formData = $meta[$group] ?? []; - $formData[$key] = (string)($items[0][$valueField] ?? ''); - $meta[$group] = $formData; + $meta = self::guardarSeleccion($meta, $group, $key, (string)($items[0][$valueField] ?? ''), (string)($items[0][$labelField] ?? '')); ConversationContext::updateMetadata($ctxId, $meta); return self::advanceAfterSelect($nextNode, $context, $company, $ctxId); } - // Si el NLU extrajo entities, intentar auto-seleccionar sin mostrar lista - $entities = $meta['__nlu_entities'] ?? []; + // Si el NLU extrajo entities, intentar auto-seleccionar sin mostrar lista. + // entity_key acota la búsqueda al campo propio: sin eso, una finca llamada + // "reposo" se compara contra grupos de mantenimiento y se consume al pedo. + $entities = $meta['__nlu_entities'] ?? []; + $entityKey = $flow['entity_key'] ?? ''; if (!empty($entities)) { - $matchedId = null; - foreach ($entities as $ev) { + $propias = []; + if ($entityKey !== '') { + foreach ($entities as $ek => $ev) { + if (self::normalize((string)$ek) === self::normalize($entityKey)) $propias[$ek] = $ev; + } + } else { + $propias = $entities; // flow sin entity_key: comportamiento histórico + } + + $matchedId = $matchedLabel = $matchedEk = null; + foreach ($propias as $ek => $ev) { $evNorm = self::normalize((string)$ev); if ($evNorm === '') continue; foreach ($labelMap as $label => $id) { $lNorm = self::normalize($label); if ($lNorm === '') continue; if ($lNorm === $evNorm || str_contains($evNorm, $lNorm) || str_contains($lNorm, $evNorm)) { - $matchedId = $id; + $matchedId = $id; $matchedLabel = $label; $matchedEk = $ek; break 2; } } } + if ($matchedId !== null) { - $formData = $meta[$group] ?? []; - $formData[$key] = $matchedId; - $meta[$group] = $formData; + $meta = self::guardarSeleccion($meta, $group, $key, $matchedId, (string)$matchedLabel); + unset($meta['__nlu_entities'][$matchedEk]); // solo la consumida + if (empty($meta['__nlu_entities'])) unset($meta['__nlu_entities']); ConversationContext::updateMetadata($ctxId, $meta); return self::advanceAfterSelect($nextNode, $context, $company, $ctxId); } - // Sin match: se descartan las entities y se muestra la lista normal - unset($meta['__nlu_entities']); - ConversationContext::updateMetadata($ctxId, $meta); + + // Sin match: las entities de otro campo siguen sirviendo más adelante + if ($entityKey === '') { + unset($meta['__nlu_entities']); + ConversationContext::updateMetadata($ctxId, $meta); + } } // Opciones fijas que redirigen a otro flow en vez de guardar el valor @@ -363,14 +383,30 @@ class NormalBot return self::buildDynamicListResponse($items, $flow, $context['from'], $company); } + /** + * Guarda la opción elegida con su etiqueta, para poder mostrarle al usuario + * en qué finca/grupo está parado sin tener que volver a consultar la API. + */ + private static function guardarSeleccion(array $meta, string $group, string $key, string $id, string $label): array + { + if ($group === '') return $meta; + $datos = $meta[$group] ?? []; + $datos[$key] = $id; + if ($label !== '') $datos[$group . '_label'] = $label; + $meta[$group] = $datos; + return $meta; + } + /** * Continúa tras resolver una selección: consume la intención pendiente y * las entities, y ejecuta el destino. */ private static function advanceAfterSelect(?string $nextNode, array $context, array $company, int $ctxId): ?array { + // Las entities que no consumió esta lista pueden ser de la siguiente + // ("finca reposo, grupo plateo" resuelve las dos), así que no se borran acá. $meta = ConversationContext::getMetadata($ctxId); - unset($meta['__after_select'], $meta['__nlu_entities']); + unset($meta['__after_select']); ConversationContext::updateMetadata($ctxId, $meta); if ($nextNode !== null) { @@ -380,7 +416,7 @@ class NormalBot return self::handleFlow($flows[$nextNode], $context, $company, $ctxId, $menus, $nextNode); } if (isset($menus[$nextNode])) { - return self::buildMenuResponse($menus[$nextNode], $context['from'], $company); + return self::buildMenuResponse($menus[$nextNode], $context['from'], $company, $ctxId); } } @@ -659,7 +695,7 @@ class NormalBot { $fe = $meta['__foreach']; $endpointKey = $fe['endpoint_key'] ?? ''; - $nav = "\n\nEscribe *menu* para ver más opciones."; + $nav = "\n\nEscribe *menú* para volver al inicio, *atrás* para subir un nivel o *salir* para terminar."; // Build POST body: array of {id, valor} objects $body = []; @@ -1039,7 +1075,7 @@ class NormalBot { $cap = $meta['__cap']; $endpointKey = $cap['endpoint_key'] ?? ''; - $nav = "\n\nEscribe *menu* para ver más opciones."; + $nav = "\n\nEscribe *menú* para volver al inicio, *atrás* para subir un nivel o *salir* para terminar."; unset($meta['__cap']); ConversationContext::updateMetadata($ctxId, $meta); @@ -1187,7 +1223,7 @@ class NormalBot return match ($type) { 'text' => self::sendText($flow['message'] ?? '', $context['from'], $company), 'image' => self::sendImage($flow['media_id'] ?? '', $flow['caption'] ?? null, $context['from'], $company), - 'menu' => self::buildMenuResponse(self::resolveMenu($flow['menu'] ?? [], $company, $menus), $context['from'], $company), + 'menu' => self::buildMenuResponse(self::resolveMenu($flow['menu'] ?? [], $company, $menus), $context['from'], $company, $ctxId), 'function' => self::executeFunction($flow['function'] ?? '', $flow['params'] ?? [], $context, $company, $ctxId), 'collect_input' => self::handleCollectInput($flow, $context, $company, $ctxId), 'dynamic_list' => self::handleDynamicList($flow, $context, $company, $ctxId), @@ -1269,7 +1305,7 @@ class NormalBot // ── Handles sequential collection of URL vars before calling endpoint ─── private static function handleApiVarsInput(array $meta, string $input, array $context, array $company, int $ctxId, array $flows, array $menus): ?array { - $nav = "\n\nEscribe *menu* para ver opciones o *salir* para terminar."; + $nav = "\n\nEscribe *menú* para volver al inicio, *atrás* para subir un nivel o *salir* para terminar."; $state = $meta['__api_vars']; $pending = $state['pending'] ?? []; @@ -1306,7 +1342,7 @@ class NormalBot private static function executeApiReport(array $params, array $context, array $company, int $ctxId): ?array { $endpointKey = $params['endpoint_key'] ?? ''; - $nav = "\n\nEscribe *menu* para ver más opciones o *salir* para terminar."; + $nav = "\n\nEscribe *menú* para volver al inicio, *atrás* para subir un nivel o *salir* para terminar."; if ($endpointKey === '') { self::preservingReset($ctxId, ConversationContext::getMetadata($ctxId)); @@ -1634,7 +1670,7 @@ class NormalBot } if (isset($menus[$resolvedInput])) { ConversationContext::updateNode($ctxId, $resolvedInput); - return self::buildMenuResponse($menus[$resolvedInput], $context['from'], $company); + return self::buildMenuResponse($menus[$resolvedInput], $context['from'], $company, $ctxId); } return null; @@ -1642,8 +1678,33 @@ class NormalBot // ── WhatsApp senders ───────────────────────────────────────────────────── - private static function buildMenuResponse(array $menu, string $to, array $company): ?array + /** + * Resuelve {finca} en los textos del menú y deja la finca activa en el footer, + * para que el usuario sepa siempre sobre qué está consultando. + */ + private static function aplicarVarsMenu(array $menu, ?int $ctxId, array $company): array { + if ($ctxId === null) return $menu; + + $meta = ConversationContext::getMetadata($ctxId); + $finca = (string)($meta['finca']['finca_label'] ?? ''); + + foreach (['header', 'body', 'footer'] as $campo) { + if (isset($menu[$campo]) && is_string($menu[$campo])) { + $menu[$campo] = str_replace('{finca}', $finca, $menu[$campo]); + } + } + + // Sin finca (cat 1 solo reporta) queda el footer de siempre + if ($finca !== '' && !str_contains((string)($menu['footer'] ?? ''), $finca)) { + $menu['footer'] = '📍 ' . $finca; + } + return $menu; + } + + private static function buildMenuResponse(array $menu, string $to, array $company, ?int $ctxId = null): ?array + { + $menu = self::aplicarVarsMenu($menu, $ctxId, $company); $menuType = $menu['type'] ?? 'list'; if ($menuType === 'list') { @@ -1696,6 +1757,10 @@ class NormalBot 'body' => ['text' => mb_substr($menu['body'] ?? 'Selecciona:', 0, 1024)], 'action' => ['buttons' => $buttons], ]; + // Los submenús de ciclos son de botones: sin footer no se veía la finca + if (($menu['footer'] ?? '') !== '') { + $interactive['footer'] = ['text' => mb_substr($menu['footer'], 0, 60)]; + } return self::enqueueInteractive($to, $interactive, $company); } diff --git a/setup/seed_palmas.php b/setup/seed_palmas.php index 76d12fe..02fd794 100644 --- a/setup/seed_palmas.php +++ b/setup/seed_palmas.php @@ -213,6 +213,7 @@ $configJson = [ 'section_title' => 'Fincas disponibles', 'meta_group' => 'finca', 'meta_key' => 'finca_id', + 'entity_key' => 'finca', 'next_node' => 'show_main_menu', 'skip_if_set' => true, 'nlu_description' => 'Seleccionar o cambiar la finca de trabajo', @@ -322,6 +323,7 @@ $configJson = [ 'section_title' => 'Grupos frecuentes', 'meta_group' => 'grupo_mant', 'meta_key' => 'grupo_id', + 'entity_key' => 'grupo', 'next_node' => 'submenu_ciclo_mantenimiento', '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', diff --git a/setup/test_navegacion.php b/setup/test_navegacion.php index e950918..5a0b8ee 100644 --- a/setup/test_navegacion.php +++ b/setup/test_navegacion.php @@ -145,6 +145,69 @@ check('parado en un menú el NLU atiende', 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 "\nask_finca\n"; $af = $flows['ask_finca']; check('no repregunta si ya hay finca', $af['skip_if_set'] ?? false, true);