diff --git a/services/NormalBot.php b/services/NormalBot.php index c8ce9a1..3f6e04f 100644 --- a/services/NormalBot.php +++ b/services/NormalBot.php @@ -531,6 +531,55 @@ class NormalBot return $data; } + /** + * Lista que acumula: cada toque marca o desmarca, y "Listo" cierra. WhatsApp + * no tiene casillas, así que el estado se muestra con ✅ en la etiqueta y la + * lista se vuelve a dibujar en cada toque. + * + * De las 10 filas disponibles, dos se reservan para "Ver más" y "Listo". + */ + private static function buildMultiSelectResponse(array $items, array $cfg, array $elegidos, string $to, array $company): ?array + { + $vf = $cfg['value_field'] ?? 'id'; + $lf = $cfg['label_field'] ?? 'label'; + $pagina = max(0, (int)($cfg['__page'] ?? 0)); + + $porPagina = self::LISTA_MAX_FILAS - 2; + $hayMas = count($items) > $porPagina; + $visibles = $hayMas ? array_slice($items, $pagina * $porPagina, $porPagina) : $items; + + $rows = []; + foreach ($visibles as $item) { + $id = (string)($item[$vf] ?? ''); + if ($id === '') continue; + $marca = isset($elegidos[$id]) ? '✅ ' : ''; + $rows[] = ['id' => $id, 'title' => mb_substr($marca . (string)($item[$lf] ?? $id), 0, 24)]; + } + + $restantes = count($items) - (($pagina + 1) * $porPagina); + if ($hayMas && $restantes > 0) { + $rows[] = ['id' => '__mas', 'title' => '➕ Ver más', 'description' => "Faltan {$restantes}"]; + } + + $n = count($elegidos); + $rows[] = [ + 'id' => '__listo', + 'title' => $n > 0 ? "✔️ Listo ({$n})" : '✔️ Listo', + 'description' => $n > 0 ? implode(', ', array_slice(array_values($elegidos), 0, 3)) : 'Elige al menos uno', + ]; + + return self::enqueueInteractive($to, [ + 'type' => 'list', + 'header' => ['type' => 'text', 'text' => mb_substr($cfg['header'] ?? 'Selecciona', 0, 60)], + 'body' => ['text' => mb_substr($cfg['body'] ?? 'Puedes elegir varios:', 0, 1024)], + 'footer' => ['text' => ''], + 'action' => ['button' => 'Ver opciones', 'sections' => [[ + 'title' => mb_substr($cfg['section_title'] ?? 'Opciones', 0, 24), + 'rows' => $rows, + ]]], + ], $company); + } + private static function buildDynamicListResponse(array $items, array $flow, string $to, array $company): ?array { $valueField = $flow['value_field'] ?? 'id'; @@ -716,6 +765,20 @@ class NormalBot return self::foreachAskNext($meta['__foreach'], $context['from'], $company); } + /** + * Reemplaza {campo} con lo ya recolectado. Permite que una clave de endpoint + * o una URL dependan de lo que el usuario eligió antes: los lotes a abrir + * salen de un catálogo y los que se pueden cerrar, de otro. + */ + private static function resolverPorCampos(string $plantilla, array $recolectado): string + { + if ($plantilla === '' || !str_contains($plantilla, '{')) return $plantilla; + foreach ($recolectado as $k => $v) { + $plantilla = str_replace('{' . $k . '}', (string)$v, $plantilla); + } + return $plantilla; + } + /** Hoy / Ayer / Anteayer / Otra — mismo criterio que el campo date_quick. */ private static function foreachAskDate(array $flow, string $to, array $company): ?array { @@ -982,6 +1045,41 @@ class NormalBot return self::capShowConfirmOrSubmit($cap, $meta, $context, $company, $ctxId); } + // ── 1a. multi_select: marcar, desmarcar o terminar ─────────────────── + if (($field['type'] ?? '') === 'multi_select' && $value !== '__mas') { + $key = $field['key']; + $elegidos = $cap['__multi'][$key] ?? []; + + if ($value === '__listo') { + if (empty($elegidos)) { + WhatsAppSender::sendText($context['from'], '⚠️ Elige al menos una opción.', (string)($context['phone_number_id'] ?? '')); + return self::capAskNext($cap, $context['from'], $company, $ctxId); + } + // strval explícito: PHP convierte a int las claves numéricas + $cap['collected'][$key] = array_map('strval', array_keys($elegidos)); + $cap['collected_labels'][$key] = implode(', ', array_values($elegidos)); + unset($cap['__multi'][$key], $cap['__page'][$key]); + $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); + } + + // Toque sobre una opción: alterna sin salir de la lista + $catalogo = $cap['__opciones'][$key] ?? []; + if (isset($catalogo[$value])) { + if (isset($elegidos[$value])) unset($elegidos[$value]); + else $elegidos[$value] = $catalogo[$value]; + $cap['__multi'][$key] = $elegidos; + $meta['__cap'] = $cap; + ConversationContext::updateMetadata($ctxId, $meta); + return self::capAskNext($cap, $context['from'], $company, $ctxId); + } + } + // ── 1b. "Ver más": avanza la página y vuelve a mostrar la misma lista ─ if ($value === '__mas') { $cap['__page'][$field['key']] = (int)($cap['__page'][$field['key']] ?? 0) + 1; @@ -1180,6 +1278,37 @@ class NormalBot $field = $cap['fields'][$idx]; $type = $field['type'] ?? 'text'; + // ── multi_select: acumula varias opciones antes de continuar ───────── + if ($type === 'multi_select') { + $items = self::fetchDynamicList( + self::resolverPorCampos($field['source_endpoint_key'] ?? '', $cap['collected'] ?? []), + $company, + ['__ep_vars_combined' => $cap['collected'] ?? []] + ); + if (empty($items)) { + return self::sendText( + "*{$cap['header']}*\n\n" . ($field['empty_text'] ?? '⚠️ No hay opciones disponibles para esta combinación.'), + $to, $company + ); + } + if ($ctxId > 0) { + $cap['__opciones'][$field['key']] = array_column( + $items, $field['label_field'] ?? 'label', $field['value_field'] ?? 'id' + ); + $m = ConversationContext::getMetadata($ctxId); + $m['__cap'] = $cap; + ConversationContext::updateMetadata($ctxId, $m); + } + return self::buildMultiSelectResponse($items, [ + 'value_field' => $field['value_field'] ?? 'id', + 'label_field' => $field['label_field'] ?? 'label', + 'header' => $cap['header'], + 'body' => $field['prompt'] ?? 'Puedes elegir varios:', + 'section_title' => $field['label'] ?? $field['key'], + '__page' => (int)($cap['__page'][$field['key']] ?? 0), + ], $cap['__multi'][$field['key']] ?? [], $to, $company); + } + // ── select: fetch from API ─────────────────────────────────────────── if ($type === 'select') { $vf = $field['value_field'] ?? 'id'; @@ -1324,7 +1453,9 @@ class NormalBot 'nombre' => $context['name'] ?? '', ]); - $ch = curl_init(self::buildUrl($ep['url'], $company)); + // La URL puede depender de lo elegido (?peticion=ciclos_{ciclo}_up), así + // seis tipos de ciclo comparten un solo endpoint registrado. + $ch = curl_init(self::buildUrl(self::resolverPorCampos($ep['url'], $cap['collected']), $company)); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, diff --git a/setup/seed_palmas.php b/setup/seed_palmas.php index 2475bdd..a13ccd8 100644 --- a/setup/seed_palmas.php +++ b/setup/seed_palmas.php @@ -231,6 +231,7 @@ $configJson = [ ['id' => 'subir_ciclo_cosecha', 'title' => '🌾 Ciclo cosecha', 'description' => 'Subir ciclo de cosecha'], ['id' => 'registrar_ausentismo', 'title' => '👥 Ausentismo', 'description' => 'Registrar falta, incapacidad o permiso'], ['id' => 'registrar_pluviometria','title' => '🌧️ Pluviometría', 'description' => 'Milímetros de lluvia por finca'], + ['id' => 'registrar_ciclo', 'title' => '🔄 Ciclos', 'description' => 'Abrir o cerrar lotes'], ], ], ], @@ -471,6 +472,64 @@ $configJson = [ 'nlu_description' => 'Registrar pluviometría, lluvia o milímetros por finca', ], + // Apertura y cierre de lotes, para los seis tipos de ciclo. + // Mantenimiento no va acá: no tiene tabla de ciclos ni apertura/cierre, + // es una labor. El orden sigue el de la pantalla web. + 'registrar_ciclo' => [ + 'type' => 'collect_and_post', + 'endpoint_key' => 'ciclos_up', + 'header' => '🔄 Ciclos', + 'confirm' => true, + 'success_text' => '✅ Enviado a Palmas360.', + 'back' => 'enviar_informacion', + 'nlu_description' => 'Abrir o cerrar ciclos de lotes (cosecha, sanidad, censo, palmarum, tratamiento, polinización)', + 'fields' => [ + [ + 'key' => 'ciclo', + 'label' => 'Ciclo', + 'type' => 'static_select', + 'prompt' => '¿Qué ciclo vas a registrar?', + 'options' => [ + ['id' => 'cosecha', 'label' => '🌾 Cosecha'], + ['id' => 'polinizacion', 'label' => '🌸 Polinización'], + ['id' => 'sanidad', 'label' => '🐛 Plagas'], + ['id' => 'censo', 'label' => '📝 Censo'], + ['id' => 'palm', 'label' => '🌴 Palmarum'], + ['id' => 'tratamiento', 'label' => '💊 Tratamiento'], + ], + ], + [ + 'key' => 'accion', + 'label' => 'Acción', + 'type' => 'static_select', + 'prompt' => '¿Qué vas a hacer?', + 'options' => [ + ['id' => 'apertura', 'label' => '📂 Abrir lotes'], + ['id' => 'cierre', 'label' => '📁 Cerrar lotes'], + ], + ], + [ + 'key' => 'fecha', + 'label' => 'Fecha', + 'type' => 'date_quick', + 'prompt' => '📅 ¿De qué fecha?', + 'other_validate' => 'date', + 'other_prompt' => 'Escribe la fecha (YYYY-MM-DD):', + ], + [ + 'key' => 'lote_ids', + 'label' => 'Lotes', + 'type' => 'multi_select', + 'prompt' => 'Elige los lotes (puedes marcar varios):', + // Apertura ofrece los lotes de la finca; cierre, solo los abiertos + 'source_endpoint_key' => 'lotes_{accion}_dn', + 'value_field' => 'id', + 'label_field' => 'label', + 'empty_text' => '⚠️ No hay lotes disponibles para esta combinación.', + ], + ], + ], + // 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']], @@ -504,6 +563,7 @@ $configJson = [ ['id' => 'subir_ciclo_cosecha', 'title' => '🌾 Subir cosecha', 'description' => 'Enviar ciclo de cosecha'], ['id' => 'registrar_ausentismo', 'title' => '👥 Ausentismo', 'description' => 'Registrar falta, incapacidad o permiso'], ['id' => 'registrar_pluviometria','title' => '🌧️ Pluviometría', 'description' => 'Milímetros de lluvia por finca'], + ['id' => 'registrar_ciclo', 'title' => '🔄 Ciclos', 'description' => 'Abrir o cerrar lotes'], ], ], ], @@ -597,6 +657,13 @@ $endpoints = [ ['key' => 'novedades_ausentismo_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=novedades_ausentismo_dn'], ['key' => 'ausentismos_up', 'dir' => 'upload', 'url' => $BASE . '?peticion=ausentismos_up'], ['key' => 'pluvio_upload', 'dir' => 'upload', 'url' => $BASE . '?peticion=pluvio_upload'], + + // ── Carga de ciclos ───────────────────────────────────────────────────── + // {ciclo} y {accion} se resuelven con lo que el usuario ya eligió, así los + // seis tipos y las dos acciones comparten estas tres entradas. + ['key' => 'lotes_apertura_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=lotes_x_finca&finca_id={finca_id}'], + ['key' => 'lotes_cierre_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=lotes_abiertos_dn&ciclo={ciclo}&finca_id={finca_id}'], + ['key' => 'ciclos_up', 'dir' => 'upload', 'url' => $BASE . '?peticion=ciclos_{ciclo}_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 526e5bd..0a1d5b4 100644 --- a/setup/test_navegacion.php +++ b/setup/test_navegacion.php @@ -406,6 +406,59 @@ check('la última página no ofrece "ver más"', 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 "\nask_finca\n"; $af = $flows['ask_finca']; check('no repregunta si ya hay finca', $af['skip_if_set'] ?? false, true);