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>
2174 lines
103 KiB
PHP
2174 lines
103 KiB
PHP
<?php
|
||
declare(strict_types=1);
|
||
|
||
class NormalBot
|
||
{
|
||
/** Filas máximas de una lista interactiva de WhatsApp. Estaba suelto como 10. */
|
||
private const LISTA_MAX_FILAS = 10;
|
||
|
||
public static function process(array $company, array $context, string $input, bool $suppressFallback = false): ?array
|
||
{
|
||
$config = self::getConfig($company);
|
||
$permType = (string)($company['_permission_type'] ?? 1);
|
||
$perType = $config['per_type'][$permType] ?? [];
|
||
|
||
// Per-type commands EXTEND globals (not replace), so salir/exit/etc. always work.
|
||
$commands = array_merge($config['commands'] ?? [], $perType['commands'] ?? []);
|
||
$flows = array_merge($config['flows'] ?? [], $perType['flows'] ?? []);
|
||
$menus = array_merge($config['menus'] ?? [], $perType['menus'] ?? []);
|
||
|
||
$botCtx = ConversationContext::getOrCreate((int)$company['id'], $context['from'], 'normal');
|
||
$ctxId = (int)$botCtx['id'];
|
||
$currentNode = $botCtx['current_node'];
|
||
$normalized = self::normalize($input);
|
||
|
||
// 0. Saludo del día — va aparte y el flujo sigue igual, así el usuario
|
||
// recibe la bienvenida y acto seguido lo que haya pedido.
|
||
self::saludarUnaVezAlDia($config, $context, $company, $ctxId, $permType);
|
||
|
||
// 1. Commands always win — escape from any state (salir, menu, etc.)
|
||
foreach ($commands as $keyword => $action) {
|
||
if ($normalized === self::normalize((string)$keyword)) {
|
||
// Clear any in-progress multi-step state so the command is a clean reset
|
||
$m = ConversationContext::getMetadata($ctxId);
|
||
$dirty = false;
|
||
foreach (['collecting', '__foreach', '__cap', '__api_vars', '__ep_vars', '__ep_vars_flat'] as $stateKey) {
|
||
if (isset($m[$stateKey])) { unset($m[$stateKey]); $dirty = true; }
|
||
}
|
||
if ($dirty) ConversationContext::updateMetadata($ctxId, $m);
|
||
|
||
// "atrás" sube un nivel según el mapa 'back' del nodo actual.
|
||
// Sin nodo o sin mapa (p.ej. tras un informe) cae al menú principal.
|
||
if ($action === '__back') {
|
||
$action = ($currentNode !== null ? ($flows[$currentNode]['back'] ?? null) : null)
|
||
?? 'show_main_menu';
|
||
}
|
||
|
||
// "menu/inicio/volver" → prefer per-type greeting_menu over global show_main_menu
|
||
$greetingMenuKey = $perType['greeting_menu'] ?? null;
|
||
if ($action === 'show_main_menu' && $greetingMenuKey && isset($menus[$greetingMenuKey])) {
|
||
$action = $greetingMenuKey;
|
||
}
|
||
|
||
ConversationContext::updateNode($ctxId, $action);
|
||
// Commands that match a flow
|
||
if (isset($flows[$action])) {
|
||
return self::handleFlow($flows[$action], $context, $company, $ctxId, $menus, $action);
|
||
}
|
||
// Commands that match a menu
|
||
if (isset($menus[$action])) {
|
||
return self::buildMenuResponse($menus[$action], $context['from'], $company, $ctxId);
|
||
}
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// 2. Multi-step form: user is currently filling in fields
|
||
$meta = ConversationContext::getMetadata($ctxId);
|
||
if (!empty($meta['collecting'])) {
|
||
return self::handleCollectingInput($meta, $input, 'text', $context, $company, $ctxId, $flows, $menus);
|
||
}
|
||
|
||
// 2b. collect_for_each: collecting a value for each item in a list
|
||
if (!empty($meta['__foreach'])) {
|
||
return self::handleForeachInput($meta, $input, $context, $company, $ctxId, $flows);
|
||
}
|
||
|
||
// 2c. collect_and_post: collecting sequential fields then POSTing
|
||
if (!empty($meta['__cap'])) {
|
||
return self::handleCapInput($meta, $input, $context, $company, $ctxId);
|
||
}
|
||
|
||
// 2d. api_vars: collecting URL variables before executing an api_report endpoint
|
||
if (!empty($meta['__api_vars'])) {
|
||
return self::handleApiVarsInput($meta, $input, $context, $company, $ctxId, $flows, $menus);
|
||
}
|
||
|
||
// 3. Active node — resume conversation
|
||
// Menu-type nodes let NLU act on free text; only re-show if NLU doesn't handle it.
|
||
$sentinels = ['collecting', '__greeted'];
|
||
if ($currentNode !== null && !in_array($currentNode, $sentinels, true) && isset($flows[$currentNode])) {
|
||
$isMenuNode = ($flows[$currentNode]['type'] ?? '') === 'menu';
|
||
if (!$isMenuNode) {
|
||
return self::handleFlow($flows[$currentNode], $context, $company, $ctxId, $menus, $currentNode);
|
||
}
|
||
}
|
||
|
||
// 4. Welcome message — shown once for brand-new sessions before the category menu
|
||
$welcomeMenuKey = $config['welcome_menu'] ?? null;
|
||
if ($welcomeMenuKey && $currentNode === null && isset($menus[$welcomeMenuKey])) {
|
||
ConversationContext::updateNode($ctxId, '__greeted');
|
||
return self::buildMenuResponse($menus[$welcomeMenuKey], $context['from'], $company, $ctxId);
|
||
}
|
||
|
||
// 4b. Per-type greeting menu — only for brand-new sessions (currentNode === null)
|
||
$greetingMenuKey = $perType['greeting_menu'] ?? null;
|
||
if ($greetingMenuKey !== null && $currentNode === null && isset($menus[$greetingMenuKey])) {
|
||
ConversationContext::updateNode($ctxId, '__greeted'); // sentinel: evita re-envío del menú
|
||
return self::buildMenuResponse($menus[$greetingMenuKey], $context['from'], $company, $ctxId);
|
||
}
|
||
|
||
// 5. Greeting text
|
||
$greeting = $perType['greeting'] ?? $config['greeting'] ?? null;
|
||
if ($greeting !== null && $currentNode === null) {
|
||
$greetingFlowId = $perType['greeting_flow'] ?? 'greeting';
|
||
if (isset($flows[$greetingFlowId])) {
|
||
ConversationContext::updateNode($ctxId, $greetingFlowId);
|
||
return self::handleFlow($flows[$greetingFlowId], $context, $company, $ctxId, $menus, $greetingFlowId);
|
||
}
|
||
$response = self::sendText($greeting, $context['from'], $company);
|
||
ConversationContext::updateNode($ctxId, null);
|
||
return $response;
|
||
}
|
||
|
||
// 6. NLU routing — si está habilitado, intenta mapear texto libre a un flow antes del fallback
|
||
if (!$suppressFallback && ($config['nlu_enabled'] ?? false)) {
|
||
$context['permission_type'] ??= (int)$permType;
|
||
$route = AiBot::routeOrChat($company, $context, $input);
|
||
if ($route['action'] === 'route' && isset($route['key'])) {
|
||
$key = $route['key'];
|
||
// reset_finca: limpiar finca del meta y relanzar ask_finca
|
||
if ($key === 'reset_finca') {
|
||
$m = ConversationContext::getMetadata($ctxId);
|
||
unset($m['finca']);
|
||
ConversationContext::updateMetadata($ctxId, $m);
|
||
$key = 'ask_finca';
|
||
}
|
||
// Inyectar entities para auto-select en dynamic_list.
|
||
// Se guarda el mapa completo: los formularios las buscan por nombre
|
||
// de campo, y aplanarlas dejaba ese pre-llenado sin funcionar.
|
||
$m = ConversationContext::getMetadata($ctxId);
|
||
unset($m['__nlu_entities']); // descartar sobras de un ruteo anterior
|
||
if (!empty($route['entities'])) {
|
||
$m['__nlu_entities'] = $route['entities'];
|
||
}
|
||
ConversationContext::updateMetadata($ctxId, $m);
|
||
if (isset($flows[$key])) {
|
||
ConversationContext::updateNode($ctxId, $key);
|
||
return self::handleFlow($flows[$key], $context, $company, $ctxId, $menus, $key);
|
||
}
|
||
if (isset($menus[$key])) {
|
||
ConversationContext::updateNode($ctxId, $key);
|
||
return self::buildMenuResponse($menus[$key], $context['from'], $company, $ctxId);
|
||
}
|
||
}
|
||
if ($route['action'] === 'chat' && ($route['text'] ?? '') !== '') {
|
||
// Preserve node when inside a menu so state isn't lost
|
||
$inMenu = $currentNode !== null && !in_array($currentNode, $sentinels, true)
|
||
&& isset($flows[$currentNode]) && ($flows[$currentNode]['type'] ?? '') === 'menu';
|
||
if (!$inMenu) ConversationContext::updateNode($ctxId, null);
|
||
return self::sendText($route['text'], $context['from'], $company);
|
||
}
|
||
}
|
||
|
||
// Re-show current menu when NLU didn't route anything
|
||
if ($currentNode !== null && !in_array($currentNode, $sentinels, true) && isset($flows[$currentNode])) {
|
||
return self::handleFlow($flows[$currentNode], $context, $company, $ctxId, $menus, $currentNode);
|
||
}
|
||
|
||
// 7. Fallback flow
|
||
$fallbackFlowId = $perType['fallback_flow'] ?? $config['fallback_flow'] ?? null;
|
||
if ($fallbackFlowId !== null && isset($flows[$fallbackFlowId])) {
|
||
ConversationContext::updateNode($ctxId, $fallbackFlowId);
|
||
return self::handleFlow($flows[$fallbackFlowId], $context, $company, $ctxId, $menus, $fallbackFlowId);
|
||
}
|
||
|
||
// 8. Greeting menu as fallback (texto no reconocido después de estar en __greeted)
|
||
if (!$suppressFallback && $greetingMenuKey !== null && isset($menus[$greetingMenuKey])) {
|
||
ConversationContext::updateNode($ctxId, '__greeted');
|
||
return self::buildMenuResponse($menus[$greetingMenuKey], $context['from'], $company, $ctxId);
|
||
}
|
||
|
||
// 9. Static fallback text
|
||
if (!$suppressFallback) {
|
||
$fallback = $perType['fallback'] ?? $config['fallback'] ?? null;
|
||
if ($fallback !== null) {
|
||
ConversationContext::updateNode($ctxId, null);
|
||
return self::sendText($fallback, $context['from'], $company);
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
// ── Multi-step collecting ────────────────────────────────────────────────
|
||
|
||
private static function handleCollectingInput(
|
||
array $meta, string $input, string $inputType,
|
||
array $context, array $company, int $ctxId, array $flows, array $menus
|
||
): ?array {
|
||
$col = $meta['collecting'];
|
||
$group = $col['meta_group'] ?? '';
|
||
$key = $col['meta_key'] ?? '';
|
||
$nextNode = $col['next_node'] ?? null;
|
||
$validIds = $col['__valid_ids'] ?? null;
|
||
$labelMap = $col['__label_map'] ?? [];
|
||
|
||
$resolved = trim($input);
|
||
if ($validIds !== null && !in_array($resolved, $validIds, true)) {
|
||
// Intento de match por label (útil cuando viene texto transcrito de audio)
|
||
$inputNorm = self::normalize($resolved);
|
||
$matched = null;
|
||
foreach ($labelMap as $label => $id) {
|
||
$lNorm = self::normalize((string)$label);
|
||
if ($lNorm === $inputNorm || str_contains($inputNorm, $lNorm) || str_contains($lNorm, $inputNorm)) {
|
||
$matched = (string)$id;
|
||
break;
|
||
}
|
||
}
|
||
if ($matched === null) {
|
||
return self::sendText(
|
||
'⚠️ Por favor selecciona una opción válida de la lista.',
|
||
$context['from'], $company
|
||
);
|
||
}
|
||
$resolved = $matched;
|
||
}
|
||
|
||
// Si el valor resuelto es un redirect_id, navegar al flow sin guardar valor
|
||
$redirectIds = $col['__redirect_ids'] ?? [];
|
||
if (in_array($resolved, $redirectIds, true)) {
|
||
unset($meta['collecting']);
|
||
ConversationContext::updateMetadata($ctxId, $meta);
|
||
ConversationContext::updateNode($ctxId, $resolved);
|
||
if (isset($flows[$resolved])) {
|
||
return self::handleFlow($flows[$resolved], $context, $company, $ctxId, $menus, $resolved);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// Se recupera la etiqueta del id elegido para poder mostrarla después
|
||
$etiqueta = '';
|
||
foreach ($labelMap as $label => $id) {
|
||
if ((string)$id === $resolved) { $etiqueta = (string)$label; break; }
|
||
}
|
||
$meta = self::guardarSeleccion($meta, $group, $key, $resolved, $etiqueta);
|
||
unset($meta['collecting']);
|
||
ConversationContext::updateMetadata($ctxId, $meta);
|
||
|
||
if ($nextNode !== null) {
|
||
ConversationContext::updateNode($ctxId, $nextNode);
|
||
if (isset($flows[$nextNode])) {
|
||
return self::handleFlow($flows[$nextNode], $context, $company, $ctxId, $menus, $nextNode);
|
||
}
|
||
if (isset($menus[$nextNode])) {
|
||
return self::buildMenuResponse($menus[$nextNode], $context['from'], $company, $ctxId);
|
||
}
|
||
} else {
|
||
ConversationContext::updateNode($ctxId, null);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
private static function handleCollectInput(array $flow, array $context, array $company, int $ctxId): ?array
|
||
{
|
||
$meta = ConversationContext::getMetadata($ctxId);
|
||
$meta['collecting'] = [
|
||
'meta_group' => $flow['meta_group'] ?? '',
|
||
'meta_key' => $flow['meta_key'] ?? '',
|
||
'next_node' => $flow['next_node'] ?? null,
|
||
];
|
||
ConversationContext::updateMetadata($ctxId, $meta);
|
||
ConversationContext::updateNode($ctxId, 'collecting');
|
||
return self::sendText($flow['prompt'] ?? '¿Cuál es el valor?', $context['from'], $company);
|
||
}
|
||
|
||
// ── Dynamic list (from endpoint) ─────────────────────────────────────────
|
||
|
||
private static function handleDynamicList(array $flow, array $context, array $company, int $ctxId): ?array
|
||
{
|
||
$meta = ConversationContext::getMetadata($ctxId);
|
||
$valueField = $flow['value_field'] ?? 'id';
|
||
$labelField = $flow['label_field'] ?? 'name';
|
||
$group = $flow['meta_group'] ?? '';
|
||
$key = $flow['meta_key'] ?? '';
|
||
// La intención pendiente manda sobre next_node: el usuario ya dijo a dónde iba.
|
||
$nextNode = $meta['__after_select'] ?? $flow['next_node'] ?? null;
|
||
|
||
// Ya está elegido → no volver a preguntar (para cambiarlo está reset_finca).
|
||
if (!empty($flow['skip_if_set']) && $group !== '' && ($meta[$group][$key] ?? '') !== '') {
|
||
return self::advanceAfterSelect($nextNode, $context, $company, $ctxId);
|
||
}
|
||
|
||
// Con entities del NLU se consulta el catálogo completo, no el top recortado:
|
||
// "plateo" puede no estar entre los grupos más frecuentes.
|
||
$srcKey = $flow['source_endpoint_key'] ?? '';
|
||
if (!empty($meta['__nlu_entities']) && !empty($flow['source_endpoint_key_all'])) {
|
||
$srcKey = $flow['source_endpoint_key_all'];
|
||
}
|
||
$items = self::fetchDynamicList($srcKey, $company, $meta);
|
||
|
||
if ($items === null || count($items) === 0) {
|
||
ConversationContext::reset($ctxId);
|
||
return self::sendText(
|
||
'No se pudieron cargar las opciones ahora. Escribe *menu* para volver.',
|
||
$context['from'], $company
|
||
);
|
||
}
|
||
|
||
// Mapa label → id para match fuzzy (usado tanto en auto-select como en collecting)
|
||
$labelMap = [];
|
||
foreach ($items as $item) {
|
||
$labelMap[(string)($item[$labelField] ?? '')] = (string)($item[$valueField] ?? '');
|
||
}
|
||
|
||
// Auto-select cuando hay exactamente 1 opción — no mostrar lista
|
||
if (count($items) === 1) {
|
||
$meta = self::guardarSeleccion($meta, $group, $key, (string)($items[0][$valueField] ?? ''), (string)($items[0][$labelField] ?? ''));
|
||
ConversationContext::updateMetadata($ctxId, $meta);
|
||
return self::advanceAfterSelect($nextNode, $context, $company, $ctxId);
|
||
}
|
||
|
||
// Si el NLU extrajo entities, intentar auto-seleccionar sin mostrar lista.
|
||
// entity_key acota la búsqueda al campo propio: sin eso, una finca llamada
|
||
// "reposo" se compara contra grupos de mantenimiento y se consume al pedo.
|
||
$entities = $meta['__nlu_entities'] ?? [];
|
||
$entityKey = $flow['entity_key'] ?? '';
|
||
if (!empty($entities)) {
|
||
$propias = [];
|
||
if ($entityKey !== '') {
|
||
foreach ($entities as $ek => $ev) {
|
||
if (self::normalize((string)$ek) === self::normalize($entityKey)) $propias[$ek] = $ev;
|
||
}
|
||
} else {
|
||
$propias = $entities; // flow sin entity_key: comportamiento histórico
|
||
}
|
||
|
||
$matchedId = $matchedLabel = $matchedEk = null;
|
||
foreach ($propias as $ek => $ev) {
|
||
$evNorm = self::normalize((string)$ev);
|
||
if ($evNorm === '') continue;
|
||
foreach ($labelMap as $label => $id) {
|
||
$lNorm = self::normalize($label);
|
||
if ($lNorm === '') continue;
|
||
if ($lNorm === $evNorm || str_contains($evNorm, $lNorm) || str_contains($lNorm, $evNorm)) {
|
||
$matchedId = $id; $matchedLabel = $label; $matchedEk = $ek;
|
||
break 2;
|
||
}
|
||
}
|
||
}
|
||
|
||
if ($matchedId !== null) {
|
||
$meta = self::guardarSeleccion($meta, $group, $key, $matchedId, (string)$matchedLabel);
|
||
unset($meta['__nlu_entities'][$matchedEk]); // solo la consumida
|
||
if (empty($meta['__nlu_entities'])) unset($meta['__nlu_entities']);
|
||
ConversationContext::updateMetadata($ctxId, $meta);
|
||
return self::advanceAfterSelect($nextNode, $context, $company, $ctxId);
|
||
}
|
||
|
||
// Sin match: las entities de otro campo siguen sirviendo más adelante
|
||
if ($entityKey === '') {
|
||
unset($meta['__nlu_entities']);
|
||
ConversationContext::updateMetadata($ctxId, $meta);
|
||
}
|
||
}
|
||
|
||
// Opciones fijas que redirigen a otro flow en vez de guardar el valor
|
||
$redirectIds = [];
|
||
$appendOptions = $flow['append_options'] ?? [];
|
||
foreach ($appendOptions as $opt) {
|
||
$optId = (string)($opt['id'] ?? '');
|
||
if ($optId === '') continue;
|
||
$labelMap[(string)($opt['title'] ?? $optId)] = $optId;
|
||
$redirectIds[] = $optId;
|
||
}
|
||
|
||
// El destino ya quedó capturado en collecting.next_node; la intención se
|
||
// consume acá para que no secuestre la siguiente lista.
|
||
unset($meta['__after_select']);
|
||
$meta['collecting'] = [
|
||
'meta_group' => $group,
|
||
'meta_key' => $key,
|
||
'next_node' => $nextNode,
|
||
'__valid_ids' => array_merge(array_map('strval', array_column($items, $valueField)), $redirectIds),
|
||
'__label_map' => $labelMap,
|
||
'__redirect_ids'=> $redirectIds,
|
||
];
|
||
ConversationContext::updateMetadata($ctxId, $meta);
|
||
ConversationContext::updateNode($ctxId, 'collecting');
|
||
|
||
return self::buildDynamicListResponse($items, $flow, $context['from'], $company);
|
||
}
|
||
|
||
/**
|
||
* Bienvenida una vez por día y por empresa. Se envía por fuera del retorno
|
||
* de process() —igual que hace el selector multi-empresa— para no reemplazar
|
||
* la respuesta que el usuario vino a buscar.
|
||
*/
|
||
private static function saludarUnaVezAlDia(array $config, array $context, array $company, int $ctxId, string $permType): void
|
||
{
|
||
$welcome = $config['welcome'] ?? [];
|
||
if (empty($welcome['enabled'])) return;
|
||
|
||
$texto = (string)($welcome['text'][$permType] ?? '');
|
||
if (trim($texto) === '') return;
|
||
|
||
$meta = ConversationContext::getMetadata($ctxId);
|
||
$hoy = date('Y-m-d'); // el punto de entrada fija America/Bogota
|
||
if (($meta['__saludo'] ?? '') === $hoy) return;
|
||
|
||
$meta['__saludo'] = $hoy;
|
||
ConversationContext::updateMetadata($ctxId, $meta);
|
||
|
||
$nombre = trim((string)($context['name'] ?? ''));
|
||
$texto = strtr($texto, [
|
||
'{saludo}' => $nombre !== '' ? "Hola *{$nombre}*" : 'Hola',
|
||
'{empresa}' => (string)($company['display_name'] ?: $company['name'] ?? ''),
|
||
'{finca}' => (string)($meta['finca']['finca_label'] ?? ''),
|
||
'{empresas}' => self::esMultiEmpresa($context['from'] ?? '')
|
||
? "\n *cambiar empresa* para consultar otra empresa"
|
||
: '',
|
||
]);
|
||
|
||
WhatsAppSender::sendText($context['from'], $texto, (string)($context['phone_number_id'] ?? ''));
|
||
}
|
||
|
||
/** Solo tiene sentido ofrecer "cambiar empresa" a quien pertenece a varias. */
|
||
private static function esMultiEmpresa(string $waNumber): bool
|
||
{
|
||
if ($waNumber === '') return false;
|
||
try {
|
||
$stmt = db()->prepare("SELECT companies_json FROM multi_company_sessions WHERE wa_number = ? LIMIT 1");
|
||
$stmt->execute([$waNumber]);
|
||
$json = $stmt->fetchColumn();
|
||
return $json !== false && count(json_decode((string)$json, true) ?: []) > 1;
|
||
} catch (\PDOException $e) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Guarda la opción elegida con su etiqueta, para poder mostrarle al usuario
|
||
* en qué finca/grupo está parado sin tener que volver a consultar la API.
|
||
*/
|
||
private static function guardarSeleccion(array $meta, string $group, string $key, string $id, string $label): array
|
||
{
|
||
if ($group === '') return $meta;
|
||
$datos = $meta[$group] ?? [];
|
||
$datos[$key] = $id;
|
||
if ($label !== '') $datos[$group . '_label'] = $label;
|
||
$meta[$group] = $datos;
|
||
return $meta;
|
||
}
|
||
|
||
/**
|
||
* Continúa tras resolver una selección: consume la intención pendiente y
|
||
* las entities, y ejecuta el destino.
|
||
*/
|
||
private static function advanceAfterSelect(?string $nextNode, array $context, array $company, int $ctxId): ?array
|
||
{
|
||
// Las entities que no consumió esta lista pueden ser de la siguiente
|
||
// ("finca reposo, grupo plateo" resuelve las dos), así que no se borran acá.
|
||
$meta = ConversationContext::getMetadata($ctxId);
|
||
unset($meta['__after_select']);
|
||
ConversationContext::updateMetadata($ctxId, $meta);
|
||
|
||
if ($nextNode !== null) {
|
||
[$flows, $menus] = self::resolveFlowsMenus($company);
|
||
ConversationContext::updateNode($ctxId, $nextNode);
|
||
if (isset($flows[$nextNode])) {
|
||
return self::handleFlow($flows[$nextNode], $context, $company, $ctxId, $menus, $nextNode);
|
||
}
|
||
if (isset($menus[$nextNode])) {
|
||
return self::buildMenuResponse($menus[$nextNode], $context['from'], $company, $ctxId);
|
||
}
|
||
}
|
||
|
||
ConversationContext::updateNode($ctxId, null);
|
||
return null;
|
||
}
|
||
|
||
// Reemplaza {variable} en la URL con valores del metadata acumulado
|
||
private static function substituteUrlVars(string $url, array $meta): string
|
||
{
|
||
// Priority: __ep_vars_combined (vars collected/resolved for this endpoint call)
|
||
$epVars = $meta['__ep_vars_combined'] ?? [];
|
||
foreach ($epVars as $key => $value) {
|
||
$url = str_replace('{' . $key . '}', urlencode((string)$value), $url);
|
||
}
|
||
// Then the rest of meta groups
|
||
foreach ($meta as $group => $fields) {
|
||
if (!is_array($fields) || str_starts_with($group, '__')) continue;
|
||
foreach ($fields as $key => $value) {
|
||
$url = str_replace('{' . $key . '}', urlencode((string)$value), $url);
|
||
}
|
||
}
|
||
return $url;
|
||
}
|
||
|
||
private static function fetchDynamicList(string $endpointKey, array $company, array $meta = []): ?array
|
||
{
|
||
if ($endpointKey === '') return null;
|
||
|
||
$stmt = db()->prepare(
|
||
'SELECT url, method FROM company_endpoints WHERE company_id=? AND endpoint_key=? AND is_active=1 LIMIT 1'
|
||
);
|
||
$stmt->execute([(int)$company['id'], $endpointKey]);
|
||
$ep = $stmt->fetch();
|
||
if (!$ep || empty($ep['url'])) return null;
|
||
|
||
$apiKey = $company['api_key'] ?? '';
|
||
$url = self::buildUrl(self::substituteUrlVars($ep['url'], $meta), $company);
|
||
$ch = curl_init($url);
|
||
curl_setopt_array($ch, [
|
||
CURLOPT_RETURNTRANSFER => true,
|
||
CURLOPT_TIMEOUT => 10,
|
||
CURLOPT_HTTPHEADER => [
|
||
'Authorization: Bearer ' . $apiKey,
|
||
'X-API-Key: ' . $apiKey,
|
||
],
|
||
]);
|
||
$body = curl_exec($ch);
|
||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||
curl_close($ch);
|
||
|
||
if ($code !== 200 || !$body) return null;
|
||
$data = json_decode($body, true);
|
||
if (!is_array($data)) return null;
|
||
// Accept {data:[...]}, {datos:[...]}, or bare array
|
||
if (isset($data['data']) && is_array($data['data'])) return $data['data'];
|
||
if (isset($data['datos']) && is_array($data['datos'])) return $data['datos'];
|
||
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';
|
||
$labelField = $flow['label_field'] ?? 'name';
|
||
// Auto-detect multi-section if items carry a 'section' key
|
||
$sectionField = $flow['section_field'] ?? (isset($items[0]['section']) ? 'section' : null);
|
||
|
||
if ($sectionField !== null) {
|
||
$grouped = [];
|
||
foreach ($items as $item) {
|
||
$id = (string)($item[$valueField] ?? '');
|
||
if ($id === '') continue;
|
||
$title = mb_substr((string)($item[$labelField] ?? $id), 0, 24);
|
||
$sec = mb_substr((string)($item[$sectionField] ?? 'Opciones'), 0, 24);
|
||
$grouped[$sec][] = ['id' => $id, 'title' => $title];
|
||
}
|
||
$sections = [];
|
||
foreach (array_slice($grouped, 0, 10, true) as $secTitle => $rows) {
|
||
$sections[] = ['title' => $secTitle, 'rows' => array_slice($rows, 0, 10)];
|
||
}
|
||
} else {
|
||
// Con más opciones que filas disponibles se pagina: el último lugar
|
||
// queda para "Ver más". Sin esto las sobrantes eran inalcanzables.
|
||
$pagina = max(0, (int)($flow['__page'] ?? 0));
|
||
$hayMas = count($items) > self::LISTA_MAX_FILAS;
|
||
$porPagina = $hayMas ? self::LISTA_MAX_FILAS - 1 : self::LISTA_MAX_FILAS;
|
||
$visibles = array_slice($items, $pagina * $porPagina, $porPagina);
|
||
|
||
$rows = [];
|
||
foreach ($visibles as $item) {
|
||
$id = (string)($item[$valueField] ?? '');
|
||
if ($id === '') continue;
|
||
$rows[] = ['id' => $id, 'title' => mb_substr((string)($item[$labelField] ?? $id), 0, 24)];
|
||
}
|
||
|
||
$restantes = count($items) - (($pagina + 1) * $porPagina);
|
||
if ($hayMas && $restantes > 0) {
|
||
$rows[] = [
|
||
'id' => '__mas',
|
||
'title' => '➕ Ver más',
|
||
'description' => "Faltan {$restantes}",
|
||
];
|
||
}
|
||
$sections = [['title' => mb_substr($flow['section_title'] ?? 'Opciones', 0, 24), 'rows' => $rows]];
|
||
}
|
||
|
||
if (empty($sections)) return null;
|
||
|
||
// Añadir opciones fijas al final de la última sección
|
||
$appendOptions = $flow['append_options'] ?? [];
|
||
if (!empty($appendOptions)) {
|
||
$last = count($sections) - 1;
|
||
foreach ($appendOptions as $opt) {
|
||
$optId = (string)($opt['id'] ?? '');
|
||
if ($optId === '') continue;
|
||
$sections[$last]['rows'][] = [
|
||
'id' => $optId,
|
||
'title' => mb_substr((string)($opt['title'] ?? $optId), 0, 24),
|
||
];
|
||
}
|
||
}
|
||
|
||
return self::enqueueInteractive($to, [
|
||
'type' => 'list',
|
||
'header' => ['type' => 'text', 'text' => mb_substr($flow['header'] ?? 'Selecciona', 0, 60)],
|
||
'body' => ['text' => mb_substr($flow['body'] ?: 'Elige una opción:', 0, 1024)],
|
||
'footer' => ['text' => mb_substr($company['display_name'] ?? '', 0, 60)],
|
||
'action' => [
|
||
'button' => mb_substr($flow['button'] ?? 'Ver opciones', 0, 20),
|
||
'sections' => $sections,
|
||
],
|
||
], $company);
|
||
}
|
||
|
||
// ── collect_for_each — pide un valor por cada ítem de una lista dinámica ──
|
||
|
||
private static function handleCollectForEach(array $flow, array $context, array $company, int $ctxId): ?array
|
||
{
|
||
$meta = ConversationContext::getMetadata($ctxId);
|
||
$items = self::fetchDynamicList($flow['source_endpoint_key'] ?? '', $company, $meta);
|
||
|
||
// exclude_values descarta opciones que no son ítems reales, como el
|
||
// "🌐 Todas las fincas" (id 0) que el catálogo antepone para los informes.
|
||
$excluir = array_map('strval', $flow['exclude_values'] ?? []);
|
||
if ($excluir && is_array($items)) {
|
||
$vf = $flow['value_field'] ?? 'id';
|
||
$items = array_values(array_filter($items, fn($i) => !in_array((string)($i[$vf] ?? ''), $excluir, true)));
|
||
}
|
||
|
||
if (empty($items)) {
|
||
ConversationContext::reset($ctxId);
|
||
return self::sendText(
|
||
'⚠️ No se pudo cargar el listado. Escribe *menu* para volver.',
|
||
$context['from'], $company
|
||
);
|
||
}
|
||
|
||
// Si el NLU extrajo finca+valor, registrar solo ese ítem directamente
|
||
$entities = $meta['__nlu_entities'] ?? [];
|
||
unset($meta['__nlu_entities']);
|
||
if (!empty($entities)) {
|
||
$valueField = $flow['value_field'] ?? 'id';
|
||
$labelField = $flow['label_field'] ?? 'name';
|
||
$entityVal = null;
|
||
$matchedId = null;
|
||
$matchedLabel = null;
|
||
|
||
// Buscar valor numérico en entities
|
||
foreach ($entities as $ek => $ev) {
|
||
if (in_array(self::normalize($ek), ['valor','value','mm','cantidad','qty'], true) && is_numeric(str_replace(',', '.', (string)$ev))) {
|
||
$entityVal = str_replace(',', '.', (string)$ev);
|
||
}
|
||
}
|
||
|
||
// Buscar finca que coincida en entities
|
||
foreach ($entities as $ek => $ev) {
|
||
if (in_array(self::normalize($ek), ['valor','value','mm','cantidad','qty'], true)) continue;
|
||
$evNorm = self::normalize((string)$ev);
|
||
foreach ($items as $item) {
|
||
if (self::normalize((string)($item[$labelField] ?? '')) === $evNorm) {
|
||
$matchedId = (string)($item[$valueField] ?? '');
|
||
$matchedLabel = (string)($item[$labelField] ?? '');
|
||
break 2;
|
||
}
|
||
}
|
||
}
|
||
|
||
if ($matchedId !== null && $entityVal !== null) {
|
||
ConversationContext::updateMetadata($ctxId, $meta);
|
||
ConversationContext::reset($ctxId);
|
||
// POST directo para ese ítem
|
||
$epKey = $flow['endpoint_key'] ?? '';
|
||
$stmt = db()->prepare("SELECT url, method FROM company_endpoints WHERE company_id=? AND endpoint_key=? AND is_active=1 LIMIT 1");
|
||
$stmt->execute([(int)$company['id'], $epKey]);
|
||
$ep = $stmt->fetch();
|
||
if ($ep && !empty($ep['url'])) {
|
||
$url = self::buildUrl($ep['url'], $company);
|
||
$apiKey = $company['api_key'] ?? '';
|
||
$body = json_encode([$valueField => $matchedId, 'valor' => $entityVal]);
|
||
$ch = curl_init($url);
|
||
curl_setopt_array($ch, [
|
||
CURLOPT_POST => true,
|
||
CURLOPT_POSTFIELDS => $body,
|
||
CURLOPT_HTTPHEADER => ['Content-Type: application/json', "Authorization: Bearer {$apiKey}", "X-API-Key: {$apiKey}"],
|
||
CURLOPT_RETURNTRANSFER => true,
|
||
CURLOPT_TIMEOUT => 15,
|
||
]);
|
||
$resp = curl_exec($ch);
|
||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||
curl_close($ch);
|
||
if ($code >= 200 && $code < 300) {
|
||
$successText = $flow['success_text'] ?? '✅ Datos registrados correctamente.';
|
||
return self::sendText("✅ *{$matchedLabel}*: {$entityVal} registrado.\n{$successText}", $context['from'], $company);
|
||
}
|
||
}
|
||
return self::sendText('⚠️ No se pudo registrar. Intenta de nuevo o escribe *menu*.', $context['from'], $company);
|
||
}
|
||
ConversationContext::updateMetadata($ctxId, $meta);
|
||
}
|
||
|
||
$meta['__foreach'] = [
|
||
'items' => $items,
|
||
'index' => 0,
|
||
'collected' => [],
|
||
'value_field' => $flow['value_field'] ?? 'id',
|
||
'label_field' => $flow['label_field'] ?? 'name',
|
||
'question' => $flow['question'] ?? '¿Cuál es el valor?',
|
||
'header' => $flow['header'] ?? '📋 Ingreso de datos',
|
||
'endpoint_key'=> $flow['endpoint_key'] ?? '',
|
||
'success_text'=> $flow['success_text'] ?? '✅ Datos registrados correctamente.',
|
||
'items_key' => $flow['items_key'] ?? 'fincas',
|
||
'ask_date' => (bool)($flow['ask_date'] ?? false),
|
||
'fecha' => null,
|
||
];
|
||
ConversationContext::updateMetadata($ctxId, $meta);
|
||
|
||
// El dato se registra contra una fecha; se pregunta antes del bucle
|
||
// para no repetirla en cada ítem.
|
||
if ($meta['__foreach']['ask_date']) {
|
||
return self::foreachAskDate($flow, $context['from'], $company);
|
||
}
|
||
|
||
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
|
||
{
|
||
$hoy = date('Y-m-d');
|
||
return self::enqueueInteractive($to, [
|
||
'type' => 'list',
|
||
'header' => ['type' => 'text', 'text' => mb_substr($flow['header'] ?? '📋 Registro', 0, 60)],
|
||
'body' => ['text' => $flow['date_question'] ?? '📅 ¿De qué fecha es el registro?'],
|
||
'footer' => ['text' => ''],
|
||
'action' => ['button' => 'Ver fechas', 'sections' => [[
|
||
'title' => 'Fecha',
|
||
'rows' => [
|
||
['id' => $hoy, 'title' => '📅 Hoy', 'description' => $hoy],
|
||
['id' => date('Y-m-d', strtotime('-1 day')), 'title' => '📅 Ayer', 'description' => date('Y-m-d', strtotime('-1 day'))],
|
||
['id' => date('Y-m-d', strtotime('-2 days')), 'title' => '📅 Anteayer', 'description' => date('Y-m-d', strtotime('-2 days'))],
|
||
['id' => '__fe_other', 'title' => '✏️ Otra fecha', 'description' => 'Escribe la fecha'],
|
||
],
|
||
]]],
|
||
], $company);
|
||
}
|
||
|
||
private static function handleForeachInput(array $meta, string $input, array $context, array $company, int $ctxId, array $flows): ?array
|
||
{
|
||
$fe = $meta['__foreach'];
|
||
$items = $fe['items'];
|
||
$idx = (int)$fe['index'];
|
||
|
||
// ── Fecha del registro, antes de recorrer los ítems ──────────────────
|
||
if (!empty($fe['ask_date']) && ($fe['fecha'] ?? null) === null) {
|
||
$v = trim($input);
|
||
|
||
if ($v === '__fe_other') {
|
||
$fe['awaiting_date'] = true;
|
||
$meta['__foreach'] = $fe;
|
||
ConversationContext::updateMetadata($ctxId, $meta);
|
||
return self::sendText('✏️ Escribe la fecha (YYYY-MM-DD, ej: ' . date('Y-m-d') . '):', $context['from'], $company);
|
||
}
|
||
|
||
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $v) || !strtotime($v)) {
|
||
return self::sendText('⚠️ Formato inválido. Escribe la fecha como YYYY-MM-DD (ej: ' . date('Y-m-d') . '):', $context['from'], $company);
|
||
}
|
||
|
||
$fe['fecha'] = $v;
|
||
unset($fe['awaiting_date']);
|
||
$meta['__foreach'] = $fe;
|
||
ConversationContext::updateMetadata($ctxId, $meta);
|
||
return self::foreachAskNext($fe, $context['from'], $company);
|
||
}
|
||
|
||
// Validate: must be a number
|
||
if (!is_numeric(str_replace(',', '.', $input))) {
|
||
$item = $items[$idx];
|
||
$label = $item[$fe['label_field']] ?? "Item " . ($idx + 1);
|
||
return self::sendText(
|
||
"⚠️ Ingresa solo un número.\n\n*{$label}*\n{$fe['question']}",
|
||
$context['from'], $company
|
||
);
|
||
}
|
||
|
||
$item = $items[$idx];
|
||
$key = (string)($item[$fe['value_field']] ?? $idx);
|
||
$label = (string)($item[$fe['label_field']] ?? $key);
|
||
$value = str_replace(',', '.', trim($input));
|
||
|
||
$fe['collected'][$key] = ['label' => $label, 'valor' => $value];
|
||
$fe['index'] = $idx + 1;
|
||
$meta['__foreach'] = $fe;
|
||
ConversationContext::updateMetadata($ctxId, $meta);
|
||
|
||
// More items to collect?
|
||
if ($fe['index'] < count($items)) {
|
||
return self::foreachAskNext($fe, $context['from'], $company);
|
||
}
|
||
|
||
// All collected — show summary and confirm/cancel buttons
|
||
$summary = "*📊 Resumen — {$fe['header']}*\n";
|
||
if (!empty($fe['fecha'])) $summary .= "📅 {$fe['fecha']}\n";
|
||
$summary .= "\n";
|
||
foreach ($fe['collected'] as $row) {
|
||
$summary .= "• {$row['label']}: *{$row['valor']}*\n";
|
||
}
|
||
$summary .= "\n¿Confirmas el registro?";
|
||
|
||
return self::enqueueInteractive($context['from'], [
|
||
'type' => 'button',
|
||
'body' => ['text' => $summary],
|
||
'action' => ['buttons' => [
|
||
['type' => 'reply', 'reply' => ['id' => '__foreach_confirm', 'title' => '✅ Confirmar']],
|
||
['type' => 'reply', 'reply' => ['id' => '__foreach_cancel', 'title' => '❌ Cancelar']],
|
||
]],
|
||
], $company);
|
||
}
|
||
|
||
private static function foreachAskNext(array $fe, string $to, array $company): ?array
|
||
{
|
||
$idx = (int)$fe['index'];
|
||
$item = $fe['items'][$idx];
|
||
$label = $item[$fe['label_field']] ?? "Item " . ($idx + 1);
|
||
$total = count($fe['items']);
|
||
|
||
$text = "*{$fe['header']}* ({$idx}/{$total})\n\n"
|
||
. "📍 *{$label}*\n"
|
||
. $fe['question'];
|
||
|
||
return self::sendText($text, $to, $company);
|
||
}
|
||
|
||
private static function submitForeach(array $meta, array $context, array $company, int $ctxId): ?array
|
||
{
|
||
$fe = $meta['__foreach'];
|
||
$endpointKey = $fe['endpoint_key'] ?? '';
|
||
$nav = "\n\nEscribe *menú* para volver al inicio, *atrás* para subir un nivel o *salir* para terminar.";
|
||
|
||
// El ERP espera {fecha, <items_key>: [{id, label, valor}]}; telefono y
|
||
// nombre los lee el controller para dejar trazabilidad en bot_entrada.
|
||
// (string) explícito: PHP convierte las claves numéricas del array en int
|
||
// y el JSON saldría con tipos distintos según el id de la finca.
|
||
$lecturas = [];
|
||
foreach ($fe['collected'] as $id => $row) {
|
||
$lecturas[] = ['id' => (string)$id, 'label' => $row['label'], 'valor' => $row['valor']];
|
||
}
|
||
$body = [
|
||
'fecha' => $fe['fecha'] ?? date('Y-m-d'),
|
||
'telefono' => $context['from'],
|
||
'nombre' => $context['name'] ?? '',
|
||
($fe['items_key'] ?? 'fincas') => $lecturas,
|
||
];
|
||
|
||
unset($meta['__foreach']);
|
||
ConversationContext::updateMetadata($ctxId, $meta);
|
||
ConversationContext::reset($ctxId);
|
||
|
||
if ($endpointKey === '') {
|
||
return self::sendText('⚠️ Endpoint de envío no configurado.' . $nav, $context['from'], $company);
|
||
}
|
||
|
||
$stmt = db()->prepare("SELECT url, method FROM company_endpoints WHERE company_id=? AND endpoint_key=? AND is_active=1 LIMIT 1");
|
||
$stmt->execute([(int)$company['id'], $endpointKey]);
|
||
$ep = $stmt->fetch();
|
||
|
||
if (!$ep || empty($ep['url'])) {
|
||
return self::sendText('⚠️ Endpoint no configurado. Contacta al administrador.' . $nav, $context['from'], $company);
|
||
}
|
||
|
||
$apiKey = $company['api_key'] ?? '';
|
||
$ch = curl_init(self::buildUrl($ep['url'], $company));
|
||
curl_setopt_array($ch, [
|
||
CURLOPT_RETURNTRANSFER => true,
|
||
CURLOPT_POST => true,
|
||
CURLOPT_POSTFIELDS => json_encode($body),
|
||
CURLOPT_TIMEOUT => 15,
|
||
CURLOPT_HTTPHEADER => [
|
||
'Content-Type: application/json',
|
||
'Authorization: Bearer ' . $apiKey,
|
||
'X-API-Key: ' . $apiKey,
|
||
],
|
||
]);
|
||
$response = curl_exec($ch);
|
||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||
$curlErr = curl_error($ch);
|
||
curl_close($ch);
|
||
|
||
if ($curlErr || $httpCode >= 400) {
|
||
self::log("collect_for_each POST error [{$endpointKey}] HTTP {$httpCode}: {$curlErr}");
|
||
return self::sendText('⚠️ Error al guardar los datos. Intenta de nuevo.' . $nav, $context['from'], $company);
|
||
}
|
||
|
||
return self::sendText($fe['success_text'] . $nav, $context['from'], $company);
|
||
}
|
||
|
||
// ── collect_and_post — pide campos definidos en config y hace POST ─────────
|
||
|
||
private static function handleCollectAndPost(array $flow, array $context, array $company, int $ctxId): ?array
|
||
{
|
||
$fields = $flow['fields'] ?? [];
|
||
|
||
// If no inline fields, auto-detect from endpoint's body_fields definition
|
||
if (empty($fields)) {
|
||
$epKey = $flow['endpoint_key'] ?? '';
|
||
if ($epKey !== '') {
|
||
$stmt = db()->prepare("SELECT body_fields FROM company_endpoints WHERE company_id=? AND endpoint_key=? AND is_active=1 LIMIT 1");
|
||
$stmt->execute([(int)$company['id'], $epKey]);
|
||
$row = $stmt->fetch();
|
||
if ($row && !empty($row['body_fields'])) {
|
||
$fields = json_decode($row['body_fields'], true) ?: [];
|
||
}
|
||
}
|
||
}
|
||
|
||
if (empty($fields)) {
|
||
ConversationContext::reset($ctxId);
|
||
return self::sendText('⚠️ Flujo sin campos configurados. Define los campos en el endpoint. Escribe *menu* para volver.', $context['from'], $company);
|
||
}
|
||
|
||
$meta = ConversationContext::getMetadata($ctxId);
|
||
$entities = $meta['__nlu_entities'] ?? [];
|
||
unset($meta['__nlu_entities']);
|
||
|
||
$collected = [];
|
||
$startIdx = 0;
|
||
if (!empty($entities)) {
|
||
foreach ($fields as $i => $field) {
|
||
$fKey = $field['key'] ?? '';
|
||
$fLabel = $field['label'] ?? '';
|
||
foreach ($entities as $ek => $ev) {
|
||
if (self::normalize($ek) === self::normalize($fKey) ||
|
||
self::normalize($ek) === self::normalize($fLabel)) {
|
||
$collected[$fKey] = (string)$ev;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
// Advance index past consecutive pre-filled leading fields
|
||
while ($startIdx < count($fields) && isset($collected[$fields[$startIdx]['key'] ?? ''])) {
|
||
$startIdx++;
|
||
}
|
||
}
|
||
|
||
$meta['__cap'] = [
|
||
'fields' => $fields,
|
||
'index' => $startIdx,
|
||
'collected' => $collected,
|
||
'prefilled' => $collected,
|
||
'endpoint_key' => $flow['endpoint_key'] ?? '',
|
||
'success_text' => $flow['success_text'] ?? '✅ Datos registrados correctamente.',
|
||
'confirm' => (bool)($flow['confirm'] ?? true),
|
||
'header' => $flow['header'] ?? '📋 Registro',
|
||
];
|
||
ConversationContext::updateMetadata($ctxId, $meta);
|
||
|
||
return self::capAskNext($meta['__cap'], $context['from'], $company, $ctxId);
|
||
}
|
||
|
||
private static function handleCapInput(array $meta, string $input, array $context, array $company, int $ctxId): ?array
|
||
{
|
||
$cap = $meta['__cap'];
|
||
$fields = $cap['fields'];
|
||
$idx = (int)$cap['index'];
|
||
$field = $fields[$idx];
|
||
$value = trim($input);
|
||
|
||
// ── 1. Awaiting manual text after user picked "Otra opción" ──────────
|
||
if (!empty($cap['__awaiting_other'])) {
|
||
if (($field['other_validate'] ?? '') === 'date'
|
||
&& !preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) {
|
||
return self::sendText(
|
||
'⚠️ Formato inválido. ' . ($field['other_prompt'] ?? 'Ingresa la fecha como YYYY-MM-DD (ej: ' . date('Y-m-d') . '):'),
|
||
$context['from'], $company
|
||
);
|
||
}
|
||
if ($err = self::capValidar($field, $value, $cap['collected'])) {
|
||
return self::sendText("*{$cap['header']}*\n\n{$err}", $context['from'], $company);
|
||
}
|
||
$cap['collected'][$field['key']] = $value;
|
||
$cap['collected_labels'][$field['key']] = $value;
|
||
unset($cap['__awaiting_other']);
|
||
$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);
|
||
if ($cap['index'] < count($fields)) {
|
||
return self::capAskNext($cap, $context['from'], $company, $ctxId);
|
||
}
|
||
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;
|
||
$meta['__cap'] = $cap;
|
||
ConversationContext::updateMetadata($ctxId, $meta);
|
||
return self::capAskNext($cap, $context['from'], $company, $ctxId);
|
||
}
|
||
|
||
// ── 2. User chose "Otra opción" — ask for manual text ────────────────
|
||
if ($value === '__cap_other') {
|
||
$cap['__awaiting_other'] = true;
|
||
$meta['__cap'] = $cap;
|
||
ConversationContext::updateMetadata($ctxId, $meta);
|
||
$prompt = $field['other_prompt'] ?? '✏️ Ingresa el valor manualmente:';
|
||
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,
|
||
$field['lookup_param'] ?? 'q',
|
||
$company
|
||
);
|
||
|
||
$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[$df] ?? 'Desconocido');
|
||
$entityId = (string)($result[$vf] ?? '');
|
||
|
||
$cap['__lookup_pending'] = ['id' => $entityId, 'label' => $displayName];
|
||
$meta['__cap'] = $cap;
|
||
ConversationContext::updateMetadata($ctxId, $meta);
|
||
|
||
return self::enqueueInteractive($context['from'], [
|
||
'type' => 'button',
|
||
'body' => ['text' => "¿Es *{$displayName}*?"],
|
||
'action' => ['buttons' => [
|
||
['type' => 'reply', 'reply' => ['id' => '__cap_lookup_yes', 'title' => '✅ Sí']],
|
||
['type' => 'reply', 'reply' => ['id' => '__cap_lookup_no', 'title' => '❌ No, reintentar']],
|
||
]],
|
||
], $company);
|
||
}
|
||
|
||
// ── 4. Normal save ────────────────────────────────────────────────────
|
||
if ($err = self::capValidar($field, $value, $cap['collected'])) {
|
||
// El aviso va aparte para poder volver a mostrar el selector debajo
|
||
WhatsAppSender::sendText($context['from'], $err, (string)($context['phone_number_id'] ?? ''));
|
||
return self::capAskNext($cap, $context['from'], $company, $ctxId);
|
||
}
|
||
$cap['collected'][$field['key']] = $value;
|
||
|
||
if (($field['type'] ?? 'text') === 'select') {
|
||
$cap['collected_labels'][$field['key']] =
|
||
$cap['__selects'][$field['key']][$value] ?? $value;
|
||
}
|
||
|
||
$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);
|
||
|
||
if ($cap['index'] < count($fields)) {
|
||
return self::capAskNext($cap, $context['from'], $company, $ctxId);
|
||
}
|
||
return self::capShowConfirmOrSubmit($cap, $meta, $context, $company, $ctxId);
|
||
}
|
||
|
||
private static function capShowConfirmOrSubmit(array $cap, array $meta, array $context, array $company, int $ctxId): ?array
|
||
{
|
||
$fields = $cap['fields'];
|
||
if ($cap['confirm']) {
|
||
$summary = "*{$cap['header']}*\n\n";
|
||
foreach ($cap['collected'] as $k => $v) {
|
||
$label = self::capFieldLabel($fields, $k);
|
||
$display = $cap['collected_labels'][$k] ?? $v;
|
||
$summary .= "• {$label}: *{$display}*\n";
|
||
}
|
||
$summary .= "\n¿Confirmas el registro?";
|
||
return self::enqueueInteractive($context['from'], [
|
||
'type' => 'button',
|
||
'body' => ['text' => $summary],
|
||
'action' => ['buttons' => [
|
||
['type' => 'reply', 'reply' => ['id' => '__cap_confirm', 'title' => '✅ Confirmar']],
|
||
['type' => 'reply', 'reply' => ['id' => '__cap_cancel', 'title' => '❌ Cancelar']],
|
||
]],
|
||
], $company);
|
||
}
|
||
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;
|
||
$stmt = db()->prepare('SELECT url FROM company_endpoints WHERE company_id=? AND endpoint_key=? AND is_active=1 LIMIT 1');
|
||
$stmt->execute([(int)$company['id'], $epKey]);
|
||
$ep = $stmt->fetch();
|
||
if (!$ep || empty($ep['url'])) return null;
|
||
|
||
$url = self::buildUrl($ep['url'], $company) . '&' . urlencode($param) . '=' . urlencode($value);
|
||
$ch = curl_init($url);
|
||
curl_setopt_array($ch, [
|
||
CURLOPT_RETURNTRANSFER => true,
|
||
CURLOPT_TIMEOUT => 10,
|
||
CURLOPT_HTTPHEADER => [
|
||
'Authorization: Bearer ' . ($company['api_key'] ?? ''),
|
||
'X-API-Key: ' . ($company['api_key'] ?? ''),
|
||
],
|
||
]);
|
||
$body = curl_exec($ch);
|
||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||
curl_close($ch);
|
||
|
||
if ($code !== 200 || !$body) return null;
|
||
$data = json_decode($body, true);
|
||
return (is_array($data) && ($data['status'] ?? '') === '1') ? ($data['datos'] ?? null) : null;
|
||
}
|
||
|
||
private static function capAskNext(array $cap, string $to, array $company, int $ctxId = 0): ?array
|
||
{
|
||
// Saltar campos ya pre-llenados (no contiguos)
|
||
$prefilled = $cap['prefilled'] ?? [];
|
||
while ((int)$cap['index'] < count($cap['fields'])) {
|
||
$f = $cap['fields'][(int)$cap['index']];
|
||
if (isset($prefilled[$f['key'] ?? '']) && !isset($cap['collected'][$f['key'] ?? ''])) {
|
||
$cap['collected'][$f['key']] = $prefilled[$f['key']];
|
||
$cap['index']++;
|
||
if ($ctxId > 0) {
|
||
$m = ConversationContext::getMetadata($ctxId);
|
||
$m['__cap'] = $cap;
|
||
ConversationContext::updateMetadata($ctxId, $m);
|
||
}
|
||
} else {
|
||
break;
|
||
}
|
||
}
|
||
|
||
$idx = (int)$cap['index'];
|
||
$total = count($cap['fields']);
|
||
$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';
|
||
$lf = $field['label_field'] ?? 'label';
|
||
$items = self::fetchDynamicList($field['source_endpoint_key'] ?? '', $company, ['__ep_vars_combined' => $cap['collected'] ?? []]);
|
||
if (!empty($items)) {
|
||
if ($ctxId > 0) {
|
||
$cap['__selects'][$field['key']] = array_column($items, $lf, $vf);
|
||
$m = ConversationContext::getMetadata($ctxId);
|
||
$m['__cap'] = $cap;
|
||
ConversationContext::updateMetadata($ctxId, $m);
|
||
}
|
||
return self::buildDynamicListResponse($items, [
|
||
'value_field' => $vf,
|
||
'label_field' => $lf,
|
||
'header' => $cap['header'],
|
||
'body' => $field['prompt'] ?? ('Selecciona ' . ($field['label'] ?? $field['key'])),
|
||
'button' => 'Ver opciones',
|
||
'section_title' => $field['label'] ?? $field['key'],
|
||
'__page' => (int)($cap['__page'][$field['key']] ?? 0),
|
||
], $to, $company);
|
||
}
|
||
}
|
||
|
||
// ── date_quick: Hoy / Ayer / Anteayer / Otra ────────────────────────
|
||
if ($type === 'date_quick') {
|
||
$hoy = date('Y-m-d');
|
||
$ayer = date('Y-m-d', strtotime('-1 day'));
|
||
$ant = date('Y-m-d', strtotime('-2 days'));
|
||
return self::enqueueInteractive($to, [
|
||
'type' => 'list',
|
||
'header' => ['type' => 'text', 'text' => mb_substr($cap['header'], 0, 60)],
|
||
'body' => ['text' => $field['prompt'] ?? '📅 ¿Cuál es la fecha?'],
|
||
'footer' => ['text' => ''],
|
||
'action' => ['button' => 'Ver fechas', 'sections' => [[
|
||
'title' => 'Fecha',
|
||
'rows' => [
|
||
['id' => $hoy, 'title' => '📅 Hoy', 'description' => $hoy],
|
||
['id' => $ayer, 'title' => '📅 Ayer', 'description' => $ayer],
|
||
['id' => $ant, 'title' => '📅 Anteayer', 'description' => $ant],
|
||
['id' => '__cap_other', 'title' => '✏️ Otra fecha', 'description' => 'Escribe la fecha'],
|
||
],
|
||
]]],
|
||
], $company);
|
||
}
|
||
|
||
// ── static_select: inline options ───────────────────────────────────
|
||
if ($type === 'static_select') {
|
||
$options = $field['options'] ?? [];
|
||
$rows = array_map(fn($o) => [
|
||
'id' => (string)$o['id'],
|
||
'title' => mb_substr((string)$o['label'], 0, 24),
|
||
], $options);
|
||
|
||
if (count($rows) <= 3) {
|
||
return self::enqueueInteractive($to, [
|
||
'type' => 'button',
|
||
'body' => ['text' => "*{$cap['header']}*\n\n" . ($field['prompt'] ?? 'Selecciona:')],
|
||
'action' => ['buttons' => array_map(fn($r) => [
|
||
'type' => 'reply', 'reply' => ['id' => $r['id'], 'title' => $r['title']],
|
||
], $rows)],
|
||
], $company);
|
||
}
|
||
|
||
return self::enqueueInteractive($to, [
|
||
'type' => 'list',
|
||
'header' => ['type' => 'text', 'text' => mb_substr($cap['header'], 0, 60)],
|
||
'body' => ['text' => $field['prompt'] ?? 'Selecciona una opción:'],
|
||
'footer' => ['text' => ''],
|
||
'action' => ['button' => 'Ver opciones', 'sections' => [[
|
||
'title' => $field['label'] ?? 'Opciones',
|
||
'rows' => $rows,
|
||
]]],
|
||
], $company);
|
||
}
|
||
|
||
// ── lookup / text: plain text prompt ────────────────────────────────
|
||
$prompt = $field['prompt'] ?? ('Campo: ' . $field['key']);
|
||
$text = "*{$cap['header']}* ({$idx}/{$total})\n\n{$prompt}";
|
||
return self::sendText($text, $to, $company);
|
||
}
|
||
|
||
/**
|
||
* Validación entre campos. min_field evita rangos invertidos (una fecha
|
||
* final anterior a la inicial), que el ERP acepta y recién detecta el
|
||
* revisor. Las fechas van en YYYY-MM-DD, así que comparar como texto basta.
|
||
*/
|
||
private static function capValidar(array $field, string $value, array $collected): ?string
|
||
{
|
||
$min = $field['min_field'] ?? '';
|
||
if ($min === '' || ($collected[$min] ?? '') === '') return null;
|
||
if ($value >= $collected[$min]) return null;
|
||
|
||
return $field['min_error']
|
||
?? "⚠️ No puede ser anterior a *{$collected[$min]}*. Elige una fecha igual o posterior:";
|
||
}
|
||
|
||
// skip_if: {"field":"novedad_id","not_in":["35","41"]} → skip this field if collected[field] not in list
|
||
private static function capShouldSkip(array $field, array $collected): bool
|
||
{
|
||
$si = $field['skip_if'] ?? null;
|
||
if (!$si) return false;
|
||
$val = (string)($collected[$si['field'] ?? ''] ?? '');
|
||
if (isset($si['not_in'])) return !in_array($val, array_map('strval', $si['not_in']));
|
||
if (isset($si['equals'])) return $val !== (string)$si['equals'];
|
||
return false;
|
||
}
|
||
|
||
private static function capFieldLabel(array $fields, string $key): string
|
||
{
|
||
foreach ($fields as $f) {
|
||
if (($f['key'] ?? '') === $key) return $f['label'] ?? $f['key'];
|
||
}
|
||
return $key;
|
||
}
|
||
|
||
private static function submitCapPost(array $meta, array $context, array $company, int $ctxId): ?array
|
||
{
|
||
$cap = $meta['__cap'];
|
||
$endpointKey = $cap['endpoint_key'] ?? '';
|
||
$nav = "\n\nEscribe *menú* para volver al inicio, *atrás* para subir un nivel o *salir* para terminar.";
|
||
|
||
unset($meta['__cap']);
|
||
ConversationContext::updateMetadata($ctxId, $meta);
|
||
ConversationContext::reset($ctxId);
|
||
|
||
if ($endpointKey === '') {
|
||
return self::sendText('⚠️ Endpoint no configurado.' . $nav, $context['from'], $company);
|
||
}
|
||
|
||
$stmt = db()->prepare("SELECT url, method FROM company_endpoints WHERE company_id=? AND endpoint_key=? AND is_active=1 LIMIT 1");
|
||
$stmt->execute([(int)$company['id'], $endpointKey]);
|
||
$ep = $stmt->fetch();
|
||
|
||
if (!$ep || empty($ep['url'])) {
|
||
return self::sendText('⚠️ Endpoint no encontrado. Contacta al administrador.' . $nav, $context['from'], $company);
|
||
}
|
||
|
||
$apiKey = $company['api_key'] ?? '';
|
||
$payload = array_merge($cap['collected'], [
|
||
'telefono' => $context['from'],
|
||
'nombre' => $context['name'] ?? '',
|
||
]);
|
||
|
||
// 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,
|
||
CURLOPT_POSTFIELDS => json_encode($payload),
|
||
CURLOPT_TIMEOUT => 15,
|
||
CURLOPT_HTTPHEADER => [
|
||
'Content-Type: application/json',
|
||
'Authorization: Bearer ' . $apiKey,
|
||
'X-API-Key: ' . $apiKey,
|
||
],
|
||
]);
|
||
$response = curl_exec($ch);
|
||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||
$curlErr = curl_error($ch);
|
||
curl_close($ch);
|
||
|
||
if ($curlErr || $httpCode >= 400) {
|
||
self::log("collect_and_post error [{$endpointKey}] HTTP {$httpCode}: {$curlErr}");
|
||
return self::sendText('⚠️ Error al guardar los datos. Intenta de nuevo.' . $nav, $context['from'], $company);
|
||
}
|
||
|
||
return self::sendText($cap['success_text'] . $nav, $context['from'], $company);
|
||
}
|
||
|
||
// ── Submit form (end of multi-step collection) ───────────────────────────
|
||
|
||
private static function handleSubmitForm(array $flow, array $context, array $company, int $ctxId): ?array
|
||
{
|
||
$meta = ConversationContext::getMetadata($ctxId);
|
||
$group = $flow['meta_group'] ?? '';
|
||
|
||
$formData = $meta[$group] ?? [];
|
||
unset($meta[$group], $meta['collecting']);
|
||
ConversationContext::updateMetadata($ctxId, $meta);
|
||
|
||
$params = [
|
||
'endpoint_key' => $flow['endpoint_key'] ?? '',
|
||
'caption' => $flow['caption'] ?? 'Informe generado.',
|
||
'filename' => $flow['filename'] ?? 'reporte.pdf',
|
||
'query' => $formData, // form fields become URL query params
|
||
];
|
||
|
||
return self::executeApiReport($params, $context, $company, $ctxId);
|
||
}
|
||
|
||
// ── Flow dispatcher ──────────────────────────────────────────────────────
|
||
|
||
private static function resolveMenu($menuRef, array $company, array $allMenus = []): array
|
||
{
|
||
if (is_string($menuRef)) {
|
||
if (isset($allMenus[$menuRef])) return $allMenus[$menuRef];
|
||
$config = self::getConfig($company);
|
||
return ($config['menus'] ?? [])[$menuRef] ?? [];
|
||
}
|
||
return is_array($menuRef) ? $menuRef : [];
|
||
}
|
||
|
||
/**
|
||
* Flows y menús efectivos del usuario (globales + overrides de su categoría).
|
||
* Centraliza el merge que antes se repetía —y en un caso se olvidaba— en varios puntos.
|
||
*/
|
||
private static function resolveFlowsMenus(array $company): array
|
||
{
|
||
$cfg = self::getConfig($company);
|
||
$permType = (string)($company['_permission_type'] ?? 1);
|
||
$pt = $cfg['per_type'][$permType] ?? [];
|
||
return [
|
||
array_merge($cfg['flows'] ?? [], $pt['flows'] ?? []),
|
||
array_merge($cfg['menus'] ?? [], $pt['menus'] ?? []),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Si el flow necesita un dato que aún no está en meta, guarda la intención y
|
||
* devuelve el selector que lo resuelve. Al elegir, handleDynamicList retoma
|
||
* el destino original en vez de su next_node.
|
||
*/
|
||
private static function divertToResolver(array $flow, ?string $flowKey, array $context, array $company, int $ctxId, array $flows, array $menus): ?array
|
||
{
|
||
$requires = $flow['requires'] ?? [];
|
||
$resolver = $flow['resolver'] ?? '';
|
||
if (!$requires || $resolver === '' || $flowKey === null || !isset($flows[$resolver])) return null;
|
||
|
||
$meta = ConversationContext::getMetadata($ctxId);
|
||
|
||
foreach ($requires as $group => $key) {
|
||
if (($meta[$group][$key] ?? '') !== '') continue;
|
||
|
||
// Un solo intento por destino: si el resolver ya corrió y aun así falta
|
||
// el dato (mal configurado), seguir de largo en vez de rebotar sin fin.
|
||
if (($meta['__resolve_attempt'] ?? '') === $flowKey) {
|
||
self::log("Resolver '{$resolver}' no dejó {$group}.{$key} para '{$flowKey}'");
|
||
return null;
|
||
}
|
||
|
||
$meta['__after_select'] = $flowKey;
|
||
$meta['__resolve_attempt'] = $flowKey;
|
||
ConversationContext::updateMetadata($ctxId, $meta);
|
||
ConversationContext::updateNode($ctxId, $resolver);
|
||
return self::handleFlow($flows[$resolver], $context, $company, $ctxId, $menus, $resolver);
|
||
}
|
||
|
||
// Requisitos cumplidos: se limpia la marca para el próximo informe
|
||
if (isset($meta['__resolve_attempt'])) {
|
||
unset($meta['__resolve_attempt']);
|
||
ConversationContext::updateMetadata($ctxId, $meta);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
private static function handleFlow(array $flow, array $context, array $company, int $ctxId, array $menus = [], ?string $flowKey = null): ?array
|
||
{
|
||
$type = $flow['type'] ?? 'text';
|
||
|
||
if (!empty($flow['requires'])) {
|
||
[$allFlows, $allMenus] = self::resolveFlowsMenus($company);
|
||
$diverted = self::divertToResolver($flow, $flowKey, $context, $company, $ctxId, $allFlows, $allMenus);
|
||
if ($diverted !== null) return $diverted;
|
||
}
|
||
|
||
return match ($type) {
|
||
'text' => self::sendText($flow['message'] ?? '', $context['from'], $company),
|
||
'image' => self::sendImage($flow['media_id'] ?? '', $flow['caption'] ?? null, $context['from'], $company),
|
||
'menu' => self::buildMenuResponse(self::resolveMenu($flow['menu'] ?? [], $company, $menus), $context['from'], $company, $ctxId),
|
||
'function' => self::executeFunction($flow['function'] ?? '', $flow['params'] ?? [], $context, $company, $ctxId),
|
||
'collect_input' => self::handleCollectInput($flow, $context, $company, $ctxId),
|
||
'dynamic_list' => self::handleDynamicList($flow, $context, $company, $ctxId),
|
||
'submit_form' => self::handleSubmitForm($flow, $context, $company, $ctxId),
|
||
'collect_for_each' => self::handleCollectForEach($flow, $context, $company, $ctxId),
|
||
'collect_and_post' => self::handleCollectAndPost($flow, $context, $company, $ctxId),
|
||
default => null,
|
||
};
|
||
}
|
||
|
||
// ── Functions ────────────────────────────────────────────────────────────
|
||
|
||
private static function executeFunction(string $function, array $params, array $context, array $company, int $ctxId): ?array
|
||
{
|
||
return match ($function) {
|
||
'reset' => (function () use ($ctxId, $context, $company) {
|
||
ConversationContext::reset($ctxId);
|
||
return self::sendText('¿En qué más puedo ayudarte?', $context['from'], $company);
|
||
})(),
|
||
'goodbye' => (function () use ($ctxId, $context, $company) {
|
||
ConversationContext::reset($ctxId);
|
||
return self::sendText("Hasta luego 👋\n\nEscribe *menu* cuando quieras volver.", $context['from'], $company);
|
||
})(),
|
||
'reset_finca' => self::reAsk($ctxId, $context, $company, 'finca', 'ask_finca', '¿En qué finca trabajas?'),
|
||
'reset_grupo_mant' => self::reAsk($ctxId, $context, $company, 'grupo_mant', 'ciclo_mantenimiento', '¿Qué grupo de mantenimiento?'),
|
||
'forward_to_ai' => null,
|
||
'api_report' => self::executeApiReport($params, $context, $company, $ctxId),
|
||
default => null,
|
||
};
|
||
}
|
||
|
||
/** Olvida un valor ya elegido y vuelve a preguntarlo ("cambiar finca", "otro grupo"). */
|
||
private static function reAsk(int $ctxId, array $context, array $company, string $group, string $flowKey, string $fallbackText): ?array
|
||
{
|
||
$meta = ConversationContext::getMetadata($ctxId);
|
||
unset($meta[$group], $meta['__after_select']);
|
||
ConversationContext::updateMetadata($ctxId, $meta);
|
||
|
||
[$flows, $menus] = self::resolveFlowsMenus($company);
|
||
ConversationContext::updateNode($ctxId, $flowKey);
|
||
return isset($flows[$flowKey])
|
||
? self::handleFlow($flows[$flowKey], $context, $company, $ctxId, $menus, $flowKey)
|
||
: self::sendText($fallbackText, $context['from'], $company);
|
||
}
|
||
|
||
// Reset manteniendo grupos de sesión (finca, etc.) para que el usuario
|
||
// no tenga que re-seleccionar su finca después de cada reporte.
|
||
private static function preservingReset(int $ctxId, array $meta): void
|
||
{
|
||
$keep = [];
|
||
// finca y grupo_mant se conservan: los submenús ofrecen varios informes sobre
|
||
// la misma selección, y perderla dejaba {finca_id}/{grupo_id} sin resolver.
|
||
// __saludo también, si no se re-saluda después de cada informe.
|
||
foreach (['finca', 'grupo_mant', '__saludo'] as $group) {
|
||
if (isset($meta[$group])) $keep[$group] = $meta[$group];
|
||
}
|
||
ConversationContext::reset($ctxId);
|
||
if (!empty($keep)) {
|
||
ConversationContext::updateMetadata($ctxId, $keep);
|
||
}
|
||
// Sesión ya iniciada, no nueva: con current_node = null los pasos de
|
||
// bienvenida se redisparan y el texto libre nunca llega al NLU.
|
||
ConversationContext::updateNode($ctxId, '__greeted');
|
||
}
|
||
|
||
// ── Resolves fixed-mode var values ───────────────────────────────────────
|
||
private static function resolveFixedVar(string $value): string
|
||
{
|
||
return match ($value) {
|
||
'current_date' => date('Y-m-d'),
|
||
'month_start' => date('Y-m-01'),
|
||
'month_end' => date('Y-m-t'),
|
||
'last_7_start' => date('Y-m-d', strtotime('-7 days')),
|
||
'last_30_start' => date('Y-m-d', strtotime('-30 days')),
|
||
'year_start' => date('Y-01-01'),
|
||
default => $value,
|
||
};
|
||
}
|
||
|
||
// ── Handles sequential collection of URL vars before calling endpoint ───
|
||
private static function handleApiVarsInput(array $meta, string $input, array $context, array $company, int $ctxId, array $flows, array $menus): ?array
|
||
{
|
||
$nav = "\n\nEscribe *menú* para volver al inicio, *atrás* para subir un nivel o *salir* para terminar.";
|
||
$state = $meta['__api_vars'];
|
||
|
||
$pending = $state['pending'] ?? [];
|
||
$collected = $state['collected'] ?? [];
|
||
$epKey = $state['endpoint_key'] ?? '';
|
||
$epParams = $state['ep_params'] ?? [];
|
||
|
||
// Save the user's answer for the current question
|
||
if (!empty($pending)) {
|
||
$current = array_shift($pending);
|
||
$collected[$current['key']] = trim($input);
|
||
$state['pending'] = $pending;
|
||
$state['collected'] = $collected;
|
||
}
|
||
|
||
// If more questions remain, ask the next one
|
||
if (!empty($state['pending'])) {
|
||
$next = $state['pending'][0];
|
||
$meta['__api_vars'] = $state;
|
||
ConversationContext::updateMetadata($ctxId, $meta);
|
||
return self::sendText($next['prompt'] . $nav, $context['from'], $company);
|
||
}
|
||
|
||
// All vars collected — inject into meta and execute
|
||
unset($meta['__api_vars']);
|
||
foreach ($collected as $k => $v) {
|
||
$meta['__ep_vars'][$k] = $v;
|
||
}
|
||
ConversationContext::updateMetadata($ctxId, $meta);
|
||
|
||
return self::executeApiReport(array_merge($epParams, ['endpoint_key' => $epKey]), $context, $company, $ctxId);
|
||
}
|
||
|
||
private static function executeApiReport(array $params, array $context, array $company, int $ctxId): ?array
|
||
{
|
||
$endpointKey = $params['endpoint_key'] ?? '';
|
||
$nav = "\n\nEscribe *menú* para volver al inicio, *atrás* para subir un nivel o *salir* para terminar.";
|
||
|
||
if ($endpointKey === '') {
|
||
self::preservingReset($ctxId, ConversationContext::getMetadata($ctxId));
|
||
return self::sendText('Error: endpoint no configurado.' . $nav, $context['from'], $company);
|
||
}
|
||
|
||
$stmt = db()->prepare("SELECT url, method, params FROM company_endpoints WHERE company_id = ? AND endpoint_key = ? AND is_active = 1 LIMIT 1");
|
||
$stmt->execute([(int)$company['id'], $endpointKey]);
|
||
$ep = $stmt->fetch();
|
||
|
||
if (!$ep || empty($ep['url'])) {
|
||
self::log("API report: endpoint_key '{$endpointKey}' no configurado para company {$company['id']}");
|
||
self::preservingReset($ctxId, ConversationContext::getMetadata($ctxId));
|
||
return self::sendText('El informe solicitado no está configurado. Contacta al administrador.' . $nav, $context['from'], $company);
|
||
}
|
||
|
||
// ── Resolve URL vars from params config ──────────────────────────────
|
||
$varConfigs = json_decode($ep['params'] ?? '[]', true) ?: [];
|
||
$meta = ConversationContext::getMetadata($ctxId);
|
||
|
||
// Inject any previously collected vars into meta for substituteUrlVars
|
||
$epVars = $meta['__ep_vars'] ?? [];
|
||
if (!empty($epVars)) {
|
||
$meta['__ep_vars_flat'] = $epVars;
|
||
}
|
||
|
||
$askVars = [];
|
||
foreach ($varConfigs as $vc) {
|
||
$key = $vc['key'] ?? '';
|
||
$mode = $vc['mode'] ?? 'ask';
|
||
if ($key === '') continue;
|
||
|
||
if ($mode === 'fixed') {
|
||
// Inject fixed value directly into meta for substituteUrlVars
|
||
$meta['__ep_vars_flat'][$key] = self::resolveFixedVar($vc['value'] ?? '');
|
||
} elseif ($mode === 'ask') {
|
||
// Only ask if not already collected
|
||
if (!isset($epVars[$key])) {
|
||
$askVars[] = ['key' => $key, 'prompt' => $vc['prompt'] ?? "Ingresa el valor para {$key}:"];
|
||
} else {
|
||
$meta['__ep_vars_flat'][$key] = $epVars[$key];
|
||
}
|
||
}
|
||
}
|
||
|
||
// If there are vars to ask, start collection flow
|
||
if (!empty($askVars)) {
|
||
$first = array_shift($askVars);
|
||
$meta['__api_vars'] = [
|
||
'endpoint_key' => $endpointKey,
|
||
'ep_params' => $params,
|
||
'pending' => $askVars,
|
||
'collected' => [],
|
||
];
|
||
// Clean collected vars so they don't bleed into the next call
|
||
unset($meta['__ep_vars']);
|
||
ConversationContext::updateMetadata($ctxId, $meta);
|
||
return self::sendText($first['prompt'] . $nav, $context['from'], $company);
|
||
}
|
||
|
||
// Flatten collected vars into meta for substituteUrlVars
|
||
if (!empty($meta['__ep_vars_flat'])) {
|
||
$meta['__ep_vars_combined'] = $meta['__ep_vars_flat'];
|
||
}
|
||
unset($meta['__ep_vars'], $meta['__ep_vars_flat']);
|
||
ConversationContext::updateMetadata($ctxId, $meta);
|
||
|
||
$url = self::buildUrl(
|
||
self::substituteUrlVars($ep['url'], array_merge($meta, ['__ep_vars_combined' => $meta['__ep_vars_combined'] ?? []])),
|
||
$company
|
||
);
|
||
// Placeholder sin resolver → la API recibiría basura y devolvería un informe
|
||
// vacío sin explicación. Mejor pedir el dato que falta.
|
||
if (preg_match('/\{(\w+)\}/', $url, $ph)) {
|
||
self::log("API report: falta '{$ph[1]}' para [{$endpointKey}] — url: {$url}");
|
||
self::preservingReset($ctxId, ConversationContext::getMetadata($ctxId));
|
||
return self::sendText('Falta seleccionar un dato para generar este informe.' . $nav, $context['from'], $company);
|
||
}
|
||
|
||
$method = strtoupper($ep['method'] ?? 'GET');
|
||
$apiKey = $company['api_key'] ?? '';
|
||
|
||
$extraQuery = $params['query'] ?? [];
|
||
$dateMode = $params['date_mode'] ?? '';
|
||
if ($dateMode === 'today') {
|
||
$extraQuery['fecha_desde'] = date('Y-m-d');
|
||
$extraQuery['fecha_hasta'] = date('Y-m-d');
|
||
} elseif ($dateMode === 'last_30') {
|
||
$extraQuery['fecha_desde'] = date('Y-m-d', strtotime('-30 days'));
|
||
$extraQuery['fecha_hasta'] = date('Y-m-d');
|
||
} elseif ($dateMode === 'current_month') {
|
||
$extraQuery['fecha_desde'] = date('Y-m-01');
|
||
$extraQuery['fecha_hasta'] = date('Y-m-d');
|
||
} elseif ($dateMode === 'last_month') {
|
||
$extraQuery['fecha_desde'] = date('Y-m-01', strtotime('first day of last month'));
|
||
$extraQuery['fecha_hasta'] = date('Y-m-t', strtotime('last day of last month'));
|
||
} elseif ($dateMode === 'last_7') {
|
||
$extraQuery['fecha_desde'] = date('Y-m-d', strtotime('-7 days'));
|
||
$extraQuery['fecha_hasta'] = date('Y-m-d');
|
||
}
|
||
$extraQuery['telefono'] = $context['from'];
|
||
$extraQuery['nombre'] = $context['name'] ?? '';
|
||
|
||
$glue = str_contains($url, '?') ? '&' : '?';
|
||
$url .= $glue . http_build_query($extraQuery);
|
||
|
||
$ch = curl_init($url);
|
||
$curlOpts = [
|
||
CURLOPT_RETURNTRANSFER => true,
|
||
CURLOPT_FOLLOWLOCATION => true,
|
||
CURLOPT_TIMEOUT => 15,
|
||
CURLOPT_HTTPHEADER => [
|
||
'Authorization: Bearer ' . $apiKey,
|
||
'X-API-Key: ' . $apiKey,
|
||
'User-Agent: bot-palmas360/1.0',
|
||
],
|
||
];
|
||
if ($method === 'POST') {
|
||
$curlOpts[CURLOPT_POST] = true;
|
||
$curlOpts[CURLOPT_POSTFIELDS] = '{}';
|
||
}
|
||
curl_setopt_array($ch, $curlOpts);
|
||
$content = curl_exec($ch);
|
||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||
$contentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
|
||
$error = curl_error($ch);
|
||
curl_close($ch);
|
||
|
||
if ($error) {
|
||
self::log("API report error [{$endpointKey}]: {$error}");
|
||
self::preservingReset($ctxId, $meta);
|
||
return self::sendText('Error al obtener el reporte. Intenta de nuevo.' . $nav, $context['from'], $company);
|
||
}
|
||
|
||
if ($httpCode >= 400) {
|
||
self::log("API report HTTP {$httpCode} [{$endpointKey}]: " . mb_substr($content, 0, 200));
|
||
self::preservingReset($ctxId, $meta);
|
||
return self::sendText('Error al obtener el reporte. Intenta de nuevo.' . $nav, $context['from'], $company);
|
||
}
|
||
|
||
// Si el ERP devuelve JSON (ej: sin_datos o texto), informar al usuario en texto
|
||
if (str_starts_with($contentType ?? '', 'application/json')) {
|
||
$json = json_decode($content, true);
|
||
$msg = $json['mensaje'] ?? ($json['message'] ?? 'No hay datos disponibles para el período solicitado.');
|
||
$nextKey = $params['next_endpoint_key'] ?? '';
|
||
if ($nextKey !== '' && ($json['status'] ?? '0') === '1') {
|
||
// Enviar texto primero directamente, luego retornar el PDF/Excel
|
||
$phoneId = $context['phone_number_id'] ?? ($company['phone_number_id'] ?? '');
|
||
WhatsAppSender::sendText($context['from'], $msg, $phoneId);
|
||
$nextParams = array_merge($params, [
|
||
'endpoint_key' => $nextKey,
|
||
'caption' => $params['next_caption'] ?? '',
|
||
'filename' => $params['next_filename'] ?? 'reporte.pdf',
|
||
]);
|
||
unset($nextParams['next_endpoint_key'], $nextParams['next_caption'], $nextParams['next_filename']);
|
||
return self::executeApiReport($nextParams, $context, $company, $ctxId);
|
||
}
|
||
self::preservingReset($ctxId, $meta);
|
||
return self::sendText('ℹ️ ' . $msg . $nav, $context['from'], $company);
|
||
}
|
||
|
||
$mimeMap = [
|
||
'application/pdf' => ['pdf', 'application/pdf'],
|
||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => ['xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'],
|
||
'application/vnd.ms-excel' => ['xls', 'application/vnd.ms-excel'],
|
||
'text/csv' => ['csv', 'text/csv'],
|
||
'application/vnd.oasis.opendocument.spreadsheet' => ['ods', 'application/vnd.oasis.opendocument.spreadsheet'],
|
||
];
|
||
|
||
$ext = 'pdf';
|
||
$mime = 'application/pdf';
|
||
foreach ($mimeMap as $m => $info) {
|
||
if (str_starts_with($contentType ?? '', $m)) {
|
||
[$ext, $mime] = $info;
|
||
break;
|
||
}
|
||
}
|
||
|
||
$phoneNumberId = $company['phone_number_id'] ?? env('WHATSAPP_PHONE_NUMBER_ID', '');
|
||
if ($phoneNumberId === '') {
|
||
return self::sendText('Error: canal de envío no configurado.', $context['from'], $company);
|
||
}
|
||
|
||
$tmpFile = sys_get_temp_dir() . '/report_' . bin2hex(random_bytes(8)) . '.' . $ext;
|
||
file_put_contents($tmpFile, $content);
|
||
|
||
$upload = WhatsAppSender::uploadMedia($tmpFile, $mime, $phoneNumberId);
|
||
|
||
if (!$upload['success'] || !$upload['media_id']) {
|
||
self::log("API report upload failed [{$endpointKey}]: " . ($upload['error'] ?? 'unknown'));
|
||
self::preservingReset($ctxId, $meta);
|
||
return self::sendText('Error al enviar el reporte. Intenta de nuevo.' . $nav, $context['from'], $company);
|
||
}
|
||
|
||
$reportName = $params['filename'] ?? ('reporte.' . $ext);
|
||
$caption = $params['caption'] ?? 'Aquí tienes el reporte solicitado.';
|
||
|
||
WhatsAppSender::sendDocument($context['from'], $upload['media_id'], $phoneNumberId, $caption, $reportName);
|
||
self::preservingReset($ctxId, $meta);
|
||
|
||
return self::sendText('✅ Reporte generado.' . $nav, $context['from'], $company);
|
||
}
|
||
|
||
// ── Public helpers ───────────────────────────────────────────────────────
|
||
|
||
public static function buildGreetingMenu(array $menu, string $to, array $company): ?array
|
||
{
|
||
return self::buildMenuResponse($menu, $to, $company);
|
||
}
|
||
|
||
// ── Interactive messages ─────────────────────────────────────────────────
|
||
|
||
public static function processInteractive(array $company, array $context, string $input): ?array
|
||
{
|
||
$config = self::getConfig($company);
|
||
$permType = (string)($company['_permission_type'] ?? 1);
|
||
$perType = $config['per_type'][$permType] ?? [];
|
||
$flows = array_merge($config['flows'] ?? [], $perType['flows'] ?? []);
|
||
$menus = array_merge($config['menus'] ?? [], $perType['menus'] ?? []);
|
||
|
||
$botCtx = ConversationContext::getOrCreate((int)$company['id'], $context['from'], 'normal');
|
||
$ctxId = (int)$botCtx['id'];
|
||
|
||
// Multi-step form: interactive selection is the collected value
|
||
$meta = ConversationContext::getMetadata($ctxId);
|
||
if (!empty($meta['collecting'])) {
|
||
return self::handleCollectingInput($meta, $input, 'interactive', $context, $company, $ctxId, $flows, $menus);
|
||
}
|
||
|
||
// collect_and_post: confirm/cancel, lookup confirm/reject, or list/button field pick
|
||
if (!empty($meta['__cap'])) {
|
||
if ($input === '__cap_confirm') {
|
||
return self::submitCapPost($meta, $context, $company, $ctxId);
|
||
}
|
||
if ($input === '__cap_cancel') {
|
||
$m = ConversationContext::getMetadata($ctxId);
|
||
unset($m['__cap']);
|
||
ConversationContext::updateMetadata($ctxId, $m);
|
||
ConversationContext::reset($ctxId);
|
||
return self::sendText("❌ Registro cancelado.\n\nEscribe *menu* para volver.", $context['from'], $company);
|
||
}
|
||
// Lookup confirmation buttons
|
||
if (!empty($meta['__cap']['__lookup_pending'])) {
|
||
if ($input === '__cap_lookup_yes') {
|
||
$cap = $meta['__cap'];
|
||
$fields = $cap['fields'];
|
||
$idx = (int)$cap['index'];
|
||
$field = $fields[$idx];
|
||
$pending = $cap['__lookup_pending'];
|
||
$cap['collected'][$field['key']] = $pending['id'];
|
||
$cap['collected_labels'][$field['key']] = $pending['label'];
|
||
unset($cap['__lookup_pending']);
|
||
$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);
|
||
if ($cap['index'] < count($fields)) {
|
||
return self::capAskNext($cap, $context['from'], $company, $ctxId);
|
||
}
|
||
return self::capShowConfirmOrSubmit($cap, $meta, $context, $company, $ctxId);
|
||
}
|
||
if ($input === '__cap_lookup_no') {
|
||
$cap = $meta['__cap'];
|
||
unset($cap['__lookup_pending']);
|
||
$meta['__cap'] = $cap;
|
||
ConversationContext::updateMetadata($ctxId, $meta);
|
||
return self::capAskNext($cap, $context['from'], $company, $ctxId);
|
||
}
|
||
}
|
||
// Interactive list/button response for select, date_quick, static_select fields
|
||
return self::handleCapInput($meta, $input, $context, $company, $ctxId);
|
||
}
|
||
|
||
// collect_for_each confirmation (confirm/cancel button)
|
||
if (!empty($meta['__foreach'])) {
|
||
if ($input === '__foreach_confirm') {
|
||
return self::submitForeach($meta, $context, $company, $ctxId);
|
||
}
|
||
if ($input === '__foreach_cancel') {
|
||
$meta2 = ConversationContext::getMetadata($ctxId);
|
||
unset($meta2['__foreach']);
|
||
ConversationContext::updateMetadata($ctxId, $meta2);
|
||
ConversationContext::reset($ctxId);
|
||
return self::sendText("❌ Registro cancelado.\n\nEscribe *menu* para volver.", $context['from'], $company);
|
||
}
|
||
// La fecha previa al bucle se elige de una lista, así que llega por acá
|
||
// y no como texto. Sin esto el flujo se quedaba mudo tras elegirla.
|
||
$fe = $meta['__foreach'];
|
||
if (!empty($fe['ask_date']) && ($fe['fecha'] ?? null) === null) {
|
||
return self::handleForeachInput($meta, $input, $context, $company, $ctxId, $flows);
|
||
}
|
||
}
|
||
|
||
// Standard button/row resolution against static menus
|
||
foreach ($flows as $flowId => $flow) {
|
||
if (($flow['type'] ?? '') !== 'menu') continue;
|
||
|
||
$menu = self::resolveMenu($flow['menu'] ?? [], $company, $menus);
|
||
foreach ($menu['sections'] ?? [] as $section) {
|
||
foreach ($section['rows'] ?? [] as $row) {
|
||
if (($row['id'] ?? '') === $input) {
|
||
ConversationContext::updateNode($ctxId, $row['id']);
|
||
if (isset($flows[$row['id']])) {
|
||
return self::handleFlow($flows[$row['id']], $context, $company, $ctxId, $menus, $row['id']);
|
||
}
|
||
return null;
|
||
}
|
||
}
|
||
}
|
||
foreach ($menu['buttons'] ?? [] as $btn) {
|
||
if (($btn['id'] ?? '') === $input) {
|
||
ConversationContext::updateNode($ctxId, $btn['id']);
|
||
if (isset($flows[$btn['id']])) {
|
||
return self::handleFlow($flows[$btn['id']], $context, $company, $ctxId, $menus, $btn['id']);
|
||
}
|
||
return null;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Direct flow match (button id == flow key)
|
||
// Redirect show_main_menu → per-type greeting_menu if configured
|
||
$resolvedInput = $input;
|
||
$greetingMenuKey = $perType['greeting_menu'] ?? null;
|
||
if ($resolvedInput === 'show_main_menu' && $greetingMenuKey && isset($menus[$greetingMenuKey])) {
|
||
$resolvedInput = $greetingMenuKey;
|
||
}
|
||
if (isset($flows[$resolvedInput])) {
|
||
ConversationContext::updateNode($ctxId, $resolvedInput);
|
||
return self::handleFlow($flows[$resolvedInput], $context, $company, $ctxId, $menus, $resolvedInput);
|
||
}
|
||
if (isset($menus[$resolvedInput])) {
|
||
ConversationContext::updateNode($ctxId, $resolvedInput);
|
||
return self::buildMenuResponse($menus[$resolvedInput], $context['from'], $company, $ctxId);
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
// ── WhatsApp senders ─────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Resuelve {finca} en los textos del menú y deja la finca activa en el footer,
|
||
* para que el usuario sepa siempre sobre qué está consultando.
|
||
*/
|
||
private static function aplicarVarsMenu(array $menu, ?int $ctxId, array $company): array
|
||
{
|
||
if ($ctxId === null) return $menu;
|
||
|
||
$meta = ConversationContext::getMetadata($ctxId);
|
||
$finca = (string)($meta['finca']['finca_label'] ?? '');
|
||
|
||
foreach (['header', 'body', 'footer'] as $campo) {
|
||
if (isset($menu[$campo]) && is_string($menu[$campo])) {
|
||
$menu[$campo] = str_replace('{finca}', $finca, $menu[$campo]);
|
||
}
|
||
}
|
||
|
||
// Sin finca (cat 1 solo reporta) queda el footer de siempre
|
||
if ($finca !== '' && !str_contains((string)($menu['footer'] ?? ''), $finca)) {
|
||
$menu['footer'] = '📍 ' . $finca;
|
||
}
|
||
return $menu;
|
||
}
|
||
|
||
private static function buildMenuResponse(array $menu, string $to, array $company, ?int $ctxId = null): ?array
|
||
{
|
||
$menu = self::aplicarVarsMenu($menu, $ctxId, $company);
|
||
$menuType = $menu['type'] ?? 'list';
|
||
|
||
if ($menuType === 'list') {
|
||
$interactive = [
|
||
'type' => 'list',
|
||
'header' => [
|
||
'type' => 'text',
|
||
'text' => mb_substr($menu['header'] ?? 'Menú', 0, 60),
|
||
],
|
||
'body' => ['text' => mb_substr($menu['body'] ?? 'Selecciona una opción:', 0, 1024)],
|
||
'footer' => ['text' => mb_substr($menu['footer'] ?? $company['display_name'] ?? '', 0, 60)],
|
||
'action' => [
|
||
'button' => mb_substr($menu['button'] ?? 'Ver opciones', 0, 20),
|
||
'sections' => [],
|
||
],
|
||
];
|
||
|
||
foreach ($menu['sections'] ?? [] as $section) {
|
||
$rows = [];
|
||
foreach ($section['rows'] ?? [] as $row) {
|
||
$rows[] = [
|
||
'id' => mb_substr($row['id'] ?? '', 0, 200),
|
||
'title' => mb_substr($row['title'] ?? '', 0, 24),
|
||
'description' => isset($row['description']) ? mb_substr($row['description'], 0, 72) : null,
|
||
];
|
||
}
|
||
$interactive['action']['sections'][] = [
|
||
'title' => mb_substr($section['title'] ?? '', 0, 24),
|
||
'rows' => $rows,
|
||
];
|
||
}
|
||
|
||
return self::enqueueInteractive($to, $interactive, $company);
|
||
}
|
||
|
||
if ($menuType === 'button') {
|
||
$buttons = [];
|
||
foreach (array_slice($menu['buttons'] ?? [], 0, 3) as $btn) {
|
||
$buttons[] = [
|
||
'type' => 'reply',
|
||
'reply' => [
|
||
'id' => mb_substr($btn['id'] ?? '', 0, 256),
|
||
'title' => mb_substr($btn['title'] ?? '', 0, 20),
|
||
],
|
||
];
|
||
}
|
||
|
||
$interactive = [
|
||
'type' => 'button',
|
||
'body' => ['text' => mb_substr($menu['body'] ?? 'Selecciona:', 0, 1024)],
|
||
'action' => ['buttons' => $buttons],
|
||
];
|
||
// Los submenús de ciclos son de botones: sin footer no se veía la finca
|
||
if (($menu['footer'] ?? '') !== '') {
|
||
$interactive['footer'] = ['text' => mb_substr($menu['footer'], 0, 60)];
|
||
}
|
||
|
||
return self::enqueueInteractive($to, $interactive, $company);
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
private static function sendText(string $text, string $to, array $company): array
|
||
{
|
||
return ['action' => 'send', 'type' => 'text', 'to' => $to, 'payload' => json_encode(['text' => $text])];
|
||
}
|
||
|
||
private static function sendImage(string $mediaId, ?string $caption, string $to, array $company): array
|
||
{
|
||
return ['action' => 'send', 'type' => 'image', 'to' => $to, 'payload' => json_encode(['media_id' => $mediaId, 'caption' => $caption])];
|
||
}
|
||
|
||
private static function enqueueInteractive(string $to, array $interactive, array $company): array
|
||
{
|
||
return ['action' => 'send', 'type' => 'interactive', 'to' => $to, 'payload' => json_encode(['interactive' => $interactive])];
|
||
}
|
||
|
||
private static function getConfig(array $company): array
|
||
{
|
||
$json = $company['config_json'] ?? '';
|
||
$config = $json !== '' ? json_decode($json, true) : null;
|
||
return is_array($config) ? $config : [];
|
||
}
|
||
|
||
private static function buildUrl(string $relOrAbs, array $company): string
|
||
{
|
||
if ($relOrAbs === '' || str_starts_with($relOrAbs, 'http')) return $relOrAbs;
|
||
return rtrim($company['api_base_url'] ?? '', '/') . '/' . ltrim($relOrAbs, '/');
|
||
}
|
||
|
||
private static function normalize(string $input): string
|
||
{
|
||
$input = mb_strtolower(trim($input));
|
||
$input = str_replace(['á','é','í','ó','ú','ü','ñ'], ['a','e','i','o','u','u','n'], $input);
|
||
return preg_replace('/[^a-z0-9\s]/', '', $input);
|
||
}
|
||
|
||
private static function log(string $msg): void
|
||
{
|
||
if (class_exists('WpWebhook')) WpWebhook::log('NORMALBOT', $msg);
|
||
}
|
||
}
|