feat: pre-menú de selección de empresa para números multi-empresa

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 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-07-04 16:07:52 -05:00
co-authored by Claude Sonnet 4.6
parent b349230362
commit 8f43ccf4c2
2 changed files with 152 additions and 27 deletions
+141 -27
View File
@@ -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,
+11
View File
@@ -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");