From 1f7d33b40de065296acdfe747c0573080039ac47 Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:27:05 -0500 Subject: [PATCH] test: flujos ejecutados de punta a punta contra un ERP falso MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- services/NormalBot.php | 2 + setup/seed_palmas.php | 22 ++++ setup/test_navegacion.php | 4 +- setup/tests/bootstrap.php | 199 ++++++++++++++++++++++++++++++++ setup/tests/fixtures_api.php | 97 ++++++++++++++++ setup/tests/run.sh | 10 ++ setup/tests/test_flujos.php | 218 +++++++++++++++++++++++++++++++++++ 7 files changed, 551 insertions(+), 1 deletion(-) create mode 100644 setup/tests/bootstrap.php create mode 100644 setup/tests/fixtures_api.php create mode 100755 setup/tests/run.sh create mode 100644 setup/tests/test_flujos.php diff --git a/services/NormalBot.php b/services/NormalBot.php index 55d4547..0b7d343 100644 --- a/services/NormalBot.php +++ b/services/NormalBot.php @@ -858,6 +858,8 @@ class NormalBot { if ($plantilla === '' || !str_contains($plantilla, '{')) return $plantilla; foreach ($recolectado as $k => $v) { + // multi_select guarda arreglos; no son sustituibles en una URL + if (is_array($v)) continue; $plantilla = str_replace('{' . $k . '}', (string)$v, $plantilla); } return $plantilla; diff --git a/setup/seed_palmas.php b/setup/seed_palmas.php index b2b2e9f..4841bde 100644 --- a/setup/seed_palmas.php +++ b/setup/seed_palmas.php @@ -585,6 +585,27 @@ $configJson = [ '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', @@ -903,6 +924,7 @@ $endpoints = [ // 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 diff --git a/setup/test_navegacion.php b/setup/test_navegacion.php index 459dde9..799a4b7 100644 --- a/setup/test_navegacion.php +++ b/setup/test_navegacion.php @@ -547,7 +547,9 @@ 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']); +// 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); diff --git a/setup/tests/bootstrap.php b/setup/tests/bootstrap.php new file mode 100644 index 0000000..0d206d4 --- /dev/null +++ b/setup/tests/bootstrap.php @@ -0,0 +1,199 @@ + SQLite en memoria, 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(): SQLite con los endpoints reales del seed ─────────────────────────── +function db(): PDO +{ + static $pdo = null; + if ($pdo !== null) return $pdo; + + $pdo = new PDO('sqlite::memory:', null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]); + $pdo->exec("CREATE TABLE company_endpoints ( + company_id INT, endpoint_key TEXT, direction TEXT, url TEXT, + method TEXT DEFAULT 'GET', params TEXT DEFAULT NULL, is_active INT DEFAULT 1)"); + $pdo->exec("CREATE TABLE company_phones ( + company_id INT, wa_number TEXT, label TEXT, permission_type INT, + tercero_id INT NULL, modulos_json TEXT NULL, es_supervisor INT DEFAULT 0, is_active INT DEFAULT 1)"); + + // 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); + $ins = $pdo->prepare("INSERT INTO company_endpoints (company_id, endpoint_key, direction, url) VALUES (1, ?, ?, ?)"); + foreach ($m[1] as $i => $key) { + $ins->execute([$key, $m[2][$i], FIXTURE_BASE . $m[3][$i]]); + } + return $pdo; +} + +// ── 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 function routeOrChat(array $company, array $context, string $input): array + { + 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"; + } +} diff --git a/setup/tests/fixtures_api.php b/setup/tests/fixtures_api.php new file mode 100644 index 0000000..f394bf0 --- /dev/null +++ b/setup/tests/fixtures_api.php @@ -0,0 +1,97 @@ + $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)"]; + +$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' => $novedades17, + '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' => [ + ['id' => '4', 'label' => 'REPOSO 1A'], + ['id' => '9', 'label' => 'REPOSO 2A'], + ], + '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}"]); diff --git a/setup/tests/run.sh b/setup/tests/run.sh new file mode 100755 index 0000000..59a5cb0 --- /dev/null +++ b/setup/tests/run.sh @@ -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 diff --git a/setup/tests/test_flujos.php b/setup/tests/test_flujos.php new file mode 100644 index 0000000..a0c7c9d --- /dev/null +++ b/setup/tests/test_flujos.php @@ -0,0 +1,218 @@ + ($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', '112', $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"; + +db()->exec("INSERT INTO company_phones (company_id, wa_number, label, permission_type, tercero_id, es_supervisor) + VALUES (1, '57300TRABAJADOR', 'Julio', 1, 412, 0), + (1, '57300JEFE', 'Sup', 3, 412, 1)"); + +$ctx = contexto('57300TRABAJADOR'); +$r = NormalBot::processInteractive($co, $ctx, 'registrar_ausentismo'); +check('el trabajador vinculado no se pregunta: arranca por el motivo', + str_contains(textoDe($r), 'motivo del 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'); +$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'])); + +$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'); + +$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 como arreglo', $post['body']['lote_ids'] ?? null, ['1', '2']); +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'])); + +$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'); + +// ════ Resultado ═══════════════════════════════════════════════════════════════ +echo "\n" . ($GLOBALS['fallas'] ? "{$GLOBALS['fallas']} falla(s)\n" : "Todos los flujos ejecutan de punta a punta\n"); +exit($GLOBALS['fallas'] ? 1 : 0);