From 8f43ccf4c2a2389e5423a27d346a1dd92ccc23af Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Sat, 4 Jul 2026 16:07:52 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20pre-men=C3=BA=20de=20selecci=C3=B3n=20d?= =?UTF-8?q?e=20empresa=20para=20n=C3=BAmeros=20multi-empresa?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cuando un número pertenece a más de una empresa, el bot muestra un menú interactivo "¿Con cuál empresa deseas comunicarte?" antes de cualquier flujo normal. La selección se cachea 8h en la tabla multi_company_sessions. Keywords de reinicio (salir, inicio, menú...) limpian la sesión y vuelven a mostrar el pre-menú. Números de una sola empresa: flujo intacto. Co-Authored-By: Claude Sonnet 4.6 --- admin/v1/WpWebhook.php | 168 ++++++++++++++++++++++++++++++++++------- setup/migrate.php | 11 +++ 2 files changed, 152 insertions(+), 27 deletions(-) diff --git a/admin/v1/WpWebhook.php b/admin/v1/WpWebhook.php index 66913ee..eb367ed 100644 --- a/admin/v1/WpWebhook.php +++ b/admin/v1/WpWebhook.php @@ -167,46 +167,141 @@ class WpWebhook } } - // Resolución principal: busca el número remitente en company_phones - private static function resolveCompanyByNumber(string $from): bool + // Devuelve todas las empresas a las que pertenece el número + private static function findCompaniesByNumber(string $from): array { - if ($from === '') { - self::$currentCompany = null; - return false; - } - + if ($from === '') return []; try { $stmt = db()->prepare(" SELECT cp.company_id, cp.permission_type FROM company_phones cp WHERE cp.wa_number = ? AND cp.is_active = 1 - LIMIT 1 + ORDER BY cp.company_id ASC "); $stmt->execute([$from]); - $row = $stmt->fetch(\PDO::FETCH_ASSOC); + return $stmt->fetchAll(\PDO::FETCH_ASSOC); } catch (\PDOException $e) { - self::log('ERROR', 'resolveCompanyByNumber DB: ' . $e->getMessage()); - self::$currentCompany = null; + self::log('ERROR', 'findCompaniesByNumber DB: ' . $e->getMessage()); + return []; + } + } + + // Pre-menú multi-empresa: retorna true si la empresa quedó resuelta, false si hay que esperar al usuario + private static function handleMultiCompany(string $from, string $name, array $companies, string $rawText, string $phoneNumberId): bool + { + // 1. El usuario tocó un botón de selección de empresa + if (str_starts_with($rawText, '__co_')) { + $selectedId = (int)substr($rawText, 5); + $company = CompanyRepository::findById($selectedId); + if (!$company) { + self::sendCompanyPreMenu($from, $phoneNumberId, $name, $companies); + return false; + } + try { + db()->prepare(" + INSERT INTO multi_company_sessions (wa_number, companies_json, selected_id, expires_at) + VALUES (?, ?, ?, DATE_ADD(NOW(), INTERVAL 8 HOUR)) + ON DUPLICATE KEY UPDATE selected_id = VALUES(selected_id), expires_at = VALUES(expires_at) + ")->execute([$from, json_encode($companies), $selectedId]); + } catch (\PDOException $e) { + self::log('ERROR', 'handleMultiCompany save session: ' . $e->getMessage()); + } + $company['_permission_type'] = self::getPermissionType($companies, $selectedId); + self::$currentCompany = $company; + $displayName = $company['display_name'] ?: $company['name']; + WhatsAppSender::sendText($from, "✅ Conectado con *{$displayName}*.\nEscribe tu consulta o *menú* para ver las opciones.", $phoneNumberId); + self::log('INFO', "Multi-empresa: {$from} seleccionó empresa #{$selectedId} ({$displayName})"); + return false; // el clic en botón no se procesa como comando del bot + } + + // 2. Keyword de reinicio → borrar sesión y mostrar pre-menú + if (self::isResetKeyword($rawText)) { + try { db()->prepare("DELETE FROM multi_company_sessions WHERE wa_number = ?")->execute([$from]); } catch (\PDOException $e) {} + self::sendCompanyPreMenu($from, $phoneNumberId, $name, $companies); return false; } - if (!$row) { - self::$currentCompany = null; - self::log('WARN', "Número {$from} no registrado en ninguna empresa"); - return false; + // 3. Sesión activa → usar empresa guardada + try { + $stmt = db()->prepare("SELECT selected_id FROM multi_company_sessions WHERE wa_number = ? AND selected_id IS NOT NULL AND expires_at > NOW() LIMIT 1"); + $stmt->execute([$from]); + $session = $stmt->fetch(\PDO::FETCH_ASSOC); + } catch (\PDOException $e) { + self::log('ERROR', 'handleMultiCompany read session: ' . $e->getMessage()); + $session = null; } - $company = CompanyRepository::findById((int)$row['company_id']); - if ($company === null) { - self::$currentCompany = null; - return false; + if ($session) { + $selectedId = (int)$session['selected_id']; + try { db()->prepare("UPDATE multi_company_sessions SET expires_at = DATE_ADD(NOW(), INTERVAL 8 HOUR) WHERE wa_number = ?")->execute([$from]); } catch (\PDOException $e) {} + $company = CompanyRepository::findById($selectedId); + if (!$company) { + try { db()->prepare("DELETE FROM multi_company_sessions WHERE wa_number = ?")->execute([$from]); } catch (\PDOException $e) {} + self::sendCompanyPreMenu($from, $phoneNumberId, $name, $companies); + return false; + } + $company['_permission_type'] = self::getPermissionType($companies, $selectedId); + self::$currentCompany = $company; + return true; } - // Adjuntar tipo de permiso al contexto de empresa - $company['_permission_type'] = (int)$row['permission_type']; - self::$currentCompany = $company; - self::log('INFO', "Número {$from} → empresa: {$company['name']} (permiso tipo {$row['permission_type']})"); - return true; + // 4. Sin sesión → mostrar pre-menú + try { + db()->prepare(" + INSERT INTO multi_company_sessions (wa_number, companies_json, selected_id, expires_at) + VALUES (?, ?, NULL, DATE_ADD(NOW(), INTERVAL 10 MINUTE)) + ON DUPLICATE KEY UPDATE companies_json = VALUES(companies_json), selected_id = NULL, expires_at = VALUES(expires_at) + ")->execute([$from, json_encode($companies)]); + } catch (\PDOException $e) { + self::log('ERROR', 'handleMultiCompany insert pending: ' . $e->getMessage()); + } + self::sendCompanyPreMenu($from, $phoneNumberId, $name, $companies); + return false; + } + + private static function sendCompanyPreMenu(string $to, string $phoneNumberId, string $name, array $companies): void + { + $items = []; + foreach ($companies as $cp) { + $co = CompanyRepository::findById((int)$cp['company_id']); + if ($co) $items[] = ['id' => (int)$cp['company_id'], 'name' => $co['display_name'] ?: $co['name']]; + } + if (empty($items)) return; + + $greeting = $name ? "Hola *{$name}* 👋\n" : "Hola 👋\n"; + $body = $greeting . "¿Con cuál empresa deseas comunicarte?"; + + if (count($items) <= 3) { + $buttons = []; + foreach ($items as $item) { + $buttons[] = ['type' => 'reply', 'reply' => ['id' => '__co_' . $item['id'], 'title' => mb_substr($item['name'], 0, 20)]]; + } + $interactive = ['type' => 'button', 'body' => ['text' => $body], 'action' => ['buttons' => $buttons]]; + } else { + $rows = []; + foreach ($items as $item) { + $rows[] = ['id' => '__co_' . $item['id'], 'title' => mb_substr($item['name'], 0, 24)]; + } + $interactive = ['type' => 'list', 'body' => ['text' => $body], 'action' => ['button' => 'Ver empresas', 'sections' => [['title' => 'Empresas', 'rows' => $rows]]]]; + } + + WhatsAppSender::sendInteractive($to, $interactive, $phoneNumberId); + self::log('INFO', "Pre-menú de empresa enviado a {$to} (" . count($items) . " opciones)"); + } + + private static function isResetKeyword(string $text): bool + { + $n = mb_strtolower(trim($text)); + $n = str_replace(['á','é','í','ó','ú','ü','ñ'], ['a','e','i','o','u','u','n'], $n); + return in_array($n, ['salir','inicio','menu','reiniciar','volver','reset','0','atras','regresar'], true); + } + + private static function getPermissionType(array $companies, int $companyId): int + { + foreach ($companies as $cp) { + if ((int)$cp['company_id'] === $companyId) return (int)$cp['permission_type']; + } + return 3; } private static function sendRejectionMessage(string $to, string $phoneNumberId): void @@ -283,15 +378,34 @@ class WpWebhook } } - // Resolver empresa por número remitente - $allowed = self::resolveCompanyByNumber($from); + // Resolver empresa(s) por número remitente + $matchedCompanies = self::findCompaniesByNumber($from); - if (!$allowed) { + if (empty($matchedCompanies)) { self::saveWebhookLog('messages', $from, $name, $type, '[NÚMERO NO HABILITADO]'); self::sendRejectionMessage($from, $phoneNumberId); continue; } + if (count($matchedCompanies) === 1) { + $co = CompanyRepository::findById((int)$matchedCompanies[0]['company_id']); + if (!$co) continue; + $co['_permission_type'] = (int)$matchedCompanies[0]['permission_type']; + self::$currentCompany = $co; + self::log('INFO', "Número {$from} → empresa: {$co['name']} (permiso {$matchedCompanies[0]['permission_type']})"); + } else { + $rawText = match ($type) { + 'text' => $msg['text']['body'] ?? '', + 'interactive' => $msg['interactive']['button_reply']['id'] ?? $msg['interactive']['list_reply']['id'] ?? '', + 'button' => $msg['button']['payload'] ?? $msg['button']['text'] ?? '', + default => '', + }; + if (!self::handleMultiCompany($from, $name, $matchedCompanies, $rawText, $phoneNumberId)) { + continue; + } + self::log('INFO', "Número {$from} → empresa (multi-sel): " . (self::$currentCompany['name'] ?? '?')); + } + $context = [ 'from' => $from, 'name' => $name, diff --git a/setup/migrate.php b/setup/migrate.php index 3a89a82..eb1edc7 100644 --- a/setup/migrate.php +++ b/setup/migrate.php @@ -244,6 +244,17 @@ $db->exec(" ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; "); +$db->exec(" + CREATE TABLE IF NOT EXISTS multi_company_sessions ( + wa_number VARCHAR(20) NOT NULL PRIMARY KEY, + companies_json TEXT NOT NULL, + selected_id INT DEFAULT NULL, + expires_at DATETIME NOT NULL, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_expires (expires_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +"); + // ─── Campo erp_active_users en companies ────────────────────────────────────── try { $db->exec("ALTER TABLE companies ADD COLUMN erp_active_users INT DEFAULT 0 AFTER is_active");