From 0e72aa3d5addfb359c1f0b2a154e78d0ba282adb Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Tue, 4 Aug 2026 19:37:16 -0500 Subject: [PATCH] feat: flujo de registro de ausentismo (Fase 1, carga de datos) Primer flujo de POST. El bot recolecta trabajador, motivo y rango de fechas, y postea a ausentismos_up; Palmas360 decide si auto-aprueba o encola para revision, como ya hace bot_entrada. El campo lookup asumia que el endpoint devolvia un solo objeto, pero /empleados devuelve una lista paginada: con eso el trabajador salia como "Desconocido" y sin id. Ahora distingue lista de objeto, y si hay varias coincidencias las ofrece para elegir en vez de quedarse con la primera. Campos opcionales del contrato (eps, diagnostico_id) quedan fuera hasta confirmar con Palmas360 que novedades son incapacidad. Co-Authored-By: Claude Sonnet 4.6 --- services/NormalBot.php | 58 +++++++++++++++++++++++++++++++++-- setup/seed_palmas.php | 64 +++++++++++++++++++++++++++++++++++++-- setup/test_navegacion.php | 43 ++++++++++++++++++++++++++ 3 files changed, 160 insertions(+), 5 deletions(-) diff --git a/services/NormalBot.php b/services/NormalBot.php index 73d3e55..c258e04 100644 --- a/services/NormalBot.php +++ b/services/NormalBot.php @@ -898,8 +898,23 @@ class NormalBot return self::sendText("*{$cap['header']}*\n\n{$prompt}", $context['from'], $company); } + // ── 3a. Ya se mostraron varias coincidencias: el usuario eligió una ── + if (!empty($cap['__lookup_options']) && isset($cap['__lookup_options'][$value])) { + $cap['collected'][$field['key']] = $value; + $cap['collected_labels'][$field['key']] = $cap['__lookup_options'][$value]; + unset($cap['__lookup_options']); + $cap['index'] = $idx + 1; + while ($cap['index'] < count($fields) && self::capShouldSkip($fields[$cap['index']], $cap['collected'])) { $cap['index']++; } + $meta['__cap'] = $cap; + ConversationContext::updateMetadata($ctxId, $meta); + return $cap['index'] < count($fields) + ? self::capAskNext($cap, $context['from'], $company, $ctxId) + : self::capShowConfirmOrSubmit($cap, $meta, $context, $company, $ctxId); + } + // ── 3. Lookup field — search entity by entered value (e.g. cédula) ─── if (($field['type'] ?? 'text') === 'lookup') { + unset($cap['__lookup_options']); $result = self::callLookupEndpoint( $field['lookup_endpoint_key'] ?? '', $value, @@ -907,13 +922,38 @@ class NormalBot $company ); - if ($result === null) { + $vf = $field['value_field'] ?? 'id'; + $df = $field['display_field'] ?? 'nombre'; + + // Catálogos como /empleados devuelven una lista; /diagnostico_buscar, un objeto. + $esLista = is_array($result) && array_is_list($result); + if ($esLista) { + $result = self::acotarCoincidencias($result, $vf, $df); + if (count($result) > 1) { + $opciones = []; + foreach ($result as $r) $opciones[(string)($r[$vf] ?? '')] = (string)($r[$df] ?? ''); + $cap['__lookup_options'] = $opciones; + $meta['__cap'] = $cap; + ConversationContext::updateMetadata($ctxId, $meta); + return self::buildDynamicListResponse($result, [ + 'value_field' => $vf, + 'label_field' => $df, + 'header' => $cap['header'], + 'body' => 'Encontré varias coincidencias. ¿Cuál es?', + 'button' => 'Ver coincidencias', + 'section_title' => $field['label'] ?? 'Resultados', + ], $context['from'], $company); + } + $result = $result[0] ?? null; + } + + if (!$result) { $notFound = $field['not_found_text'] ?? '⚠️ No encontré resultados. Intenta de nuevo:'; return self::sendText("*{$cap['header']}*\n\n{$notFound}", $context['from'], $company); } - $displayName = (string)($result[$field['display_field'] ?? 'nombre'] ?? 'Desconocido'); - $entityId = (string)($result[$field['value_field'] ?? 'id'] ?? ''); + $displayName = (string)($result[$df] ?? 'Desconocido'); + $entityId = (string)($result[$vf] ?? ''); $cap['__lookup_pending'] = ['id' => $entityId, 'label' => $displayName]; $meta['__cap'] = $cap; @@ -971,6 +1011,18 @@ class NormalBot return self::submitCapPost($meta, $context, $company, $ctxId); } + /** + * Una lista de WhatsApp admite pocas filas, y con 40 homónimos elegir no es + * viable. Se recortan a 10 y se descartan los que no traigan id o etiqueta. + */ + private static function acotarCoincidencias(array $items, string $valueField, string $labelField): array + { + $validos = array_values(array_filter($items, fn($r) => + is_array($r) && ($r[$valueField] ?? '') !== '' && ($r[$labelField] ?? '') !== '' + )); + return array_slice($validos, 0, 10); + } + private static function callLookupEndpoint(string $epKey, string $value, string $param, array $company): ?array { if ($epKey === '' || $value === '') return null; diff --git a/setup/seed_palmas.php b/setup/seed_palmas.php index 1d93f3d..3da3eb2 100644 --- a/setup/seed_palmas.php +++ b/setup/seed_palmas.php @@ -228,7 +228,8 @@ $configJson = [ [ 'title' => 'Opciones', 'rows' => [ - ['id' => 'subir_ciclo_cosecha', 'title' => '🌾 Ciclo cosecha', 'description' => 'Subir ciclo de cosecha'], + ['id' => 'subir_ciclo_cosecha', 'title' => '🌾 Ciclo cosecha', 'description' => 'Subir ciclo de cosecha'], + ['id' => 'registrar_ausentismo', 'title' => '👥 Ausentismo', 'description' => 'Registrar falta, incapacidad o permiso'], ], ], ], @@ -395,6 +396,58 @@ $configJson = [ 'params'=>['endpoint_key'=>'mantenimiento_todos_bot_dn','date_mode'=>'today','filename'=>'mantenimiento_todos.pdf','caption'=>'Mantenimiento — todos los lotes'], ], + // ── Carga de datos (POST) — Fase 1 ────────────────────────────────── + // Palmas360 decide si auto-aprueba o encola para revisión; el bot solo + // envía. Campos opcionales del contrato (eps, diagnostico_id) quedan + // fuera hasta confirmar qué novedades son incapacidad. + 'registrar_ausentismo' => [ + 'type' => 'collect_and_post', + 'endpoint_key' => 'ausentismos_up', + 'header' => '👥 Registrar ausentismo', + 'confirm' => true, + 'success_text' => '✅ Ausentismo enviado a Palmas360.', + 'back' => 'enviar_informacion', + 'nlu_description' => 'Registrar un ausentismo, incapacidad o permiso de un trabajador', + 'fields' => [ + [ + 'key' => 'empleado_id', + 'label' => 'Trabajador', + 'type' => 'lookup', + 'prompt' => '👤 Escribe el nombre o documento del trabajador:', + 'lookup_endpoint_key' => 'empleados_buscar_dn', + 'lookup_param' => 'filtro', + 'value_field' => 'id', + 'display_field' => 'nombre', + 'not_found_text' => '⚠️ No encontré ese trabajador. Escribe el nombre de nuevo:', + ], + [ + 'key' => 'novedad_id', + 'label' => 'Motivo', + 'type' => 'select', + 'prompt' => '¿Cuál es el motivo del ausentismo?', + 'source_endpoint_key' => 'novedades_ausentismo_dn', + 'value_field' => 'id', + 'label_field' => 'nombre', + ], + [ + 'key' => 'fecha_inicial', + 'label' => 'Desde', + 'type' => 'date_quick', + 'prompt' => '📅 ¿Desde qué fecha?', + 'other_validate' => 'date', + 'other_prompt' => 'Escribe la fecha de inicio (YYYY-MM-DD):', + ], + [ + 'key' => 'fecha_final', + 'label' => 'Hasta', + 'type' => 'date_quick', + 'prompt' => '📅 ¿Hasta qué fecha? (si es un solo día, elige la misma)', + 'other_validate' => 'date', + 'other_prompt' => 'Escribe la fecha final (YYYY-MM-DD):', + ], + ], + ], + // Upload 'enviar_informacion' => ['type' => 'menu', 'menu' => 'submenu_subir_ciclos', 'nlu_skip' => true, 'back' => 'show_main_menu'], 'subir_ciclo_cosecha' => ['type' => 'function', 'function' => 'upload_ciclo', 'nlu_description' => 'Subir o enviar un ciclo de cosecha', 'params' => ['ciclo' => 'cosecha']], @@ -425,7 +478,8 @@ $configJson = [ [ 'title' => 'Opciones', 'rows' => [ - ['id' => 'subir_ciclo_cosecha', 'title' => '🌾 Subir cosecha', 'description' => 'Enviar ciclo de cosecha'], + ['id' => 'subir_ciclo_cosecha', 'title' => '🌾 Subir cosecha', 'description' => 'Enviar ciclo de cosecha'], + ['id' => 'registrar_ausentismo', 'title' => '👥 Ausentismo', 'description' => 'Registrar falta, incapacidad o permiso'], ], ], ], @@ -512,6 +566,12 @@ $endpoints = [ ['key' => 'tratamiento_historico_bot_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=tratamiento_historico_bot&finca_id={finca_id}'], ['key' => 'fincas_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=fincas'], ['key' => 'numeros_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=whatsapp_numeros&estado=1'], + + // ── Carga de datos (POST) — Fase 1: Ausentismo ────────────────────────── + // El lookup agrega &filtro= a esta URL; estado=1 restringe a activos. + ['key' => 'empleados_buscar_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=empleados&estado=1&items=10'], + ['key' => 'novedades_ausentismo_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=novedades_ausentismo_dn'], + ['key' => 'ausentismos_up', 'dir' => 'upload', 'url' => $BASE . '?peticion=ausentismos_up'], // Todos los lotes (PDF) por ciclo ['key' => 'cosecha_todos_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=ciclos_cosecha_dn&finca_id={finca_id}'], ['key' => 'polinizacion_todos_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=ciclos_polinizacion_dn&finca_id={finca_id}'], diff --git a/setup/test_navegacion.php b/setup/test_navegacion.php index cb18d6a..0284316 100644 --- a/setup/test_navegacion.php +++ b/setup/test_navegacion.php @@ -253,6 +253,49 @@ $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 "\nask_finca\n"; $af = $flows['ask_finca']; check('no repregunta si ya hay finca', $af['skip_if_set'] ?? false, true);