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:
Lizandro Guarnizo
2026-08-05 07:56:04 -05:00
co-authored by Claude Sonnet 4.6
parent 576ea7de40
commit e5326bda8c
3 changed files with 252 additions and 1 deletions
+132 -1
View File
@@ -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,