feat: carga de ciclos — apertura y cierre de lotes con seleccion multiple
Ultimo flujo de Fase 1. Pregunta ciclo, accion, fecha y lotes, y postea a
ciclos_{ciclo}_up con lote_ids[], que es el contrato que quedo en el ERP.
Pieza nueva del motor, multi_select: la lista acumula, cada toque marca o
desmarca con un check en la etiqueta, y "Listo" cierra. WhatsApp no tiene
casillas, asi que el estado se muestra redibujando. De las 10 filas, dos van
para "Ver mas" y "Listo".
Tambien resolverPorCampos(), que reemplaza {campo} con lo ya elegido tanto en
la clave del endpoint como en la URL del POST. Con eso los seis tipos de ciclo
y las dos acciones comparten tres entradas de endpoint en vez de dieciocho:
apertura ofrece los lotes de la finca y cierre solo los que estan abiertos.
Mantenimiento no aparece: no tiene tabla de ciclos ni apertura/cierre, es una
labor y va con Fase 2.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
576ea7de40
commit
e5326bda8c
+132
-1
@@ -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,
|
||||
|
||||
@@ -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}'],
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user