diff --git a/admin/DashboardController.php b/admin/DashboardController.php index a7fd7f2..87043fb 100644 --- a/admin/DashboardController.php +++ b/admin/DashboardController.php @@ -1142,6 +1142,59 @@ HTML; "; } $bfSection = $epDir === 'upload' ? '' : 'display:none'; + + // ── URL vars config ────────────────────────────────────────── + $varConfigs = json_decode($ep['params'] ?? '[]', true) ?: []; + // Detect {var} placeholders in URL + preg_match_all('/\{([^}]+)\}/', $ep['url'] ?? '', $urlVarMatches); + $urlVars = array_unique($urlVarMatches[1] ?? []); + // Merge: URL vars not yet in config get added with default mode=ask + $configuredKeys = array_column($varConfigs, 'key'); + foreach ($urlVars as $uv) { + if (!in_array($uv, $configuredKeys, true)) { + $varConfigs[] = ['key' => $uv, 'mode' => 'ask', 'prompt' => "¿Ingresa {$uv}?", 'value' => '']; + } + } + $varRowsHtml = ''; + $fixedPresets = ['current_date'=>'Fecha actual','month_start'=>'Inicio mes actual','month_end'=>'Fin mes actual','last_7_start'=>'Hace 7 días','last_30_start'=>'Hace 30 días','year_start'=>'Inicio año']; + foreach ($varConfigs as $vc) { + $vk = self::h($vc['key'] ?? ''); + $vm = $vc['mode'] ?? 'ask'; + $vp = self::h($vc['prompt'] ?? ''); + $vv = self::h($vc['value'] ?? ''); + $mAsk = $vm === 'ask' ? 'selected' : ''; + $mFix = $vm === 'fixed' ? 'selected' : ''; + $askStyle = $vm === 'ask' ? '' : 'display:none'; + $fixStyle = $vm === 'fixed' ? '' : 'display:none'; + $fixOptions = ''; + foreach ($fixedPresets as $pk => $pl) { + $sel = $vv === $pk ? 'selected' : ''; + $fixOptions .= ""; + } + // Custom literal option + $isCustom = $vv !== '' && !array_key_exists($vv, $fixedPresets); + $fixOptions .= ''; + $customVal = $isCustom ? $vv : ''; + $varRowsHtml .= << + {{{$vk}}} + + + + + + + + + + +VR; + } + $varsSection = !empty($urlVars) ? '' : 'display:none'; + $epRows .= << @@ -1163,7 +1216,7 @@ HTML; - +
Usa {'{var}'} para variables. {$lastAt}
@@ -1173,6 +1226,12 @@ HTML; + + +
⚙️ Variables de URL — configura cómo se obtiene cada {'{var}'}
+
{$varRowsHtml}
+ +
📋 Campos del body (para "Pedir campos y enviar")
@@ -1504,6 +1563,72 @@ function collectBfFields(epId) { return JSON.stringify(fields); } +function toggleVarMode(sel) { + const row = sel.closest('.var-row'); + const isAsk = sel.value === 'ask'; + row.querySelector('.vr-ask-wrap').style.display = isAsk ? '' : 'none'; + row.querySelector('.vr-fix-wrap').style.display = isAsk ? 'none' : ''; +} + +function toggleCustom(sel) { + const wrap = sel.closest('.vr-fix-wrap'); + wrap.querySelector('.vr-custom').style.display = sel.value === '__custom' ? 'block' : 'none'; +} + +function collectVarConfigs(epId) { + const rows = document.querySelectorAll('#vr-rows-'+epId+' .var-row'); + const configs = []; + rows.forEach(r => { + const key = r.dataset.key; + const mode = r.querySelector('.vr-mode').value; + if (mode === 'ask') { + configs.push({ key, mode, prompt: r.querySelector('.vr-prompt')?.value.trim() || '' }); + } else { + const preset = r.querySelector('.vr-preset').value; + const value = preset === '__custom' ? (r.querySelector('.vr-custom')?.value.trim() || '') : preset; + configs.push({ key, mode, value }); + } + }); + return JSON.stringify(configs); +} + +// When URL changes, refresh the vars section +function onUrlChange(epId) { + const url = document.getElementById('epu_'+epId).value; + const matches = [...url.matchAll(/\{([^}]+)\}/g)].map(m => m[1]); + const vrRows = document.getElementById('vr-rows-'+epId); + const vrTr = document.getElementById('ep-vars-'+epId); + if (!vrRows) return; + // Add rows for new vars not yet present + const existing = [...vrRows.querySelectorAll('.var-row')].map(r => r.dataset.key); + matches.forEach(key => { + if (existing.includes(key)) return; + const div = document.createElement('div'); + div.className = 'var-row'; + div.dataset.key = key; + div.style.cssText = 'display:grid;grid-template-columns:80px 100px 1fr auto;gap:6px;align-items:center;margin-bottom:5px;font-size:12px'; + div.innerHTML = '{'+key+'}' + ` + + + + + + `; + vrRows.appendChild(div); + }); + if (vrTr) vrTr.style.display = matches.length ? '' : 'none'; +} + async function saveEpRow(cid, epId) { const fd=new FormData(); fd.append('company_id', cid); @@ -1515,6 +1640,7 @@ async function saveEpRow(cid, epId) { fd.append('url', document.getElementById('epu_'+epId).value.trim()); fd.append('is_active', document.getElementById('epa_'+epId).checked ? '1' : '0'); fd.append('body_fields', collectBfFields(epId)); + fd.append('params', collectVarConfigs(epId)); const msg=document.getElementById('epMsg'); const r=await fetch('/admin/company/endpoint/save',{method:'POST',body:fd}); const j=await r.json(); diff --git a/services/NormalBot.php b/services/NormalBot.php index 4262911..0b7b71f 100644 --- a/services/NormalBot.php +++ b/services/NormalBot.php @@ -59,6 +59,11 @@ class NormalBot 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 $sentinels = ['collecting', '__greeted']; if ($currentNode !== null && !in_array($currentNode, $sentinels, true) && isset($flows[$currentNode])) { @@ -178,8 +183,14 @@ class NormalBot // 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)) continue; + if (!is_array($fields) || str_starts_with($group, '__')) continue; foreach ($fields as $key => $value) { $url = str_replace('{' . $key . '}', urlencode((string)$value), $url); } @@ -620,6 +631,57 @@ class NormalBot }; } + // ── 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 *menu* para ver opciones 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'] ?? ''; @@ -630,7 +692,7 @@ class NormalBot return self::sendText('Error: 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 = 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(); @@ -640,18 +702,72 @@ class NormalBot return self::sendText('El informe solicitado no está configurado. Contacta al administrador.' . $nav, $context['from'], $company); } - $meta = ConversationContext::getMetadata($ctxId); - $url = self::substituteUrlVars($ep['url'], $meta); // reemplaza {var} con valores del formulario + // ── 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::substituteUrlVars($ep['url'], array_merge($meta, ['__ep_vars_combined' => $meta['__ep_vars_combined'] ?? []])); $method = strtoupper($ep['method'] ?? 'GET'); $apiKey = $company['api_key'] ?? ''; $extraQuery = $params['query'] ?? []; $dateMode = $params['date_mode'] ?? ''; if ($dateMode === 'today') { - $extraQuery['fecha'] = date('Y-m-d'); + $extraQuery['fecha_desde'] = date('Y-m-d'); + $extraQuery['fecha_hasta'] = date('Y-m-d'); } elseif ($dateMode === 'last_30') { - $extraQuery['fecha_inicio'] = date('Y-m-d', strtotime('-30 days')); - $extraQuery['fecha_fin'] = date('Y-m-d'); + $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'); } $extraQuery['telefono'] = $context['from']; $extraQuery['nombre'] = $context['name'] ?? ''; @@ -693,6 +809,14 @@ class NormalBot return self::sendText('Error al obtener el reporte. Intenta de nuevo.' . $nav, $context['from'], $company); } + // Si el ERP devuelve JSON (ej: sin_datos), 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.'); + ConversationContext::reset($ctxId); + 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'],