Compare commits
107
Commits
34322ae3dc
..
main
+142
@@ -0,0 +1,142 @@
|
|||||||
|
========================= FLOW 1 — CICLOS =========================
|
||||||
|
|
||||||
|
{
|
||||||
|
"version": "7.0",
|
||||||
|
"screens": [
|
||||||
|
{
|
||||||
|
"id": "CICLOS",
|
||||||
|
"title": "Ciclos de lotes",
|
||||||
|
"terminal": true,
|
||||||
|
"data": {
|
||||||
|
"titulo": { "type": "string", "__example__": "Abrir ciclos — Cosecha" },
|
||||||
|
"fecha": { "type": "string", "__example__": "2026-08-04" },
|
||||||
|
"lotes": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "string" },
|
||||||
|
"title": { "type": "string" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"__example__": [
|
||||||
|
{ "id": "4", "title": "REPOSO 1A" },
|
||||||
|
{ "id": "7", "title": "REPOSO 1B" },
|
||||||
|
{ "id": "9", "title": "REPOSO 2A" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"layout": {
|
||||||
|
"type": "SingleColumnLayout",
|
||||||
|
"children": [
|
||||||
|
{ "type": "TextHeading", "text": "${data.titulo}" },
|
||||||
|
{
|
||||||
|
"type": "Form",
|
||||||
|
"name": "form_ciclos",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"type": "DatePicker",
|
||||||
|
"name": "fecha",
|
||||||
|
"label": "Fecha",
|
||||||
|
"required": true,
|
||||||
|
"init-value": "${data.fecha}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "CheckboxGroup",
|
||||||
|
"name": "lotes",
|
||||||
|
"label": "Lotes",
|
||||||
|
"required": true,
|
||||||
|
"data-source": "${data.lotes}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Footer",
|
||||||
|
"label": "Enviar",
|
||||||
|
"on-click-action": {
|
||||||
|
"name": "complete",
|
||||||
|
"payload": {
|
||||||
|
"fecha": "${form.fecha}",
|
||||||
|
"lotes": "${form.lotes}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
====================== FLOW 2 — PLUVIOMETRIA ======================
|
||||||
|
|
||||||
|
{
|
||||||
|
"version": "7.0",
|
||||||
|
"screens": [
|
||||||
|
{
|
||||||
|
"id": "PLUVIOMETRIA",
|
||||||
|
"title": "Registrar pluviometría",
|
||||||
|
"terminal": true,
|
||||||
|
"data": {
|
||||||
|
"fecha": { "type": "string", "__example__": "2026-08-04" },
|
||||||
|
"finca_1_label": { "type": "string", "__example__": "REPOSO" },
|
||||||
|
"finca_1_visible": { "type": "boolean", "__example__": true },
|
||||||
|
"finca_2_label": { "type": "string", "__example__": "ROSA BLANCA" },
|
||||||
|
"finca_2_visible": { "type": "boolean", "__example__": true },
|
||||||
|
"finca_3_label": { "type": "string", "__example__": "" },
|
||||||
|
"finca_3_visible": { "type": "boolean", "__example__": false },
|
||||||
|
"finca_4_label": { "type": "string", "__example__": "" },
|
||||||
|
"finca_4_visible": { "type": "boolean", "__example__": false },
|
||||||
|
"finca_5_label": { "type": "string", "__example__": "" },
|
||||||
|
"finca_5_visible": { "type": "boolean", "__example__": false },
|
||||||
|
"finca_6_label": { "type": "string", "__example__": "" },
|
||||||
|
"finca_6_visible": { "type": "boolean", "__example__": false },
|
||||||
|
"finca_7_label": { "type": "string", "__example__": "" },
|
||||||
|
"finca_7_visible": { "type": "boolean", "__example__": false },
|
||||||
|
"finca_8_label": { "type": "string", "__example__": "" },
|
||||||
|
"finca_8_visible": { "type": "boolean", "__example__": false }
|
||||||
|
},
|
||||||
|
"layout": {
|
||||||
|
"type": "SingleColumnLayout",
|
||||||
|
"children": [
|
||||||
|
{ "type": "TextBody", "text": "Milímetros de lluvia registrados por finca." },
|
||||||
|
{
|
||||||
|
"type": "Form",
|
||||||
|
"name": "form_pluvio",
|
||||||
|
"children": [
|
||||||
|
{ "type": "DatePicker", "name": "fecha", "label": "Fecha", "required": true, "init-value": "${data.fecha}" },
|
||||||
|
|
||||||
|
{ "type": "TextInput", "name": "finca_1", "label": "${data.finca_1_label}", "input-type": "number", "visible": "${data.finca_1_visible}" },
|
||||||
|
{ "type": "TextInput", "name": "finca_2", "label": "${data.finca_2_label}", "input-type": "number", "visible": "${data.finca_2_visible}" },
|
||||||
|
{ "type": "TextInput", "name": "finca_3", "label": "${data.finca_3_label}", "input-type": "number", "visible": "${data.finca_3_visible}" },
|
||||||
|
{ "type": "TextInput", "name": "finca_4", "label": "${data.finca_4_label}", "input-type": "number", "visible": "${data.finca_4_visible}" },
|
||||||
|
{ "type": "TextInput", "name": "finca_5", "label": "${data.finca_5_label}", "input-type": "number", "visible": "${data.finca_5_visible}" },
|
||||||
|
{ "type": "TextInput", "name": "finca_6", "label": "${data.finca_6_label}", "input-type": "number", "visible": "${data.finca_6_visible}" },
|
||||||
|
{ "type": "TextInput", "name": "finca_7", "label": "${data.finca_7_label}", "input-type": "number", "visible": "${data.finca_7_visible}" },
|
||||||
|
{ "type": "TextInput", "name": "finca_8", "label": "${data.finca_8_label}", "input-type": "number", "visible": "${data.finca_8_visible}" },
|
||||||
|
|
||||||
|
{
|
||||||
|
"type": "Footer",
|
||||||
|
"label": "Enviar",
|
||||||
|
"on-click-action": {
|
||||||
|
"name": "complete",
|
||||||
|
"payload": {
|
||||||
|
"fecha": "${form.fecha}",
|
||||||
|
"finca_1": "${form.finca_1}",
|
||||||
|
"finca_2": "${form.finca_2}",
|
||||||
|
"finca_3": "${form.finca_3}",
|
||||||
|
"finca_4": "${form.finca_4}",
|
||||||
|
"finca_5": "${form.finca_5}",
|
||||||
|
"finca_6": "${form.finca_6}",
|
||||||
|
"finca_7": "${form.finca_7}",
|
||||||
|
"finca_8": "${form.finca_8}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+662
-81
@@ -1113,12 +1113,23 @@ HTML;
|
|||||||
$epStmt->execute([$id]);
|
$epStmt->execute([$id]);
|
||||||
$allEps = $epStmt->fetchAll(\PDO::FETCH_ASSOC);
|
$allEps = $epStmt->fetchAll(\PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
$baseRaw = rtrim($company['api_base_url'] ?? '', '/');
|
||||||
|
$baseHtml = self::h($baseRaw);
|
||||||
|
|
||||||
$epRows = '';
|
$epRows = '';
|
||||||
foreach ($allEps as $ep) {
|
foreach ($allEps as $ep) {
|
||||||
$epKey = self::h($ep['endpoint_key']);
|
$epKey = self::h($ep['endpoint_key']);
|
||||||
$epName = self::h($ep['name'] ?? '');
|
$epName = self::h($ep['name'] ?? '');
|
||||||
$epUrl = self::h($ep['url'] ?? '');
|
$epUrl = self::h($ep['url'] ?? '');
|
||||||
$epMeth = $ep['method'] ?? 'GET';
|
$epMeth = $ep['method'] ?? 'GET';
|
||||||
|
// Preview full URL
|
||||||
|
$rawUrl = $ep['url'] ?? '';
|
||||||
|
if ($rawUrl === '' || str_starts_with($rawUrl, 'http')) {
|
||||||
|
$previewUrl = self::h($rawUrl);
|
||||||
|
} else {
|
||||||
|
$sep = str_starts_with($rawUrl, '?') ? '' : '/';
|
||||||
|
$previewUrl = self::h($baseRaw . $sep . ltrim($rawUrl, '/'));
|
||||||
|
}
|
||||||
$epDir = $ep['direction'] ?? 'download';
|
$epDir = $ep['direction'] ?? 'download';
|
||||||
$epParams = self::h($ep['params'] ?? '');
|
$epParams = self::h($ep['params'] ?? '');
|
||||||
$epAct = $ep['is_active'] ? 'checked' : '';
|
$epAct = $ep['is_active'] ? 'checked' : '';
|
||||||
@@ -1215,9 +1226,10 @@ VR;
|
|||||||
<option {$mSel('GET')}>GET</option><option {$mSel('POST')}>POST</option>
|
<option {$mSel('GET')}>GET</option><option {$mSel('POST')}>POST</option>
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
<td style="min-width:200px">
|
<td style="min-width:220px">
|
||||||
<input type="text" value="{$epUrl}" id="epu_{$epId}" placeholder="https://..." oninput="onUrlChange({$epId})" style="width:100%;font-size:12px;padding:4px 6px;border:1px solid #e5e7eb;border-radius:4px">
|
<input type="text" value="{$epUrl}" id="epu_{$epId}" placeholder="?peticion=xxx o /ruta" oninput="onUrlChange({$epId})" style="width:100%;font-size:12px;padding:4px 6px;border:1px solid #e5e7eb;border-radius:4px">
|
||||||
<div style="font-size:10px;color:#9ca3af;margin-top:2px">Usa {'{var}'} para variables. {$lastAt}</div>
|
<div id="ep-preview-{$epId}" style="font-size:10px;color:#6b7280;font-family:monospace;margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="{$previewUrl}">{$previewUrl}</div>
|
||||||
|
<div style="font-size:10px;color:#9ca3af;margin-top:1px">Usa {'{var}'} para variables. {$lastAt}</div>
|
||||||
</td>
|
</td>
|
||||||
<td><input type="checkbox" {$epAct} id="epa_{$epId}" title="Activo"></td>
|
<td><input type="checkbox" {$epAct} id="epa_{$epId}" title="Activo"></td>
|
||||||
<td style="white-space:nowrap">
|
<td style="white-space:nowrap">
|
||||||
@@ -1246,15 +1258,21 @@ ROW;
|
|||||||
|
|
||||||
$epHtml = <<<EP
|
$epHtml = <<<EP
|
||||||
<div class="card-h" style="margin-bottom:12px">🔌 Endpoints API</div>
|
<div class="card-h" style="margin-bottom:12px">🔌 Endpoints API</div>
|
||||||
|
<div style="background:#fffbeb;border:1px solid #fde68a;border-radius:8px;padding:10px 14px;margin-bottom:14px;font-size:12px;display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
||||||
|
<span>🔗 <strong>URL base:</strong></span>
|
||||||
|
<code style="background:#fff8e1;padding:2px 6px;border-radius:4px;font-size:12px">{$baseHtml}</code>
|
||||||
|
<a href="/admin/company/edit?id={$id}&tab=general" style="color:#1e40af;font-size:11px">cambiar en General →</a>
|
||||||
|
<span style="color:#92400e;font-size:11px">· Los endpoints usan rutas relativas a esta URL</span>
|
||||||
|
</div>
|
||||||
<p style="font-size:12px;color:#7a8291;margin-bottom:14px">
|
<p style="font-size:12px;color:#7a8291;margin-bottom:14px">
|
||||||
Define los endpoints del ERP. Usa <code>{'{variable}'}</code> en la URL para variables que el bot pedirá al usuario.<br>
|
Escribe solo la ruta o sufijo: <code>?peticion=produccion</code> o <code>/mi/ruta</code>. Se combina con la URL base.<br>
|
||||||
Ejemplo: <code>?peticion=produccion&desde={'{desde}'}&hasta={'{hasta}'}</code>
|
Usa <code>{'{variable}'}</code> para variables que el bot pedirá al usuario. Ejemplo: <code>?desde={'{desde}'}&hasta={'{hasta}'}</code>
|
||||||
</p>
|
</p>
|
||||||
<div class="card" style="overflow-x:auto">
|
<div class="card" style="overflow-x:auto">
|
||||||
<table style="width:100%;min-width:700px">
|
<table style="width:100%;min-width:700px">
|
||||||
<thead>
|
<thead>
|
||||||
<tr style="font-size:12px;text-align:left">
|
<tr style="font-size:12px;text-align:left">
|
||||||
<th>Key</th><th>Nombre</th><th>Dirección</th><th>Método</th><th>URL</th><th>Activo</th><th></th>
|
<th>Key</th><th>Nombre</th><th>Dirección</th><th>Método</th><th>Ruta / sufijo</th><th>Activo</th><th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="epTableBody">{$epRows}</tbody>
|
<tbody id="epTableBody">{$epRows}</tbody>
|
||||||
@@ -1290,14 +1308,43 @@ ROW;
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>URL</label>
|
<label>Ruta / sufijo</label>
|
||||||
<input type="text" id="newEpUrl" placeholder="https://app.palmas360.com/...?peticion=xxx">
|
<input type="text" id="newEpUrl" placeholder="?peticion=xxx o /mi/ruta" oninput="updateNewEpPreview()">
|
||||||
|
<div id="newEpPreview" style="font-size:11px;color:#6b7280;font-family:monospace;margin-top:4px"></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="newEpMsg" style="margin-bottom:8px"></div>
|
<div id="newEpMsg" style="margin-bottom:8px"></div>
|
||||||
<button class="btn-primary" onclick="addNewEp({$id})">Agregar</button>
|
<button class="btn-primary" onclick="addNewEp({$id})">Agregar</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
EP;
|
EP;
|
||||||
|
|
||||||
|
// Prepend clone panel
|
||||||
|
$otherCompanies = array_filter(CompanyRepository::findAll(), fn($c) => (int)$c['id'] !== $id);
|
||||||
|
$cloneOpts = '<option value="">— Selecciona empresa origen —</option>';
|
||||||
|
foreach ($otherCompanies as $oc) {
|
||||||
|
$cloneOpts .= '<option value="' . (int)$oc['id'] . '">' . self::h($oc['name'] ?? $oc['display_name'] ?? '') . '</option>';
|
||||||
|
}
|
||||||
|
$clonePanel = <<<CLONE
|
||||||
|
<div style="background:#f0f4ff;border:1px solid #c7d7ff;border-radius:10px;padding:14px 16px;margin-bottom:20px">
|
||||||
|
<div style="font-size:13px;font-weight:600;color:#1e40af;margin-bottom:10px">📋 Clonar endpoints desde otra empresa</div>
|
||||||
|
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
|
||||||
|
<select id="cloneSrc" style="flex:1;min-width:200px;padding:7px 10px;border:1px solid #c7d7ff;border-radius:6px;font-size:13px">
|
||||||
|
{$cloneOpts}
|
||||||
|
</select>
|
||||||
|
<button class="btn-secondary" onclick="showCloneOptions()">Clonar →</button>
|
||||||
|
</div>
|
||||||
|
<div id="cloneOptions" style="display:none;margin-top:10px">
|
||||||
|
<div style="font-size:12px;color:#374151;margin-bottom:8px;font-weight:500">¿Cómo quieres clonar los endpoints existentes en esta empresa?</div>
|
||||||
|
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||||
|
<button class="btn-primary" onclick="doClone('overwrite')" style="font-size:12px">Sobreescribir todo</button>
|
||||||
|
<button class="btn-secondary" onclick="doClone('add_only')" style="font-size:12px">Solo añadir faltantes</button>
|
||||||
|
<button onclick="cancelClone()" style="background:none;border:none;font-size:12px;color:#6b7280;cursor:pointer;padding:4px 8px">Cancelar</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="cloneMsg" style="margin-top:8px"></div>
|
||||||
|
</div>
|
||||||
|
CLONE;
|
||||||
|
$epHtml = $clonePanel . $epHtml;
|
||||||
}
|
}
|
||||||
|
|
||||||
// For "copy from" dropdown on new company form
|
// For "copy from" dropdown on new company form
|
||||||
@@ -1592,8 +1639,33 @@ function collectVarConfigs(epId) {
|
|||||||
return JSON.stringify(configs);
|
return JSON.stringify(configs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const BASE_URL = '{$baseHtml}';
|
||||||
|
|
||||||
|
function buildFullUrl(path) {
|
||||||
|
if (!path || path.startsWith('http')) return path;
|
||||||
|
const base = BASE_URL.replace(/\/$/, '');
|
||||||
|
const sep = path.startsWith('?') ? '' : '/';
|
||||||
|
return base + sep + path.replace(/^\//, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateEpPreview(epId) {
|
||||||
|
const path = document.getElementById('epu_'+epId)?.value || '';
|
||||||
|
const el = document.getElementById('ep-preview-'+epId);
|
||||||
|
if (!el) return;
|
||||||
|
const full = buildFullUrl(path);
|
||||||
|
el.textContent = full || '';
|
||||||
|
el.title = full || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateNewEpPreview() {
|
||||||
|
const path = document.getElementById('newEpUrl')?.value || '';
|
||||||
|
const el = document.getElementById('newEpPreview');
|
||||||
|
if (el) el.textContent = buildFullUrl(path) || '';
|
||||||
|
}
|
||||||
|
|
||||||
// When URL changes, refresh the vars section
|
// When URL changes, refresh the vars section
|
||||||
function onUrlChange(epId) {
|
function onUrlChange(epId) {
|
||||||
|
updateEpPreview(epId);
|
||||||
const url = document.getElementById('epu_'+epId).value;
|
const url = document.getElementById('epu_'+epId).value;
|
||||||
const matches = [...url.matchAll(/\{([^}]+)\}/g)].map(m => m[1]);
|
const matches = [...url.matchAll(/\{([^}]+)\}/g)].map(m => m[1]);
|
||||||
const vrRows = document.getElementById('vr-rows-'+epId);
|
const vrRows = document.getElementById('vr-rows-'+epId);
|
||||||
@@ -1700,6 +1772,38 @@ async function addNewEp(cid) {
|
|||||||
msg.innerHTML='<div class="toast toast-error">'+j.error+'</div>';
|
msg.innerHTML='<div class="toast toast-error">'+j.error+'</div>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showCloneOptions() {
|
||||||
|
const src = document.getElementById('cloneSrc').value;
|
||||||
|
if (!src) { alert('Selecciona una empresa origen'); return; }
|
||||||
|
document.getElementById('cloneOptions').style.display = 'block';
|
||||||
|
}
|
||||||
|
function cancelClone() {
|
||||||
|
document.getElementById('cloneOptions').style.display = 'none';
|
||||||
|
}
|
||||||
|
async function doClone(mode) {
|
||||||
|
const src = document.getElementById('cloneSrc').value;
|
||||||
|
if (!src) return;
|
||||||
|
cancelClone();
|
||||||
|
const msg = document.getElementById('cloneMsg');
|
||||||
|
msg.innerHTML = '<span style="font-size:12px;color:#6b7280">Clonando...</span>';
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('source_company_id', src);
|
||||||
|
fd.append('target_company_id', {$id});
|
||||||
|
fd.append('mode', mode);
|
||||||
|
try {
|
||||||
|
const r = await fetch('/admin/company/endpoints/clone', {method:'POST', body:fd});
|
||||||
|
const j = await r.json();
|
||||||
|
if (j.ok) {
|
||||||
|
msg.innerHTML = '<div class="toast toast-success" style="margin:0">' + j.message + '</div>';
|
||||||
|
setTimeout(() => location.reload(), 1200);
|
||||||
|
} else {
|
||||||
|
msg.innerHTML = '<div class="toast toast-error" style="margin:0">' + j.error + '</div>';
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
msg.innerHTML = '<div class="toast toast-error" style="margin:0">Error de red</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
HTML;
|
HTML;
|
||||||
}
|
}
|
||||||
@@ -1737,9 +1841,15 @@ HTML;
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Llamar al endpoint
|
// Llamar al endpoint — prepend base URL si es ruta relativa
|
||||||
|
$epUrl = $ep['url'];
|
||||||
|
if (!str_starts_with($epUrl, 'http')) {
|
||||||
|
$base = rtrim($company['api_base_url'] ?? '', '/');
|
||||||
|
$sep = str_starts_with($epUrl, '?') ? '' : '/';
|
||||||
|
$epUrl = $base . $sep . ltrim($epUrl, '/');
|
||||||
|
}
|
||||||
$apiKey = $company['api_key'] ?? '';
|
$apiKey = $company['api_key'] ?? '';
|
||||||
$ch = curl_init($ep['url']);
|
$ch = curl_init($epUrl);
|
||||||
$opts = [
|
$opts = [
|
||||||
CURLOPT_RETURNTRANSFER => true,
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
CURLOPT_TIMEOUT => 15,
|
CURLOPT_TIMEOUT => 15,
|
||||||
@@ -1990,6 +2100,13 @@ HTML;
|
|||||||
$company = CompanyRepository::findById($companyId);
|
$company = CompanyRepository::findById($companyId);
|
||||||
$apiKey = $company['api_key'] ?? '';
|
$apiKey = $company['api_key'] ?? '';
|
||||||
|
|
||||||
|
// Prepend api_base_url for relative paths
|
||||||
|
if ($url !== '' && !str_starts_with($url, 'http')) {
|
||||||
|
$base = rtrim($company['api_base_url'] ?? '', '/');
|
||||||
|
$sep = str_starts_with($url, '?') ? '' : '/';
|
||||||
|
$url = $base . $sep . ltrim($url, '/');
|
||||||
|
}
|
||||||
|
|
||||||
$ch = curl_init($url);
|
$ch = curl_init($url);
|
||||||
$opts = [
|
$opts = [
|
||||||
CURLOPT_RETURNTRANSFER => true,
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
@@ -2033,6 +2150,55 @@ HTML;
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── POST /admin/company/endpoints/clone ─────────────────────────────────
|
||||||
|
|
||||||
|
public static function companyEndpointsClone(): void
|
||||||
|
{
|
||||||
|
SessionAuth::require();
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
$sourceId = (int)($_POST['source_company_id'] ?? 0);
|
||||||
|
$targetId = (int)($_POST['target_company_id'] ?? 0);
|
||||||
|
$mode = ($_POST['mode'] ?? '') === 'add_only' ? 'add_only' : 'overwrite';
|
||||||
|
|
||||||
|
if ($sourceId <= 0 || $targetId <= 0 || $sourceId === $targetId) {
|
||||||
|
echo json_encode(['ok' => false, 'error' => 'IDs inválidos']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$stmt = db()->prepare("SELECT endpoint_key, direction, url, method FROM company_endpoints WHERE company_id = ?");
|
||||||
|
$stmt->execute([$sourceId]);
|
||||||
|
$sourceEps = $stmt->fetchAll(\PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
if (empty($sourceEps)) {
|
||||||
|
echo json_encode(['ok' => false, 'error' => 'La empresa origen no tiene endpoints configurados']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$existingKeys = [];
|
||||||
|
if ($mode === 'add_only') {
|
||||||
|
$ex = db()->prepare("SELECT endpoint_key FROM company_endpoints WHERE company_id = ? AND url IS NOT NULL AND url != ''");
|
||||||
|
$ex->execute([$targetId]);
|
||||||
|
$existingKeys = array_column($ex->fetchAll(\PDO::FETCH_ASSOC), 'endpoint_key');
|
||||||
|
}
|
||||||
|
|
||||||
|
$upsert = db()->prepare("INSERT INTO company_endpoints (company_id, endpoint_key, direction, url, method) VALUES (?,?,?,?,?) ON DUPLICATE KEY UPDATE url=VALUES(url), method=VALUES(method)");
|
||||||
|
$count = 0;
|
||||||
|
foreach ($sourceEps as $ep) {
|
||||||
|
if ($mode === 'add_only' && in_array($ep['endpoint_key'], $existingKeys, true)) continue;
|
||||||
|
$upsert->execute([$targetId, $ep['endpoint_key'], $ep['direction'], $ep['url'], $ep['method']]);
|
||||||
|
$count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$label = $mode === 'add_only' ? 'añadidos' : 'sobreescritos';
|
||||||
|
echo json_encode(['ok' => true, 'count' => $count, 'message' => "{$count} endpoint(s) {$label} correctamente"]);
|
||||||
|
} catch (\PDOException $e) {
|
||||||
|
echo json_encode(['ok' => false, 'error' => 'Error de base de datos: ' . $e->getMessage()]);
|
||||||
|
}
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── GET /admin/sync-companies ──────────────────────────────────────────
|
// ─── GET /admin/sync-companies ──────────────────────────────────────────
|
||||||
|
|
||||||
public static function syncCompanies(): void
|
public static function syncCompanies(): void
|
||||||
@@ -2132,17 +2298,38 @@ HTML;
|
|||||||
['key' => 'whatsapp_default_phone_number_id', 'label' => 'Phone Number ID (default)', 'type' => 'text', 'placeholder' => '384710295638401'],
|
['key' => 'whatsapp_default_phone_number_id', 'label' => 'Phone Number ID (default)', 'type' => 'text', 'placeholder' => '384710295638401'],
|
||||||
['key' => 'verify_hmac_signature', 'label' => 'Validar firma HMAC de Meta', 'type' => 'checkbox', 'placeholder' => ''],
|
['key' => 'verify_hmac_signature', 'label' => 'Validar firma HMAC de Meta', 'type' => 'checkbox', 'placeholder' => ''],
|
||||||
],
|
],
|
||||||
'Inteligencia Artificial' => [
|
|
||||||
['key' => 'ai_provider', 'label' => 'Proveedor', 'type' => 'select', 'options' => ['mock' => 'Mock (simulado)', 'openai' => 'OpenAI', 'gemini' => 'Google Gemini']],
|
|
||||||
['key' => 'openai_api_key', 'label' => 'OpenAI API Key', 'type' => 'password', 'placeholder' => 'sk-...'],
|
|
||||||
['key' => 'openai_model', 'label' => 'Modelo OpenAI', 'type' => 'select', 'options' => ['gpt-4o-mini' => 'GPT-4o Mini', 'gpt-4o' => 'GPT-4o', 'gpt-3.5-turbo' => 'GPT-3.5 Turbo']],
|
|
||||||
['key' => 'gemini_api_key', 'label' => 'Google Gemini API Key', 'type' => 'password', 'placeholder' => 'AIza...'],
|
|
||||||
['key' => 'gemini_model', 'label' => 'Modelo Gemini', 'type' => 'select', 'options' => ['gemini-2.0-flash' => 'Gemini 2.0 Flash', 'gemini-1.5-flash' => 'Gemini 1.5 Flash', 'gemini-1.5-pro' => 'Gemini 1.5 Pro']],
|
|
||||||
['key' => 'ai_max_tokens', 'label' => 'Máximo de tokens', 'type' => 'number', 'placeholder' => '500'],
|
|
||||||
['key' => 'ai_default_prompt', 'label' => 'System Prompt por defecto', 'type' => 'textarea', 'placeholder' => 'Eres un asistente...'],
|
|
||||||
],
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// ── Valores globales de IA para el panel custom ────────────────────────
|
||||||
|
$gAiProvider = $settings['ai_provider'] ?? 'openai';
|
||||||
|
$gOaiKey = self::h($settings['openai_api_key'] ?? '');
|
||||||
|
$gOaiModel = $settings['openai_model'] ?? 'gpt-4o-mini';
|
||||||
|
$gGemKey = self::h($settings['gemini_api_key'] ?? '');
|
||||||
|
$gGemModel = $settings['gemini_model'] ?? 'gemini-2.5-flash';
|
||||||
|
$gClaKey = self::h($settings['claude_api_key'] ?? '');
|
||||||
|
$gClaModel = $settings['claude_model'] ?? 'claude-haiku-4-5';
|
||||||
|
$gMaxTok = self::h($settings['ai_max_tokens'] ?? '500');
|
||||||
|
$gPrompt = self::h($settings['ai_default_prompt'] ?? '');
|
||||||
|
$gSelOai = $gAiProvider === 'openai' ? ' selected' : '';
|
||||||
|
$gSelGem = $gAiProvider === 'gemini' ? ' selected' : '';
|
||||||
|
$gSelCla = $gAiProvider === 'claude' ? ' selected' : '';
|
||||||
|
$gSelMock = $gAiProvider === 'mock' ? ' selected' : '';
|
||||||
|
$gShowOai = ($gAiProvider === 'openai' || $gAiProvider === '') ? '' : 'display:none';
|
||||||
|
$gShowGem = $gAiProvider === 'gemini' ? '' : 'display:none';
|
||||||
|
$gShowCla = $gAiProvider === 'claude' ? '' : 'display:none';
|
||||||
|
$sOaiMini = $gOaiModel === 'gpt-4o-mini' ? ' selected' : '';
|
||||||
|
$sOai41m = $gOaiModel === 'gpt-4.1-mini' ? ' selected' : '';
|
||||||
|
$sOai4o = $gOaiModel === 'gpt-4o' ? ' selected' : '';
|
||||||
|
$sOai41 = $gOaiModel === 'gpt-4.1' ? ' selected' : '';
|
||||||
|
$sOai35 = $gOaiModel === 'gpt-3.5-turbo' ? ' selected' : '';
|
||||||
|
$sGemF25 = $gGemModel === 'gemini-2.5-flash' ? ' selected' : '';
|
||||||
|
$sGemF20 = $gGemModel === 'gemini-2.0-flash' ? ' selected' : '';
|
||||||
|
$sGemF15 = $gGemModel === 'gemini-1.5-flash' ? ' selected' : '';
|
||||||
|
$sGemP15 = $gGemModel === 'gemini-1.5-pro' ? ' selected' : '';
|
||||||
|
$sClaH = $gClaModel === 'claude-haiku-4-5' ? ' selected' : '';
|
||||||
|
$sClaS = $gClaModel === 'claude-sonnet-5' ? ' selected' : '';
|
||||||
|
$sClaO = $gClaModel === 'claude-opus-4-8' ? ' selected' : '';
|
||||||
|
|
||||||
$versionHash = substr(sha1_file(__DIR__ . '/../.env'), 0, 8);
|
$versionHash = substr(sha1_file(__DIR__ . '/../.env'), 0, 8);
|
||||||
|
|
||||||
http_response_code(200);
|
http_response_code(200);
|
||||||
@@ -2203,45 +2390,131 @@ HTML;
|
|||||||
echo '</div></div></div>';
|
echo '</div></div></div>';
|
||||||
}
|
}
|
||||||
echo <<<HTML
|
echo <<<HTML
|
||||||
|
<!-- IA Global custom card (inside form, saves with the rest) -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-h">🤖 Inteligencia Artificial (Global)</div>
|
||||||
|
<div class="card-b">
|
||||||
|
<div class="form-grid">
|
||||||
|
<div>
|
||||||
|
<label class="lbl">Proveedor por defecto</label>
|
||||||
|
<select class="inp-sel" name="ai_provider" id="g_ai_provider" onchange="gAiChange(this.value)" style="width:100%">
|
||||||
|
<option value="openai"{$gSelOai}>OpenAI (GPT)</option>
|
||||||
|
<option value="gemini"{$gSelGem}>Google Gemini</option>
|
||||||
|
<option value="claude"{$gSelCla}>Anthropic Claude</option>
|
||||||
|
<option value="mock"{$gSelMock}>Mock (simulado)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="lbl">Máximo de tokens</label>
|
||||||
|
<input class="inp" type="number" name="ai_max_tokens" value="{$gMaxTok}" placeholder="500">
|
||||||
|
</div>
|
||||||
|
<div class="fw">
|
||||||
|
<label class="lbl">System Prompt por defecto</label>
|
||||||
|
<textarea class="inp" name="ai_default_prompt" rows="3" placeholder="Eres un asistente...">{$gPrompt}</textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- OpenAI -->
|
||||||
|
<div id="g_oai" style="margin-top:14px;padding:14px;background:#f8f9fb;border-radius:8px;border:1px solid #e5e7eb;{$gShowOai}">
|
||||||
|
<div style="font-weight:700;font-size:13px;margin-bottom:10px">🔵 OpenAI</div>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div>
|
||||||
|
<label class="lbl">API Key</label>
|
||||||
|
<input class="inp" type="password" name="openai_api_key" id="g_oai_key" value="{$gOaiKey}" placeholder="sk-...">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="lbl">Modelo</label>
|
||||||
|
<select class="inp-sel" name="openai_model" id="g_oai_model" style="width:100%">
|
||||||
|
<option value="gpt-4o-mini"{$sOaiMini}>GPT-4o Mini</option>
|
||||||
|
<option value="gpt-4.1-mini"{$sOai41m}>GPT-4.1 Mini</option>
|
||||||
|
<option value="gpt-4o"{$sOai4o}>GPT-4o</option>
|
||||||
|
<option value="gpt-4.1"{$sOai41}>GPT-4.1</option>
|
||||||
|
<option value="gpt-3.5-turbo"{$sOai35}>GPT-3.5 Turbo</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:10px">
|
||||||
|
<button type="button" class="btn-primary" onclick="testProvider('openai','g_oai_key','g_oai_model','g_oai_res')" style="font-size:12px;padding:6px 14px">Probar conexión</button>
|
||||||
|
<span id="g_oai_res" style="font-size:12px;margin-left:10px"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Gemini -->
|
||||||
|
<div id="g_gem" style="margin-top:14px;padding:14px;background:#f8f9fb;border-radius:8px;border:1px solid #e5e7eb;{$gShowGem}">
|
||||||
|
<div style="font-weight:700;font-size:13px;margin-bottom:10px">🟢 Google Gemini</div>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div>
|
||||||
|
<label class="lbl">API Key</label>
|
||||||
|
<input class="inp" type="password" name="gemini_api_key" id="g_gem_key" value="{$gGemKey}" placeholder="AIza...">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="lbl">Modelo</label>
|
||||||
|
<select class="inp-sel" name="gemini_model" id="g_gem_model" style="width:100%">
|
||||||
|
<option value="gemini-2.5-flash"{$sGemF25}>Gemini 2.5 Flash</option>
|
||||||
|
<option value="gemini-2.0-flash"{$sGemF20}>Gemini 2.0 Flash</option>
|
||||||
|
<option value="gemini-1.5-flash"{$sGemF15}>Gemini 1.5 Flash</option>
|
||||||
|
<option value="gemini-1.5-pro"{$sGemP15}>Gemini 1.5 Pro</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:10px">
|
||||||
|
<button type="button" class="btn-primary" onclick="testProvider('gemini','g_gem_key','g_gem_model','g_gem_res')" style="font-size:12px;padding:6px 14px">Probar conexión</button>
|
||||||
|
<span id="g_gem_res" style="font-size:12px;margin-left:10px"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Claude -->
|
||||||
|
<div id="g_cla" style="margin-top:14px;padding:14px;background:#f8f9fb;border-radius:8px;border:1px solid #e5e7eb;{$gShowCla}">
|
||||||
|
<div style="font-weight:700;font-size:13px;margin-bottom:10px">🟣 Anthropic Claude</div>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div>
|
||||||
|
<label class="lbl">API Key</label>
|
||||||
|
<input class="inp" type="password" name="claude_api_key" id="g_cla_key" value="{$gClaKey}" placeholder="sk-ant-...">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="lbl">Modelo</label>
|
||||||
|
<select class="inp-sel" name="claude_model" id="g_cla_model" style="width:100%">
|
||||||
|
<option value="claude-haiku-4-5"{$sClaH}>Claude Haiku 4.5 (rápido)</option>
|
||||||
|
<option value="claude-sonnet-5"{$sClaS}>Claude Sonnet 5</option>
|
||||||
|
<option value="claude-opus-4-8"{$sClaO}>Claude Opus 4.8</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:10px">
|
||||||
|
<button type="button" class="btn-primary" onclick="testProvider('claude','g_cla_key','g_cla_model','g_cla_res')" style="font-size:12px;padding:6px 14px">Probar conexión</button>
|
||||||
|
<span id="g_cla_res" style="font-size:12px;margin-left:10px"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="btn-row">
|
<div class="btn-row">
|
||||||
<a href="/admin/dashboard" class="btn-out" style="background:#ddd;color:#333;padding:10px 24px;border-radius:8px;text-decoration:none;font-size:14px">Cancelar</a>
|
<a href="/admin/dashboard" class="btn-out" style="background:#ddd;color:#333;padding:10px 24px;border-radius:8px;text-decoration:none;font-size:14px">Cancelar</a>
|
||||||
<button type="submit" class="btn-primary">Guardar configuración</button>
|
<button type="submit" class="btn-primary">Guardar configuración</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="card" style="margin-top:16px">
|
|
||||||
<div class="card-h">Probar conexión IA</div>
|
|
||||||
<div class="card-b">
|
|
||||||
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
|
|
||||||
<button type="button" class="btn-primary" onclick="testAiConn()" id="btnTestAi">Probar conexión</button>
|
|
||||||
<span id="aiTestResult" style="font-size:13px"></span>
|
|
||||||
</div>
|
|
||||||
<small style="margin-top:8px;color:#888">Guarda primero la configuración, luego prueba la conexión. Se enviará un mensaje de prueba al proveedor activo.</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="version-info">Los cambios se aplican inmediatamente. Los valores se almacenan en la base de datos.</div>
|
<div class="version-info">Los cambios se aplican inmediatamente. Los valores se almacenan en la base de datos.</div>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
function testAiConn() {
|
function gAiChange(v) {
|
||||||
var btn = document.getElementById('btnTestAi');
|
document.getElementById('g_oai').style.display = (v === 'openai' || !v) ? '' : 'none';
|
||||||
var res = document.getElementById('aiTestResult');
|
document.getElementById('g_gem').style.display = v === 'gemini' ? '' : 'none';
|
||||||
btn.disabled = true;
|
document.getElementById('g_cla').style.display = v === 'claude' ? '' : 'none';
|
||||||
res.textContent = 'Probando...';
|
}
|
||||||
res.style.color = '#666';
|
function testProvider(provider, keyId, modelId, resId) {
|
||||||
fetch('/admin/settings/test-ai', {method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded'}, body:''})
|
var key = document.getElementById(keyId)?.value || '';
|
||||||
|
var model = document.getElementById(modelId)?.value || '';
|
||||||
|
var res = document.getElementById(resId);
|
||||||
|
res.textContent = 'Probando...'; res.style.color = '#666';
|
||||||
|
var body = 'provider=' + encodeURIComponent(provider) + '&api_key=' + encodeURIComponent(key) + '&model=' + encodeURIComponent(model);
|
||||||
|
fetch('/admin/settings/test-ai', {method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded'}, body: body})
|
||||||
.then(function(r){return r.json();})
|
.then(function(r){return r.json();})
|
||||||
.then(function(d){
|
.then(function(d){
|
||||||
if (d.ok) {
|
if (d.ok) { res.textContent = '✅ OK: ' + d.response; res.style.color = '#16a34a'; }
|
||||||
res.textContent = 'Conexion exitosa (' + d.provider + '): ' + d.response;
|
else { res.textContent = '❌ ' + d.error; res.style.color = '#dc2626'; }
|
||||||
res.style.color = '#16a34a';
|
|
||||||
} else {
|
|
||||||
res.textContent = 'Error (' + d.provider + '): ' + d.error;
|
|
||||||
res.style.color = '#dc2626';
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.catch(function(e){ res.textContent = 'Error de red'; res.style.color = '#dc2626'; })
|
.catch(function(){ res.textContent = 'Error de red'; res.style.color = '#dc2626'; });
|
||||||
.finally(function(){ btn.disabled = false; });
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
@@ -2869,6 +3142,7 @@ HTML;
|
|||||||
$fallbackVal = self::h($config['fallback'] ?? '');
|
$fallbackVal = self::h($config['fallback'] ?? '');
|
||||||
$aiPromptVal = self::h($config['ai_prompt'] ?? '');
|
$aiPromptVal = self::h($config['ai_prompt'] ?? '');
|
||||||
$aiForMediaChecked = ($config['ai_for_media'] ?? true) ? 'checked' : '';
|
$aiForMediaChecked = ($config['ai_for_media'] ?? true) ? 'checked' : '';
|
||||||
|
$nluChecked = !empty($config['nlu']) ? 'checked' : '';
|
||||||
$aiModelVal = self::h($config['ai_model'] ?? 'gpt-4o-mini');
|
$aiModelVal = self::h($config['ai_model'] ?? 'gpt-4o-mini');
|
||||||
$aiTempVal = self::h((string)($config['ai_temperature'] ?? 0.7));
|
$aiTempVal = self::h((string)($config['ai_temperature'] ?? 0.7));
|
||||||
$aiProviderVal = self::h($config['ai_provider'] ?? '');
|
$aiProviderVal = self::h($config['ai_provider'] ?? '');
|
||||||
@@ -2885,44 +3159,115 @@ HTML;
|
|||||||
}
|
}
|
||||||
$aiProviderOpenai = $aiProviderVal === 'openai' ? ' selected' : '';
|
$aiProviderOpenai = $aiProviderVal === 'openai' ? ' selected' : '';
|
||||||
$aiProviderGemini = $aiProviderVal === 'gemini' ? ' selected' : '';
|
$aiProviderGemini = $aiProviderVal === 'gemini' ? ' selected' : '';
|
||||||
|
$aiProviderClaude = $aiProviderVal === 'claude' ? ' selected' : '';
|
||||||
$aiProviderMock = $aiProviderVal === 'mock' ? ' selected' : '';
|
$aiProviderMock = $aiProviderVal === 'mock' ? ' selected' : '';
|
||||||
$openaiApiKeyVal = self::h($config['openai_api_key'] ?? '');
|
$openaiApiKeyVal = self::h($config['openai_api_key'] ?? '');
|
||||||
|
$aiModelVal = $config['ai_model'] ?? 'gpt-4o-mini';
|
||||||
|
$aiModelMini = $aiModelVal === 'gpt-4o-mini' ? ' selected' : '';
|
||||||
|
$aiModel4o = $aiModelVal === 'gpt-4o' ? ' selected' : '';
|
||||||
|
$aiModel41 = $aiModelVal === 'gpt-4.1' ? ' selected' : '';
|
||||||
|
$aiModel41mini = $aiModelVal === 'gpt-4.1-mini' ? ' selected' : '';
|
||||||
|
$aiModel35 = $aiModelVal === 'gpt-3.5-turbo'? ' selected' : '';
|
||||||
$geminiApiKeyVal = self::h($config['gemini_api_key'] ?? '');
|
$geminiApiKeyVal = self::h($config['gemini_api_key'] ?? '');
|
||||||
$geminiModelVal = $config['gemini_model'] ?? 'gemini-2.0-flash';
|
$geminiModelVal = $config['gemini_model'] ?? 'gemini-2.5-flash';
|
||||||
|
$whisperUrlVal = self::h($config['whisper_url'] ?? '');
|
||||||
|
$whisperUserVal = self::h($config['whisper_user'] ?? '');
|
||||||
|
$whisperPassVal = self::h($config['whisper_pass'] ?? '');
|
||||||
|
$geminiModelF25 = $geminiModelVal === 'gemini-2.5-flash' ? ' selected' : '';
|
||||||
$geminiModelF20 = $geminiModelVal === 'gemini-2.0-flash' ? ' selected' : '';
|
$geminiModelF20 = $geminiModelVal === 'gemini-2.0-flash' ? ' selected' : '';
|
||||||
$geminiModelF15 = $geminiModelVal === 'gemini-1.5-flash' ? ' selected' : '';
|
$geminiModelF15 = $geminiModelVal === 'gemini-1.5-flash' ? ' selected' : '';
|
||||||
$geminiModelP15 = $geminiModelVal === 'gemini-1.5-pro' ? ' selected' : '';
|
$geminiModelP15 = $geminiModelVal === 'gemini-1.5-pro' ? ' selected' : '';
|
||||||
|
$claudeApiKeyVal = self::h($config['claude_api_key'] ?? '');
|
||||||
|
$claudeModelVal = $config['claude_model'] ?? 'claude-haiku-4-5';
|
||||||
|
$claudeModelH = $claudeModelVal === 'claude-haiku-4-5' ? ' selected' : '';
|
||||||
|
$claudeModelS = $claudeModelVal === 'claude-sonnet-5' ? ' selected' : '';
|
||||||
|
$claudeModelO = $claudeModelVal === 'claude-opus-4-8' ? ' selected' : '';
|
||||||
// Determine which provider panel to show initially
|
// Determine which provider panel to show initially
|
||||||
$showOpenai = ($aiProviderVal === 'openai' || $aiProviderVal === '')
|
$showOpenai = ($aiProviderVal === 'openai' || $aiProviderVal === '')
|
||||||
? 'display:flex;flex-direction:column;gap:10px' : 'display:none';
|
? 'display:flex;flex-direction:column;gap:10px' : 'display:none';
|
||||||
$showGemini = $aiProviderVal === 'gemini'
|
$showGemini = $aiProviderVal === 'gemini'
|
||||||
? 'display:flex;flex-direction:column;gap:10px' : 'display:none';
|
? 'display:flex;flex-direction:column;gap:10px' : 'display:none';
|
||||||
|
$showClaude = $aiProviderVal === 'claude'
|
||||||
|
? 'display:flex;flex-direction:column;gap:10px' : 'display:none';
|
||||||
|
|
||||||
// Per-category config HTML
|
// Per-category config HTML
|
||||||
$perTypeConfig = $config['per_type'] ?? [];
|
$perTypeConfig = $config['per_type'] ?? [];
|
||||||
$menuKeysList = array_keys($menus);
|
$menuKeysList = array_keys($menus);
|
||||||
$catLabels = ['1' => 'Categoría 1 — Solo reporta', '2' => 'Categoría 2 — Solo recibe informes', '3' => 'Categoría 3 — Reporta y recibe'];
|
$flowKeysList = array_keys($config['flows'] ?? []);
|
||||||
|
// Include per_type menus/flows in the option lists
|
||||||
|
foreach ($config['per_type'] ?? [] as $pt) {
|
||||||
|
foreach (array_keys($pt['menus'] ?? []) as $k) if (!in_array($k, $menuKeysList)) $menuKeysList[] = $k;
|
||||||
|
foreach (array_keys($pt['flows'] ?? []) as $k) if (!in_array($k, $flowKeysList)) $flowKeysList[] = $k;
|
||||||
|
}
|
||||||
|
$catMeta = [
|
||||||
|
'1' => ['label' => 'Categoría 1 — Solo reporta', 'icon' => '📤', 'bg' => '#f0fdf4', 'bd' => '#86efac', 'clr' => '#166534'],
|
||||||
|
'2' => ['label' => 'Categoría 2 — Solo recibe informes', 'icon' => '📥', 'bg' => '#eff6ff', 'bd' => '#93c5fd', 'clr' => '#1e40af'],
|
||||||
|
'3' => ['label' => 'Categoría 3 — Reporta y recibe', 'icon' => '🔄', 'bg' => '#fdf4ff', 'bd' => '#e879f9', 'clr' => '#701a75'],
|
||||||
|
];
|
||||||
$categoryTabHtml = '';
|
$categoryTabHtml = '';
|
||||||
|
// Build shared option lists
|
||||||
|
$menuOptsBase = '<option value="">— Sin menú —</option>';
|
||||||
|
foreach ($menuKeysList as $mk) $menuOptsBase .= '<option value="' . self::h($mk) . '">' . self::h($mk) . '</option>';
|
||||||
|
$flowOptsBase = '<option value="">— Ninguno —</option>';
|
||||||
|
foreach ($menuKeysList as $mk) $flowOptsBase .= '<option value="' . self::h($mk) . '">📑 ' . self::h($mk) . '</option>';
|
||||||
|
foreach ($flowKeysList as $fk) $flowOptsBase .= '<option value="' . self::h($fk) . '">🔀 ' . self::h($fk) . '</option>';
|
||||||
|
|
||||||
foreach ([1, 2, 3] as $cat) {
|
foreach ([1, 2, 3] as $cat) {
|
||||||
$catCfg = $perTypeConfig[(string)$cat] ?? [];
|
$catCfg = $perTypeConfig[(string)$cat] ?? [];
|
||||||
$selMenu = $catCfg['greeting_menu'] ?? '';
|
$m = $catMeta[(string)$cat];
|
||||||
$opts = '<option value="">— Usa configuración global —</option>';
|
$greetFlow = self::h($catCfg['greeting_flow'] ?? '');
|
||||||
foreach ($menuKeysList as $mk) {
|
$fallback = $catCfg['fallback_flow'] ?? '';
|
||||||
$s = $selMenu === $mk ? ' selected' : '';
|
$selMenu = $catCfg['greeting_menu'] ?? '';
|
||||||
$opts .= '<option value="' . self::h($mk) . '"' . $s . '>' . self::h($mk) . '</option>';
|
|
||||||
}
|
// greeting_menu selector (insert selected attr)
|
||||||
$label = $catLabels[(string)$cat];
|
$menuOpts = str_replace(
|
||||||
|
'value="' . self::h($selMenu) . '"',
|
||||||
|
'value="' . self::h($selMenu) . '" selected',
|
||||||
|
$menuOptsBase
|
||||||
|
);
|
||||||
|
// fallback selector (insert selected attr)
|
||||||
|
$fbOpts = str_replace(
|
||||||
|
'value="' . self::h($fallback) . '"',
|
||||||
|
'value="' . self::h($fallback) . '" selected',
|
||||||
|
$flowOptsBase
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mostrar greeting_menu solo cuando NO hay greeting_flow (cat 1)
|
||||||
|
$hasGreetFlow = $greetFlow !== '';
|
||||||
|
$greetFlowStyle = '';
|
||||||
|
$greetMenuStyle = $hasGreetFlow ? 'display:none' : '';
|
||||||
|
|
||||||
|
$label = $m['label']; $icon = $m['icon']; $bg = $m['bg']; $bd = $m['bd']; $clr = $m['clr'];
|
||||||
$categoryTabHtml .= <<<CATHTML
|
$categoryTabHtml .= <<<CATHTML
|
||||||
<div style="background:#f8f9fd;border:1px solid #eef1f5;border-radius:10px;padding:18px;margin-bottom:16px">
|
<div style="background:{$bg};border:1px solid {$bd};border-radius:10px;padding:18px;margin-bottom:16px">
|
||||||
<div style="font-weight:700;font-size:13px;color:#0b3d91;margin-bottom:12px">{$label}</div>
|
<div style="display:flex;align-items:center;gap:8px;margin-bottom:14px">
|
||||||
<div class="form-group" style="margin-bottom:0">
|
<span style="font-size:20px">{$icon}</span>
|
||||||
<label>Menú de bienvenida (greeting menu)</label>
|
<span style="font-weight:700;font-size:13px;color:{$clr}">{$label}</span>
|
||||||
<select name="per_type_menu[{$cat}]" style="width:100%;padding:9px 12px;border:1px solid #e2e5ea;border-radius:8px;font-size:13px">
|
</div>
|
||||||
{$opts}
|
<div class="form-group" style="{$greetFlowStyle};margin-bottom:12px">
|
||||||
</select>
|
<label>Flujo de bienvenida</label>
|
||||||
<div style="font-size:11px;color:#7a8291;margin-top:4px">Menú que se muestra cuando el usuario escribe por primera vez o sin contexto activo</div>
|
<input type="text" name="per_type_greeting_flow[{$cat}]" value="{$greetFlow}"
|
||||||
</div>
|
list="ptFlowsDatalist" placeholder="ej: ask_finca (se auto-selecciona si hay 1 sola opción)"
|
||||||
</div>
|
style="width:100%;padding:8px 12px;border:1px solid #e2e5ea;border-radius:8px;font-size:13px">
|
||||||
|
<div style="font-size:11px;color:#7a8291;margin-top:3px">
|
||||||
|
Flow que se ejecuta al primer mensaje. Si el flow retorna 1 sola opción (finca, empresa, etc.) la selecciona automáticamente sin preguntar.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="cat-greet-menu-{$cat}" style="{$greetMenuStyle}" class="form-group" style="margin-bottom:12px">
|
||||||
|
<label>Menú inicial</label>
|
||||||
|
<select name="per_type_menu[{$cat}]" style="width:100%;padding:9px 12px;border:1px solid #e2e5ea;border-radius:8px;font-size:13px">
|
||||||
|
{$menuOpts}
|
||||||
|
</select>
|
||||||
|
<div style="font-size:11px;color:#7a8291;margin-top:3px">Menú que se muestra cuando no hay flujo de bienvenida configurado</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin-bottom:0">
|
||||||
|
<label>Flujo de respaldo (fallback)</label>
|
||||||
|
<select name="per_type_fallback[{$cat}]" style="width:100%;padding:9px 12px;border:1px solid #e2e5ea;border-radius:8px;font-size:13px">
|
||||||
|
{$fbOpts}
|
||||||
|
</select>
|
||||||
|
<div style="font-size:11px;color:#7a8291;margin-top:3px">Se muestra cuando el usuario envía algo que no corresponde a ninguna opción</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
CATHTML;
|
CATHTML;
|
||||||
}
|
}
|
||||||
$aiModelMini = $aiModelVal === 'gpt-4o-mini' ? ' selected' : '';
|
$aiModelMini = $aiModelVal === 'gpt-4o-mini' ? ' selected' : '';
|
||||||
@@ -2976,6 +3321,7 @@ HTML;
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<button type="button" class="add-btn" onclick="addCmd()">+ Agregar comando</button>
|
<button type="button" class="add-btn" onclick="addCmd()">+ Agregar comando</button>
|
||||||
|
<div style="margin-top:14px"><button type="submit" class="btn-primary">💾 Guardar</button></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ─── MENUS ────────────────────────────────────────────────────────── -->
|
<!-- ─── MENUS ────────────────────────────────────────────────────────── -->
|
||||||
@@ -3065,6 +3411,7 @@ HTML;
|
|||||||
echo <<<HTML
|
echo <<<HTML
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="add-btn" onclick="addMenu()">+ Agregar menú</button>
|
<button type="button" class="add-btn" onclick="addMenu()">+ Agregar menú</button>
|
||||||
|
<div style="margin-top:14px"><button type="submit" class="btn-primary">💾 Guardar</button></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ─── FLOWS ────────────────────────────────────────────────────────── -->
|
<!-- ─── FLOWS ────────────────────────────────────────────────────────── -->
|
||||||
@@ -3092,6 +3439,7 @@ HTML;
|
|||||||
$flowIdx = 0;
|
$flowIdx = 0;
|
||||||
foreach ($flows as $fk => $flow) {
|
foreach ($flows as $fk => $flow) {
|
||||||
$ft = $flow['type'] ?? 'text';
|
$ft = $flow['type'] ?? 'text';
|
||||||
|
$fNluDesc = self::h($flow['nlu_description'] ?? '');
|
||||||
$fMsg = self::h($flow['message'] ?? '');
|
$fMsg = self::h($flow['message'] ?? '');
|
||||||
$fFn = $flow['function'] ?? 'forward_to_ai';
|
$fFn = $flow['function'] ?? 'forward_to_ai';
|
||||||
$fMenu = $flow['menu'] ?? '';
|
$fMenu = $flow['menu'] ?? '';
|
||||||
@@ -3207,6 +3555,8 @@ HTML;
|
|||||||
<div class=\"item-card\" id=\"flow-card-{$flowIdx}\">
|
<div class=\"item-card\" id=\"flow-card-{$flowIdx}\">
|
||||||
<div class=\"item-head\">
|
<div class=\"item-head\">
|
||||||
<span>ID: <input type=\"text\" name=\"flow_key[]\" value=\"" . self::h($fk) . "\" style=\"border:none;background:transparent;font-weight:700;color:#0b3d91;width:180px;font-size:13px\"></span>
|
<span>ID: <input type=\"text\" name=\"flow_key[]\" value=\"" . self::h($fk) . "\" style=\"border:none;background:transparent;font-weight:700;color:#0b3d91;width:180px;font-size:13px\"></span>
|
||||||
|
<span style=\"font-size:11px;color:#7a8291;font-weight:400;margin-left:8px\">🧠 NLU:</span>
|
||||||
|
<input type=\"text\" name=\"flow_nlu_desc[]\" value=\"{$fNluDesc}\" placeholder=\"Descripción para IA (ej: Registrar pluviometría)\" style=\"border:none;background:#f0f4ff;border-radius:4px;padding:2px 8px;font-size:11px;color:#374151;flex:1;min-width:0\">
|
||||||
<button type=\"button\" class=\"del\" onclick=\"this.closest('.item-card').remove()\">✕</button>
|
<button type=\"button\" class=\"del\" onclick=\"this.closest('.item-card').remove()\">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div class=\"form-row\" style=\"align-items:flex-start;gap:10px\">
|
<div class=\"form-row\" style=\"align-items:flex-start;gap:10px\">
|
||||||
@@ -3340,12 +3690,19 @@ HTML;
|
|||||||
echo <<<HTML
|
echo <<<HTML
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="add-btn" onclick="addFlowCard()">+ Agregar flujo</button>
|
<button type="button" class="add-btn" onclick="addFlowCard()">+ Agregar flujo</button>
|
||||||
|
<div style="margin-top:14px"><button type="submit" class="btn-primary">💾 Guardar</button></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ─── POR CATEGORÍA ────────────────────────────────────────────────── -->
|
<!-- ─── POR CATEGORÍA ────────────────────────────────────────────────── -->
|
||||||
<div class="tab-content" id="tab-categories">
|
<div class="tab-content" id="tab-categories">
|
||||||
<div class="card-h">👥 Configuración por Categoría</div>
|
<div class="card-h">👥 Configuración por Categoría</div>
|
||||||
<p style="font-size:12px;color:#7a8291;margin-bottom:16px">Define qué menú de bienvenida ve cada categoría de usuario cuando escribe por primera vez o sin contexto activo.</p>
|
<p style="font-size:12px;color:#7a8291;margin-bottom:16px">Define el comportamiento inicial de cada categoría de usuario: si pide finca, qué menú muestra y qué hacer ante texto no reconocido.</p>
|
||||||
|
<datalist id="ptFlowsDatalist">
|
||||||
|
HTML;
|
||||||
|
foreach ($menuKeysList as $mk) echo '<option value="' . self::h($mk) . '">📑 Menú</option>' . "\n";
|
||||||
|
foreach ($flowKeysList as $fk) echo '<option value="' . self::h($fk) . '">🔀 Flow</option>' . "\n";
|
||||||
|
echo <<<HTML
|
||||||
|
</datalist>
|
||||||
{$categoryTabHtml}
|
{$categoryTabHtml}
|
||||||
<button type="button" id="savePerTypeBtn" onclick="savePerType()" class="btn-primary" style="margin-top:8px">💾 Guardar categorías</button>
|
<button type="button" id="savePerTypeBtn" onclick="savePerType()" class="btn-primary" style="margin-top:8px">💾 Guardar categorías</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -3359,6 +3716,7 @@ HTML;
|
|||||||
<option value="">Usar configuración global</option>
|
<option value="">Usar configuración global</option>
|
||||||
<option value="openai"{$aiProviderOpenai}>OpenAI (GPT)</option>
|
<option value="openai"{$aiProviderOpenai}>OpenAI (GPT)</option>
|
||||||
<option value="gemini"{$aiProviderGemini}>Google Gemini</option>
|
<option value="gemini"{$aiProviderGemini}>Google Gemini</option>
|
||||||
|
<option value="claude"{$aiProviderClaude}>Anthropic Claude</option>
|
||||||
<option value="mock"{$aiProviderMock}>Mock (simulado)</option>
|
<option value="mock"{$aiProviderMock}>Mock (simulado)</option>
|
||||||
</select>
|
</select>
|
||||||
<div style="font-size:11px;color:#7a8291;margin-top:3px">Si no se selecciona, usa el proveedor de Configuración General</div>
|
<div style="font-size:11px;color:#7a8291;margin-top:3px">Si no se selecciona, usa el proveedor de Configuración General</div>
|
||||||
@@ -3375,7 +3733,9 @@ HTML;
|
|||||||
<label>Modelo</label>
|
<label>Modelo</label>
|
||||||
<select name="ai_model">
|
<select name="ai_model">
|
||||||
<option value="gpt-4o-mini"{$aiModelMini}>GPT-4o Mini</option>
|
<option value="gpt-4o-mini"{$aiModelMini}>GPT-4o Mini</option>
|
||||||
|
<option value="gpt-4.1-mini"{$aiModel41mini}>GPT-4.1 Mini</option>
|
||||||
<option value="gpt-4o"{$aiModel4o}>GPT-4o</option>
|
<option value="gpt-4o"{$aiModel4o}>GPT-4o</option>
|
||||||
|
<option value="gpt-4.1"{$aiModel41}>GPT-4.1</option>
|
||||||
<option value="gpt-3.5-turbo"{$aiModel35}>GPT-3.5 Turbo</option>
|
<option value="gpt-3.5-turbo"{$aiModel35}>GPT-3.5 Turbo</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -3392,6 +3752,7 @@ HTML;
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Modelo Gemini</label>
|
<label>Modelo Gemini</label>
|
||||||
<select name="gemini_model">
|
<select name="gemini_model">
|
||||||
|
<option value="gemini-2.5-flash"{$geminiModelF25}>Gemini 2.5 Flash</option>
|
||||||
<option value="gemini-2.0-flash"{$geminiModelF20}>Gemini 2.0 Flash</option>
|
<option value="gemini-2.0-flash"{$geminiModelF20}>Gemini 2.0 Flash</option>
|
||||||
<option value="gemini-1.5-flash"{$geminiModelF15}>Gemini 1.5 Flash</option>
|
<option value="gemini-1.5-flash"{$geminiModelF15}>Gemini 1.5 Flash</option>
|
||||||
<option value="gemini-1.5-pro"{$geminiModelP15}>Gemini 1.5 Pro</option>
|
<option value="gemini-1.5-pro"{$geminiModelP15}>Gemini 1.5 Pro</option>
|
||||||
@@ -3400,6 +3761,44 @@ HTML;
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Claude fields -->
|
||||||
|
<div id="ai-claude-fields" style="{$showClaude}">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>API Key Anthropic</label>
|
||||||
|
<input type="password" name="claude_api_key" value="{$claudeApiKeyVal}" placeholder="sk-ant-...">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Modelo Claude</label>
|
||||||
|
<select name="claude_model">
|
||||||
|
<option value="claude-haiku-4-5"{$claudeModelH}>Claude Haiku 4.5 (rápido)</option>
|
||||||
|
<option value="claude-sonnet-5"{$claudeModelS}>Claude Sonnet 5</option>
|
||||||
|
<option value="claude-opus-4-8"{$claudeModelO}>Claude Opus 4.8</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Whisper server (auto-hosted) -->
|
||||||
|
<div class="form-group" style="margin-top:12px;padding:12px;background:#f0f7f0;border:1px solid #c6e0c6;border-radius:8px">
|
||||||
|
<label style="font-weight:600">🎙️ Servidor Whisper propio (transcripción de audio)</label>
|
||||||
|
<div style="font-size:11px;color:#4a5568;margin-bottom:8px">Si se configura, el audio se transcribe aquí en vez del proveedor seleccionado arriba.</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>URL del servidor</label>
|
||||||
|
<input type="text" name="whisper_url" value="{$whisperUrlVal}" placeholder="https://whisper.u-s.app/asr">
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Usuario</label>
|
||||||
|
<input type="text" name="whisper_user" value="{$whisperUserVal}" placeholder="whisper">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Contraseña</label>
|
||||||
|
<input type="password" name="whisper_pass" value="{$whisperPassVal}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-row" style="margin-top:4px">
|
<div class="form-row" style="margin-top:4px">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Temperatura ({$aiTempVal})</label>
|
<label>Temperatura ({$aiTempVal})</label>
|
||||||
@@ -3421,6 +3820,19 @@ HTML;
|
|||||||
Si está desactivado, el bot muestra el menú de categoría directamente.
|
Si está desactivado, el bot muestra el menú de categoría directamente.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group" style="margin-top:12px;padding:14px;background:#f0f4ff;border:1px solid #c7d7f7;border-radius:10px">
|
||||||
|
<label style="display:flex;align-items:center;gap:10px;cursor:pointer">
|
||||||
|
<input type="checkbox" name="nlu" value="1" {$nluChecked}>
|
||||||
|
<span style="font-weight:700">🧠 Habilitar NLU — enrutamiento por IA</span>
|
||||||
|
</label>
|
||||||
|
<div style="font-size:11px;color:#4a5568;margin-top:6px;line-height:1.6">
|
||||||
|
Cuando el usuario escribe algo que el bot no reconoce, la IA lo ubica en el menú o flujo correcto según sus permisos.<br>
|
||||||
|
Audio → transcripción automática (Whisper/Gemini) → enrutado.<br>
|
||||||
|
Imagen → la IA extrae el contexto → enrutado.<br>
|
||||||
|
<strong>Requiere bot_type = hybrid y proveedor de IA configurado.</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:14px"><button type="button" id="saveAiBtn" onclick="saveAiConfig()" class="btn-primary">💾 Guardar IA</button></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ─── GENERAL ──────────────────────────────────────────────────────── -->
|
<!-- ─── GENERAL ──────────────────────────────────────────────────────── -->
|
||||||
@@ -3479,6 +3891,7 @@ HTML;
|
|||||||
<div style="font-size:10px;color:#a0a8b8;text-align:center;margin-top:6px">Vista previa aproximada</div>
|
<div style="font-size:10px;color:#a0a8b8;text-align:center;margin-top:6px">Vista previa aproximada</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div style="margin-top:14px"><button type="submit" class="btn-primary">💾 Guardar</button></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="display:flex;gap:12px;margin-top:16px">
|
<div style="display:flex;gap:12px;margin-top:16px">
|
||||||
@@ -3497,8 +3910,12 @@ function savePerType() {
|
|||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.set('company_id', cid);
|
fd.set('company_id', cid);
|
||||||
[1, 2, 3].forEach(function(cat) {
|
[1, 2, 3].forEach(function(cat) {
|
||||||
|
var gf = document.querySelector('[name="per_type_greeting_flow[' + cat + ']"]');
|
||||||
var sel = document.querySelector('[name="per_type_menu[' + cat + ']"]');
|
var sel = document.querySelector('[name="per_type_menu[' + cat + ']"]');
|
||||||
|
var fb = document.querySelector('[name="per_type_fallback[' + cat + ']"]');
|
||||||
|
if (gf) fd.append('per_type_greeting_flow[' + cat + ']', gf.value);
|
||||||
if (sel) fd.append('per_type_menu[' + cat + ']', sel.value);
|
if (sel) fd.append('per_type_menu[' + cat + ']', sel.value);
|
||||||
|
if (fb) fd.append('per_type_fallback[' + cat + ']', fb.value);
|
||||||
});
|
});
|
||||||
var btn = document.getElementById('savePerTypeBtn');
|
var btn = document.getElementById('savePerTypeBtn');
|
||||||
if (btn) { btn.textContent = 'Guardando...'; btn.disabled = true; }
|
if (btn) { btn.textContent = 'Guardando...'; btn.disabled = true; }
|
||||||
@@ -3514,6 +3931,45 @@ function savePerType() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function saveAiConfig() {
|
||||||
|
const cid = document.querySelector('input[name="company_id"]').value;
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.set('company_id', cid);
|
||||||
|
const qs = sel => document.querySelector(sel);
|
||||||
|
fd.set('ai_provider', qs('select[name="ai_provider"]')?.value || '');
|
||||||
|
fd.set('ai_model', qs('select[name="ai_model"]')?.value || 'gpt-4o-mini');
|
||||||
|
fd.set('ai_temperature', qs('input[name="ai_temperature"]')?.value || '0.7');
|
||||||
|
fd.set('ai_prompt', qs('textarea[name="ai_prompt"]')?.value || '');
|
||||||
|
fd.set('gemini_model', qs('select[name="gemini_model"]')?.value || '');
|
||||||
|
fd.set('claude_model', qs('select[name="claude_model"]')?.value || '');
|
||||||
|
const oKey = qs('input[name="openai_api_key"]')?.value || '';
|
||||||
|
const gKey = qs('input[name="gemini_api_key"]')?.value || '';
|
||||||
|
const cKey = qs('input[name="claude_api_key"]')?.value || '';
|
||||||
|
if (oKey) fd.set('openai_api_key', oKey);
|
||||||
|
if (gKey) fd.set('gemini_api_key', gKey);
|
||||||
|
if (cKey) fd.set('claude_api_key', cKey);
|
||||||
|
fd.set('whisper_url', qs('input[name="whisper_url"]')?.value || '');
|
||||||
|
fd.set('whisper_user', qs('input[name="whisper_user"]')?.value || '');
|
||||||
|
const wPass = qs('input[name="whisper_pass"]')?.value || '';
|
||||||
|
if (wPass) fd.set('whisper_pass', wPass);
|
||||||
|
if (qs('input[name="ai_for_media"]')?.checked) fd.set('ai_for_media', '1');
|
||||||
|
if (qs('input[name="nlu"]')?.checked) fd.set('nlu', '1');
|
||||||
|
|
||||||
|
const btn = document.getElementById('saveAiBtn');
|
||||||
|
if (btn) { btn.textContent = 'Guardando...'; btn.disabled = true; }
|
||||||
|
fetch('/admin/bot-config/save-ai', { method: 'POST', body: fd })
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => {
|
||||||
|
if (btn) { btn.textContent = '💾 Guardar IA'; btn.disabled = false; }
|
||||||
|
if (d.ok) { btn.textContent = '✅ Guardado'; setTimeout(() => { btn.textContent = '💾 Guardar IA'; }, 2000); }
|
||||||
|
else alert('❌ Error al guardar: ' + JSON.stringify(d));
|
||||||
|
})
|
||||||
|
.catch(e => {
|
||||||
|
if (btn) { btn.textContent = '💾 Guardar IA'; btn.disabled = false; }
|
||||||
|
alert('❌ Error de red: ' + e.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function switchTab(tab, btn) {
|
function switchTab(tab, btn) {
|
||||||
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
|
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
|
||||||
document.querySelectorAll('.tab-btn').forEach(t => t.classList.remove('active'));
|
document.querySelectorAll('.tab-btn').forEach(t => t.classList.remove('active'));
|
||||||
@@ -3665,6 +4121,8 @@ function addFlowCard() {
|
|||||||
'<div class="item-card" id="' + cardId + '">' +
|
'<div class="item-card" id="' + cardId + '">' +
|
||||||
' <div class="item-head">' +
|
' <div class="item-head">' +
|
||||||
' <span>ID: <input type="text" name="flow_key[]" value="" style="border:none;background:transparent;font-weight:700;color:#0b3d91;width:180px;font-size:13px" placeholder="nuevo_flujo"></span>' +
|
' <span>ID: <input type="text" name="flow_key[]" value="" style="border:none;background:transparent;font-weight:700;color:#0b3d91;width:180px;font-size:13px" placeholder="nuevo_flujo"></span>' +
|
||||||
|
' <span style="font-size:11px;color:#7a8291;font-weight:400;margin-left:8px">🧠 NLU:</span>' +
|
||||||
|
' <input type="text" name="flow_nlu_desc[]" value="" placeholder="Descripción para IA (ej: Registrar pluviometría)" style="border:none;background:#f0f4ff;border-radius:4px;padding:2px 8px;font-size:11px;color:#374151;flex:1;min-width:0">' +
|
||||||
' <button type="button" class="del" onclick="this.closest(\'.item-card\').remove()">✕</button>' +
|
' <button type="button" class="del" onclick="this.closest(\'.item-card\').remove()">✕</button>' +
|
||||||
' </div>' +
|
' </div>' +
|
||||||
' <div class="form-row" style="align-items:flex-start;gap:10px">' +
|
' <div class="form-row" style="align-items:flex-start;gap:10px">' +
|
||||||
@@ -3787,14 +4245,15 @@ function addCapField(containerId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function aiProviderChange(v) {
|
function aiProviderChange(v) {
|
||||||
const oai = document.getElementById('ai-openai-fields');
|
const panels = {openai: 'ai-openai-fields', gemini: 'ai-gemini-fields', claude: 'ai-claude-fields'};
|
||||||
const gem = document.getElementById('ai-gemini-fields');
|
Object.entries(panels).forEach(([key, id]) => {
|
||||||
if (oai) oai.style.display = (v === 'openai' || v === '') ? 'flex' : 'none';
|
const el = document.getElementById(id);
|
||||||
if (oai) oai.style.flexDirection = 'column';
|
if (!el) return;
|
||||||
if (oai) oai.style.gap = '10px';
|
const show = (key === 'openai' && (v === 'openai' || v === '')) || v === key;
|
||||||
if (gem) gem.style.display = v === 'gemini' ? 'flex' : 'none';
|
el.style.display = show ? 'flex' : 'none';
|
||||||
if (gem) gem.style.flexDirection = 'column';
|
el.style.flexDirection = 'column';
|
||||||
if (gem) gem.style.gap = '10px';
|
el.style.gap = '10px';
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function updatePreview() {
|
function updatePreview() {
|
||||||
@@ -4811,12 +5270,21 @@ function saveConfig() {
|
|||||||
// General
|
// General
|
||||||
fd.append('greeting', fullConfig.greeting || '');
|
fd.append('greeting', fullConfig.greeting || '');
|
||||||
fd.append('fallback', fullConfig.fallback || '');
|
fd.append('fallback', fullConfig.fallback || '');
|
||||||
fd.append('ai_prompt', fullConfig.ai_prompt || '');
|
// AI fields — leer del DOM porque el usuario puede editarlos sin tocar el CONFIG JS
|
||||||
fd.append('ai_model', fullConfig.ai_model || 'gpt-4o-mini');
|
const _qs = sel => document.querySelector(sel);
|
||||||
fd.append('ai_temperature', String(fullConfig.ai_temperature || 0.7));
|
fd.set('ai_provider', _qs('select[name="ai_provider"]')?.value || fullConfig.ai_provider || '');
|
||||||
fd.append('ai_provider', fullConfig.ai_provider || '');
|
fd.set('ai_prompt', _qs('textarea[name="ai_prompt"]')?.value || fullConfig.ai_prompt || '');
|
||||||
fd.append('approval_webhook', fullConfig.approval_webhook || '');
|
fd.set('ai_model', _qs('select[name="ai_model"]')?.value || fullConfig.ai_model || 'gpt-4o-mini');
|
||||||
fd.append('ignore_prefixes', (fullConfig.ignore_prefixes || []).join('\\n'));
|
fd.set('ai_temperature', _qs('input[name="ai_temperature"]')?.value || String(fullConfig.ai_temperature || 0.7));
|
||||||
|
fd.set('openai_api_key', _qs('input[name="openai_api_key"]')?.value || '');
|
||||||
|
fd.set('gemini_api_key', _qs('input[name="gemini_api_key"]')?.value || '');
|
||||||
|
fd.set('gemini_model', _qs('select[name="gemini_model"]')?.value || '');
|
||||||
|
fd.set('claude_api_key', _qs('input[name="claude_api_key"]')?.value || '');
|
||||||
|
fd.set('claude_model', _qs('select[name="claude_model"]')?.value || '');
|
||||||
|
if (_qs('input[name="ai_for_media"]')?.checked) fd.set('ai_for_media', '1');
|
||||||
|
if (_qs('input[name="nlu"]')?.checked) fd.set('nlu', '1');
|
||||||
|
fd.set('approval_webhook', fullConfig.approval_webhook || '');
|
||||||
|
fd.set('ignore_prefixes', (fullConfig.ignore_prefixes || []).join('\\n'));
|
||||||
|
|
||||||
// Per-category menus — read from DOM so they're preserved on every AJAX save
|
// Per-category menus — read from DOM so they're preserved on every AJAX save
|
||||||
[1, 2, 3].forEach(cat => {
|
[1, 2, 3].forEach(cat => {
|
||||||
@@ -5313,4 +5781,117 @@ HTML;
|
|||||||
}
|
}
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function aiLogs(): void
|
||||||
|
{
|
||||||
|
SessionAuth::require();
|
||||||
|
$page = max(1, (int)($_GET['page'] ?? 1));
|
||||||
|
$limit = 50;
|
||||||
|
$offset = ($page - 1) * $limit;
|
||||||
|
|
||||||
|
$provider = $_GET['provider'] ?? '';
|
||||||
|
$company = (int)($_GET['company'] ?? 0);
|
||||||
|
|
||||||
|
$where = [];
|
||||||
|
$params = [];
|
||||||
|
if ($provider !== '') { $where[] = 'provider = ?'; $params[] = $provider; }
|
||||||
|
if ($company > 0) { $where[] = 'company_id = ?'; $params[] = $company; }
|
||||||
|
$wSql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
|
||||||
|
|
||||||
|
$stmt = db()->prepare("SELECT COUNT(*) FROM ai_logs $wSql");
|
||||||
|
$stmt->execute($params);
|
||||||
|
$total = (int)$stmt->fetchColumn();
|
||||||
|
|
||||||
|
$stmt2 = db()->prepare("SELECT * FROM ai_logs $wSql ORDER BY id DESC LIMIT $limit OFFSET $offset");
|
||||||
|
$stmt2->execute($params);
|
||||||
|
$rows = $stmt2->fetchAll(\PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
$companies = db()->query("SELECT id, name FROM companies ORDER BY name")->fetchAll(\PDO::FETCH_ASSOC);
|
||||||
|
$companyMap = [];
|
||||||
|
foreach ($companies as $c) $companyMap[(int)$c['id']] = self::h($c['name']);
|
||||||
|
|
||||||
|
$pages = (int)ceil($total / $limit);
|
||||||
|
|
||||||
|
$companyOptions = '<option value="">Todas las empresas</option>';
|
||||||
|
foreach ($companies as $c) {
|
||||||
|
$sel = ($company === (int)$c['id']) ? ' selected' : '';
|
||||||
|
$companyOptions .= '<option value="' . (int)$c['id'] . '"' . $sel . '>' . self::h($c['name']) . '</option>';
|
||||||
|
}
|
||||||
|
|
||||||
|
$providerOptions = '';
|
||||||
|
foreach (['', 'openai', 'gemini', 'claude', 'whisper-own'] as $p) {
|
||||||
|
$label = $p === '' ? 'Todos' : ucfirst($p);
|
||||||
|
$sel = $provider === $p ? ' selected' : '';
|
||||||
|
$providerOptions .= "<option value=\"{$p}\"{$sel}>{$label}</option>";
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows_html = '';
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$cid = (int)($r['company_id'] ?? 0);
|
||||||
|
$cname = $companyMap[$cid] ?? '<span style="color:#aaa">—</span>';
|
||||||
|
$badge = match($r['provider'] ?? '') {
|
||||||
|
'openai' => 'background:#10a37f;color:#fff',
|
||||||
|
'gemini' => 'background:#4285f4;color:#fff',
|
||||||
|
'claude' => 'background:#d97706;color:#fff',
|
||||||
|
'whisper-own' => 'background:#7c3aed;color:#fff',
|
||||||
|
default => 'background:#6b7280;color:#fff',
|
||||||
|
};
|
||||||
|
$tok = ($r['tokens_in'] ?? null) !== null
|
||||||
|
? self::h($r['tokens_in']) . '+' . self::h($r['tokens_out'])
|
||||||
|
: '—';
|
||||||
|
$rows_html .= '<tr>'
|
||||||
|
. '<td style="color:#9ca3af;font-size:11px">' . self::h(substr($r['created_at'] ?? '', 0, 16)) . '</td>'
|
||||||
|
. '<td>' . $cname . '</td>'
|
||||||
|
. '<td>' . self::h($r['phone_number'] ?? '') . '</td>'
|
||||||
|
. '<td><span style="' . $badge . ';border-radius:4px;padding:2px 7px;font-size:11px">' . self::h($r['provider']) . '</span></td>'
|
||||||
|
. '<td style="font-size:11px">' . self::h($r['model'] ?? '') . '</td>'
|
||||||
|
. '<td><span style="background:#f3f4f6;border-radius:4px;padding:2px 6px;font-size:11px">' . self::h($r['call_type'] ?? '') . '</span></td>'
|
||||||
|
. '<td style="font-size:11px;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="' . self::h($r['input_preview'] ?? '') . '">' . self::h(mb_substr($r['input_preview'] ?? '', 0, 60)) . '</td>'
|
||||||
|
. '<td style="font-size:11px;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="' . self::h($r['output_preview'] ?? '') . '">' . self::h(mb_substr($r['output_preview'] ?? '', 0, 60)) . '</td>'
|
||||||
|
. '<td style="text-align:right;font-size:11px">' . $tok . '</td>'
|
||||||
|
. '<td style="text-align:right;font-size:11px">' . number_format((int)($r['duration_ms'] ?? 0)) . 'ms</td>'
|
||||||
|
. '</tr>';
|
||||||
|
}
|
||||||
|
|
||||||
|
$pager = '';
|
||||||
|
for ($i = 1; $i <= $pages; $i++) {
|
||||||
|
$active = $i === $page ? ' style="font-weight:bold"' : '';
|
||||||
|
$url = '/admin/ai-logs?page=' . $i . ($provider ? '&provider=' . urlencode($provider) : '') . ($company ? '&company=' . $company : '');
|
||||||
|
$pager .= "<a href=\"{$url}\"{$active} class=\"btn-sm\">{$i}</a> ";
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = SessionAuth::user();
|
||||||
|
echo Layout::open('IA Logs', 'ai-logs', $user['name'] ?? 'Admin');
|
||||||
|
?>
|
||||||
|
<style>
|
||||||
|
.filter-bar{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:16px}
|
||||||
|
.filter-bar select{padding:5px 10px;border:1px solid #d1d5db;border-radius:6px;font-size:13px}
|
||||||
|
.log-table{width:100%;border-collapse:collapse;font-size:13px}
|
||||||
|
.log-table th{background:#f9fafb;padding:8px 10px;text-align:left;border-bottom:2px solid #e5e7eb;font-size:11px;text-transform:uppercase;color:#6b7280;white-space:nowrap}
|
||||||
|
.log-table td{padding:7px 10px;border-bottom:1px solid #f3f4f6;vertical-align:middle}
|
||||||
|
.log-table tr:hover td{background:#fafafa}
|
||||||
|
@media(prefers-color-scheme:dark){.log-table th{background:#1f2937;border-color:#374151;color:#9ca3af}.log-table td{border-color:#1f2937}.log-table tr:hover td{background:#111827}}
|
||||||
|
</style>
|
||||||
|
<div class="card" style="padding:20px">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
|
||||||
|
<h2 style="margin:0">🤖 IA Logs <span style="font-size:14px;color:#9ca3af;font-weight:400">(<?= number_format($total) ?> registros)</span></h2>
|
||||||
|
</div>
|
||||||
|
<form class="filter-bar" method="get">
|
||||||
|
<select name="provider" onchange="this.form.submit()"><?= $providerOptions ?></select>
|
||||||
|
<select name="company" onchange="this.form.submit()"><?= $companyOptions ?></select>
|
||||||
|
</form>
|
||||||
|
<div style="overflow-x:auto">
|
||||||
|
<table class="log-table">
|
||||||
|
<thead><tr>
|
||||||
|
<th>Fecha</th><th>Empresa</th><th>Teléfono</th><th>Proveedor</th><th>Modelo</th>
|
||||||
|
<th>Tipo</th><th>Entrada</th><th>Salida</th><th>Tokens</th><th>Duración</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody><?= $rows_html ?: '<tr><td colspan="10" style="text-align:center;color:#9ca3af;padding:30px">Sin registros</td></tr>' ?></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<?php if ($pages > 1): ?><div style="margin-top:12px"><?= $pager ?></div><?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
echo Layout::close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ class Layout
|
|||||||
['bot-config', '/admin/bot-config', 'bot-config', 'Bot'],
|
['bot-config', '/admin/bot-config', 'bot-config', 'Bot'],
|
||||||
['conv-flow', '/admin/conversation-flow', 'conv-flow', 'Flujos'],
|
['conv-flow', '/admin/conversation-flow', 'conv-flow', 'Flujos'],
|
||||||
['settings', '/admin/settings', 'settings', 'Ajustes'],
|
['settings', '/admin/settings', 'settings', 'Ajustes'],
|
||||||
|
['ai-logs', '/admin/ai-logs', 'live', 'IA Logs'],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
+167
-26
@@ -167,46 +167,166 @@ class WpWebhook
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolución principal: busca el número remitente en company_phones
|
// Devuelve todas las empresas a las que pertenece el número
|
||||||
private static function resolveCompanyByNumber(string $from): bool
|
private static function findCompaniesByNumber(string $from): array
|
||||||
{
|
{
|
||||||
if ($from === '') {
|
if ($from === '') return [];
|
||||||
self::$currentCompany = null;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$stmt = db()->prepare("
|
$stmt = db()->prepare("
|
||||||
SELECT cp.company_id, cp.permission_type
|
SELECT cp.company_id, cp.permission_type
|
||||||
FROM company_phones cp
|
FROM company_phones cp
|
||||||
WHERE cp.wa_number = ? AND cp.is_active = 1
|
WHERE cp.wa_number = ? AND cp.is_active = 1
|
||||||
LIMIT 1
|
ORDER BY cp.company_id ASC
|
||||||
");
|
");
|
||||||
$stmt->execute([$from]);
|
$stmt->execute([$from]);
|
||||||
$row = $stmt->fetch(\PDO::FETCH_ASSOC);
|
return $stmt->fetchAll(\PDO::FETCH_ASSOC);
|
||||||
} catch (\PDOException $e) {
|
} catch (\PDOException $e) {
|
||||||
self::log('ERROR', 'resolveCompanyByNumber DB: ' . $e->getMessage());
|
self::log('ERROR', 'findCompaniesByNumber DB: ' . $e->getMessage());
|
||||||
self::$currentCompany = null;
|
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, string $msgType = 'text'): 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'];
|
||||||
|
|
||||||
|
// Mensaje 1: confirmación
|
||||||
|
WhatsAppSender::sendText($from, "✅ Conectado con *{$displayName}*.", $phoneNumberId);
|
||||||
|
|
||||||
|
// Mensaje 2: greeting del bot — reset contexto y disparar con mensaje vacío
|
||||||
|
$botCtx = ConversationContext::getOrCreate((int)$selectedId, $from, 'normal');
|
||||||
|
ConversationContext::reset((int)$botCtx['id']);
|
||||||
|
$synthCtx = [
|
||||||
|
'from' => $from,
|
||||||
|
'name' => $name,
|
||||||
|
'message_id' => 'mc_' . uniqid(),
|
||||||
|
'type' => 'text',
|
||||||
|
'timestamp' => time(),
|
||||||
|
'phone_number_id' => $phoneNumberId,
|
||||||
|
'display_phone' => '',
|
||||||
|
'permission_type' => self::getPermissionType($companies, $selectedId),
|
||||||
|
];
|
||||||
|
BotRouter::route($company, $synthCtx, '', 'text');
|
||||||
|
|
||||||
|
self::log('INFO', "Multi-empresa: {$from} seleccionó empresa #{$selectedId} ({$displayName})");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$row) {
|
// 2. Keyword de reinicio → borrar sesión y mostrar pre-menú
|
||||||
self::$currentCompany = null;
|
// Solo aplica para texto libre; las respuestas interactivas no son keywords
|
||||||
self::log('WARN', "Número {$from} no registrado en ninguna empresa");
|
if ($msgType === 'text' && 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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$company = CompanyRepository::findById((int)$row['company_id']);
|
// 3. Sesión activa → usar empresa guardada
|
||||||
if ($company === null) {
|
try {
|
||||||
self::$currentCompany = null;
|
$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");
|
||||||
return false;
|
$stmt->execute([$from]);
|
||||||
|
$session = $stmt->fetch(\PDO::FETCH_ASSOC);
|
||||||
|
} catch (\PDOException $e) {
|
||||||
|
self::log('ERROR', 'handleMultiCompany read session: ' . $e->getMessage());
|
||||||
|
$session = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Adjuntar tipo de permiso al contexto de empresa
|
if ($session) {
|
||||||
$company['_permission_type'] = (int)$row['permission_type'];
|
$selectedId = (int)$session['selected_id'];
|
||||||
self::$currentCompany = $company;
|
try { db()->prepare("UPDATE multi_company_sessions SET expires_at = DATE_ADD(NOW(), INTERVAL 8 HOUR) WHERE wa_number = ?")->execute([$from]); } catch (\PDOException $e) {}
|
||||||
self::log('INFO', "Número {$from} → empresa: {$company['name']} (permiso tipo {$row['permission_type']})");
|
$company = CompanyRepository::findById($selectedId);
|
||||||
return true;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Solo palabras que signifiquen "cambiar de empresa". Antes incluía menu,
|
||||||
|
* salir, atras, volver, inicio y regresar — el vocabulario de navegación del
|
||||||
|
* bot— así que a un usuario multi-empresa se le comían todos esos comandos
|
||||||
|
* acá arriba y nunca llegaban a NormalBot.
|
||||||
|
*/
|
||||||
|
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, ['cambiar empresa', 'cambiar de empresa', 'empresas', 'reiniciar', 'reset'], 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
|
private static function sendRejectionMessage(string $to, string $phoneNumberId): void
|
||||||
@@ -283,15 +403,34 @@ class WpWebhook
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolver empresa por número remitente
|
// Resolver empresa(s) por número remitente
|
||||||
$allowed = self::resolveCompanyByNumber($from);
|
$matchedCompanies = self::findCompaniesByNumber($from);
|
||||||
|
|
||||||
if (!$allowed) {
|
if (empty($matchedCompanies)) {
|
||||||
self::saveWebhookLog('messages', $from, $name, $type, '[NÚMERO NO HABILITADO]');
|
self::saveWebhookLog('messages', $from, $name, $type, '[NÚMERO NO HABILITADO]');
|
||||||
self::sendRejectionMessage($from, $phoneNumberId);
|
self::sendRejectionMessage($from, $phoneNumberId);
|
||||||
continue;
|
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, $type)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
self::log('INFO', "Número {$from} → empresa (multi-sel): " . (self::$currentCompany['name'] ?? '?'));
|
||||||
|
}
|
||||||
|
|
||||||
$context = [
|
$context = [
|
||||||
'from' => $from,
|
'from' => $from,
|
||||||
'name' => $name,
|
'name' => $name,
|
||||||
@@ -348,6 +487,8 @@ class WpWebhook
|
|||||||
$isNew = self::saveConversation($ctx, $preview, $mediaId);
|
$isNew = self::saveConversation($ctx, $preview, $mediaId);
|
||||||
self::forwardToCompany($ctx, $preview, $mediaId);
|
self::forwardToCompany($ctx, $preview, $mediaId);
|
||||||
if ($isNew && self::$currentCompany !== null) {
|
if ($isNew && self::$currentCompany !== null) {
|
||||||
|
// media_id in context so BotRouter/MediaTranscriber can download the file
|
||||||
|
$ctx['media_id'] = $mediaId;
|
||||||
BotRouter::route(self::$currentCompany, $ctx, $caption, $type);
|
BotRouter::route(self::$currentCompany, $ctx, $caption, $type);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../config/env.php';
|
||||||
|
require_once __DIR__ . '/../config/db.php';
|
||||||
|
require_once __DIR__ . '/../services/CompanyRepository.php';
|
||||||
|
require_once __DIR__ . '/../services/PhoneSync.php';
|
||||||
|
|
||||||
|
$results = PhoneSync::syncAll();
|
||||||
|
|
||||||
|
$ts = date('Y-m-d H:i:s');
|
||||||
|
foreach ($results as $r) {
|
||||||
|
$status = $r['status'];
|
||||||
|
if ($status === 'ok') {
|
||||||
|
echo "[{$ts}] {$r['company']}: {$r['upserted']} upserted, {$r['deactivated']} deactivated\n";
|
||||||
|
} elseif ($status === 'skip') {
|
||||||
|
echo "[{$ts}] {$r['company']}: skip — {$r['reason']}\n";
|
||||||
|
} else {
|
||||||
|
echo "[{$ts}] {$r['company']}: ERROR — {$r['reason']}\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
$BASE = 'https://app.palmas360.com/rosablanca/php/controller/controller_api_externa.php';
|
||||||
|
$KEY = 'd8ed3ac2dbe633cd5e0d75cf50503d7be7084a7dbf0ca65325aa54e831491379'; // test key (IP-unrestricted)
|
||||||
|
$TODAY = date('Y-m-d');
|
||||||
|
$M1 = date('Y-m-01');
|
||||||
|
|
||||||
|
function hit(string $label, string $url, string $key): void
|
||||||
|
{
|
||||||
|
$ch = curl_init($url);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_TIMEOUT => 20,
|
||||||
|
CURLOPT_HTTPHEADER => ["Authorization: Bearer {$key}", 'Accept: application/json'],
|
||||||
|
]);
|
||||||
|
$body = curl_exec($ch);
|
||||||
|
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
$type = curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
|
||||||
|
$err = curl_error($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($err) { echo "❌ [{$label}] cURL: {$err}\n"; return; }
|
||||||
|
|
||||||
|
$isPdf = str_contains($type, 'pdf');
|
||||||
|
$isXls = str_contains($type, 'spreadsheet') || str_contains($type, 'excel');
|
||||||
|
$isJson = str_contains($type, 'json');
|
||||||
|
|
||||||
|
if ($isPdf || $isXls) {
|
||||||
|
$ext = $isPdf ? 'PDF' : 'EXCEL';
|
||||||
|
$size = round(strlen($body) / 1024, 1);
|
||||||
|
echo "✅ [{$label}] HTTP {$code} → {$ext} {$size} KB\n";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$d = json_decode($body, true);
|
||||||
|
$status = $d['status'] ?? '?';
|
||||||
|
if ($code === 200 && in_array($status, ['1', 'sin_datos'])) {
|
||||||
|
$msg = $d['message'] ?? $d['mensaje'] ?? $d['mensaje'] ?? '';
|
||||||
|
echo "✅ [{$label}] HTTP {$code} status={$status} " . mb_substr($msg, 0, 80) . "\n";
|
||||||
|
} else {
|
||||||
|
$msg = $d['mensaje'] ?? $d['message'] ?? mb_substr($body, 0, 120);
|
||||||
|
echo "❌ [{$label}] HTTP {$code} → {$msg}\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 1. Fincas (para obtener un finca_id real) ─────────────────────────────
|
||||||
|
echo "\n── Fincas ──\n";
|
||||||
|
$ch = curl_init("{$BASE}?peticion=fincas");
|
||||||
|
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>10,
|
||||||
|
CURLOPT_HTTPHEADER=>["Authorization: Bearer {$KEY}"]]);
|
||||||
|
$raw = curl_exec($ch); curl_close($ch);
|
||||||
|
$fincas = json_decode($raw, true);
|
||||||
|
$fincaId = 0;
|
||||||
|
if (!empty($fincas['datos'])) {
|
||||||
|
foreach ($fincas['datos'] as $f) {
|
||||||
|
echo " id={$f['id']} {$f['label']}\n";
|
||||||
|
if ($fincaId === 0 && (int)$f['id'] > 0) $fincaId = (int)$f['id']; // skip id=0 (Todas)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
echo " " . mb_substr($raw, 0, 200) . "\n";
|
||||||
|
}
|
||||||
|
echo " → Usando finca_id={$fincaId} para los tests filtrados\n";
|
||||||
|
|
||||||
|
// ── 2. Ausentismos texto ──────────────────────────────────────────────────
|
||||||
|
echo "\n── Ausentismos texto ──\n";
|
||||||
|
hit('hoy', "{$BASE}?peticion=ausentismos_bot_texto&fecha_desde={$TODAY}&fecha_hasta={$TODAY}", $KEY);
|
||||||
|
hit('semana', "{$BASE}?peticion=ausentismos_bot_texto&fecha_desde=" . date('Y-m-d', strtotime('-7 days')) . "&fecha_hasta={$TODAY}", $KEY);
|
||||||
|
|
||||||
|
// ── 3. Ausentismos PDF ────────────────────────────────────────────────────
|
||||||
|
echo "\n── Ausentismos PDF (mes) ──\n";
|
||||||
|
hit('mes_pdf', "{$BASE}?peticion=ausentismos_bot_pdf&fecha_desde={$M1}&fecha_hasta={$TODAY}", $KEY);
|
||||||
|
|
||||||
|
// ── 4. Producción total texto ─────────────────────────────────────────────
|
||||||
|
echo "\n── Producción total texto ──\n";
|
||||||
|
hit('sin_finca', "{$BASE}?peticion=produccion_total_bot&fecha_desde={$M1}&fecha_hasta={$TODAY}", $KEY);
|
||||||
|
if ($fincaId > 0)
|
||||||
|
hit("finca_{$fincaId}", "{$BASE}?peticion=produccion_total_bot&fecha_desde={$M1}&fecha_hasta={$TODAY}&finca_id={$fincaId}", $KEY);
|
||||||
|
|
||||||
|
// ── 5. Producción total PDF ───────────────────────────────────────────────
|
||||||
|
echo "\n── Producción total PDF ──\n";
|
||||||
|
if ($fincaId > 0)
|
||||||
|
hit("finca_{$fincaId}", "{$BASE}?peticion=produccion_total_pdf_bot&fecha_desde={$M1}&fecha_hasta={$TODAY}&finca_id={$fincaId}", $KEY);
|
||||||
|
|
||||||
|
// ── 6. Producción por lotes Excel ─────────────────────────────────────────
|
||||||
|
echo "\n── Producción lotes Excel ──\n";
|
||||||
|
hit('lotes', "{$BASE}?peticion=produccion_kilos_lotes&fecha_desde={$M1}&fecha_hasta={$TODAY}", $KEY);
|
||||||
|
|
||||||
|
// ── 7. Ciclos existentes ──────────────────────────────────────────────────
|
||||||
|
echo "\n── Ciclos (spot check) ──\n";
|
||||||
|
hit('cosecha', "{$BASE}?peticion=ciclos_cosecha_dn&fecha_desde={$M1}&finca_id={$fincaId}", $KEY);
|
||||||
|
hit('polinizacion',"{$BASE}?peticion=ciclos_polinizacion_dn&fecha_desde={$M1}&finca_id={$fincaId}", $KEY);
|
||||||
|
hit('mantenimiento',"{$BASE}?peticion=mantenimiento_dn&fecha_desde={$M1}&grupo=0&finca_id={$fincaId}", $KEY);
|
||||||
|
|
||||||
|
// ── 8. Top texto (lotes con ciclos más largos) ───────────────────────────
|
||||||
|
echo "\n── Top texto (sin finca) ──\n";
|
||||||
|
hit('cosecha_top', "{$BASE}?peticion=cosecha_top_texto_bot", $KEY);
|
||||||
|
hit('polinizacion_top', "{$BASE}?peticion=polinizacion_top_texto_bot", $KEY);
|
||||||
|
hit('censo_top', "{$BASE}?peticion=censo_top_texto_bot", $KEY);
|
||||||
|
hit('plagas_top', "{$BASE}?peticion=plagas_top_texto_bot", $KEY);
|
||||||
|
hit('palm_top', "{$BASE}?peticion=palm_top_texto_bot", $KEY);
|
||||||
|
hit('tratamiento_top', "{$BASE}?peticion=tratamiento_top_texto_bot", $KEY);
|
||||||
|
hit('mant_top', "{$BASE}?peticion=mantenimiento_top_texto_bot&grupo=0", $KEY);
|
||||||
|
|
||||||
|
if ($fincaId > 0) {
|
||||||
|
echo "\n── Top texto (con finca_id={$fincaId}) ──\n";
|
||||||
|
hit('cosecha_top', "{$BASE}?peticion=cosecha_top_texto_bot&finca_id={$fincaId}", $KEY);
|
||||||
|
hit('polinizacion_top', "{$BASE}?peticion=polinizacion_top_texto_bot&finca_id={$fincaId}", $KEY);
|
||||||
|
hit('censo_top', "{$BASE}?peticion=censo_top_texto_bot&finca_id={$fincaId}", $KEY);
|
||||||
|
hit('plagas_top', "{$BASE}?peticion=plagas_top_texto_bot&finca_id={$fincaId}", $KEY);
|
||||||
|
hit('palm_top', "{$BASE}?peticion=palm_top_texto_bot&finca_id={$fincaId}", $KEY);
|
||||||
|
hit('tratamiento_top', "{$BASE}?peticion=tratamiento_top_texto_bot&finca_id={$fincaId}", $KEY);
|
||||||
|
hit('mant_top', "{$BASE}?peticion=mantenimiento_top_texto_bot&grupo=0&finca_id={$fincaId}", $KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 9. Histórico 30 días (PDF) ────────────────────────────────────────────
|
||||||
|
echo "\n── Histórico bot PDF (sin finca) ──\n";
|
||||||
|
hit('cosecha_hist', "{$BASE}?peticion=cosecha_historico_bot&fecha_hasta={$TODAY}&finca_id=0", $KEY);
|
||||||
|
hit('polinizacion_hist', "{$BASE}?peticion=polinizacion_historico_bot&fecha_hasta={$TODAY}&finca_id=0", $KEY);
|
||||||
|
hit('censo_hist', "{$BASE}?peticion=censo_historico_bot&fecha_hasta={$TODAY}&finca_id=0", $KEY);
|
||||||
|
hit('plagas_hist', "{$BASE}?peticion=plagas_historico_bot&fecha_hasta={$TODAY}&finca_id=0", $KEY);
|
||||||
|
hit('palm_hist', "{$BASE}?peticion=palm_historico_bot&fecha_hasta={$TODAY}&finca_id=0", $KEY);
|
||||||
|
hit('tratamiento_hist', "{$BASE}?peticion=tratamiento_historico_bot&fecha_hasta={$TODAY}&finca_id=0", $KEY);
|
||||||
|
|
||||||
|
if ($fincaId > 0) {
|
||||||
|
echo "\n── Histórico bot PDF (con finca_id={$fincaId}) ──\n";
|
||||||
|
hit('cosecha_hist', "{$BASE}?peticion=cosecha_historico_bot&fecha_hasta={$TODAY}&finca_id={$fincaId}", $KEY);
|
||||||
|
hit('polinizacion_hist', "{$BASE}?peticion=polinizacion_historico_bot&fecha_hasta={$TODAY}&finca_id={$fincaId}", $KEY);
|
||||||
|
hit('censo_hist', "{$BASE}?peticion=censo_historico_bot&fecha_hasta={$TODAY}&finca_id={$fincaId}", $KEY);
|
||||||
|
hit('plagas_hist', "{$BASE}?peticion=plagas_historico_bot&fecha_hasta={$TODAY}&finca_id={$fincaId}", $KEY);
|
||||||
|
hit('palm_hist', "{$BASE}?peticion=palm_historico_bot&fecha_hasta={$TODAY}&finca_id={$fincaId}", $KEY);
|
||||||
|
hit('tratamiento_hist', "{$BASE}?peticion=tratamiento_historico_bot&fecha_hasta={$TODAY}&finca_id={$fincaId}", $KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "\n── Fin del test ──\n";
|
||||||
+140
-32
@@ -1,6 +1,8 @@
|
|||||||
<?php
|
<?php
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
date_default_timezone_set('America/Bogota');
|
||||||
|
|
||||||
require_once __DIR__ . '/../config/env.php';
|
require_once __DIR__ . '/../config/env.php';
|
||||||
require_once __DIR__ . '/../config/db.php';
|
require_once __DIR__ . '/../config/db.php';
|
||||||
require_once __DIR__ . '/../services/Settings.php';
|
require_once __DIR__ . '/../services/Settings.php';
|
||||||
@@ -37,7 +39,9 @@ require_once __DIR__ . '/../services/OutboundWorker.php';
|
|||||||
require_once __DIR__ . '/../services/ErpSync.php';
|
require_once __DIR__ . '/../services/ErpSync.php';
|
||||||
require_once __DIR__ . '/../services/ConversationContext.php';
|
require_once __DIR__ . '/../services/ConversationContext.php';
|
||||||
require_once __DIR__ . '/../services/NormalBot.php';
|
require_once __DIR__ . '/../services/NormalBot.php';
|
||||||
|
require_once __DIR__ . '/../services/AiLogger.php';
|
||||||
require_once __DIR__ . '/../services/AiBot.php';
|
require_once __DIR__ . '/../services/AiBot.php';
|
||||||
|
require_once __DIR__ . '/../services/MediaTranscriber.php';
|
||||||
require_once __DIR__ . '/../services/BotRouter.php';
|
require_once __DIR__ . '/../services/BotRouter.php';
|
||||||
require_once __DIR__ . '/../services/PendingApproval.php';
|
require_once __DIR__ . '/../services/PendingApproval.php';
|
||||||
require_once __DIR__ . '/../services/ErpMonitor.php';
|
require_once __DIR__ . '/../services/ErpMonitor.php';
|
||||||
@@ -282,6 +286,7 @@ $routes = [
|
|||||||
['POST', '/admin/company/endpoint/save', fn() => DashboardController::companyEndpointSave()],
|
['POST', '/admin/company/endpoint/save', fn() => DashboardController::companyEndpointSave()],
|
||||||
['POST', '/admin/company/endpoint/test', fn() => DashboardController::companyEndpointTest()],
|
['POST', '/admin/company/endpoint/test', fn() => DashboardController::companyEndpointTest()],
|
||||||
['POST', '/admin/company/endpoint/delete', fn() => DashboardController::companyEndpointDelete()],
|
['POST', '/admin/company/endpoint/delete', fn() => DashboardController::companyEndpointDelete()],
|
||||||
|
['POST', '/admin/company/endpoints/clone', fn() => DashboardController::companyEndpointsClone()],
|
||||||
|
|
||||||
// ─── Admin: listar pendientes de aprobación ─────────────────────────────
|
// ─── Admin: listar pendientes de aprobación ─────────────────────────────
|
||||||
['GET', '/admin/pending-list', fn() => DashboardController::pendingList()],
|
['GET', '/admin/pending-list', fn() => DashboardController::pendingList()],
|
||||||
@@ -393,6 +398,7 @@ $routes = [
|
|||||||
|
|
||||||
// Flows
|
// Flows
|
||||||
$flowKeys = $_POST['flow_key'] ?? [];
|
$flowKeys = $_POST['flow_key'] ?? [];
|
||||||
|
$flowNluDescs = $_POST['flow_nlu_desc'] ?? [];
|
||||||
$flowTypes = $_POST['flow_type'] ?? [];
|
$flowTypes = $_POST['flow_type'] ?? [];
|
||||||
$flowMessages = $_POST['flow_message'] ?? [];
|
$flowMessages = $_POST['flow_message'] ?? [];
|
||||||
$flowFunctions = $_POST['flow_function'] ?? [];
|
$flowFunctions = $_POST['flow_function'] ?? [];
|
||||||
@@ -441,6 +447,9 @@ $routes = [
|
|||||||
|
|
||||||
$f = ['type' => $ftype];
|
$f = ['type' => $ftype];
|
||||||
|
|
||||||
|
$nluDesc = trim($flowNluDescs[$i] ?? '');
|
||||||
|
if ($nluDesc !== '') $f['nlu_description'] = $nluDesc;
|
||||||
|
|
||||||
if ($ftype === 'text') {
|
if ($ftype === 'text') {
|
||||||
$f['message'] = trim($flowMessages[$i] ?? '');
|
$f['message'] = trim($flowMessages[$i] ?? '');
|
||||||
|
|
||||||
@@ -533,12 +542,17 @@ $routes = [
|
|||||||
$aiProvider = trim($_POST['ai_provider'] ?? '');
|
$aiProvider = trim($_POST['ai_provider'] ?? '');
|
||||||
if ($aiProvider !== '') $config['ai_provider'] = $aiProvider; else unset($config['ai_provider']);
|
if ($aiProvider !== '') $config['ai_provider'] = $aiProvider; else unset($config['ai_provider']);
|
||||||
$config['ai_for_media'] = isset($_POST['ai_for_media']);
|
$config['ai_for_media'] = isset($_POST['ai_for_media']);
|
||||||
|
$config['nlu'] = isset($_POST['nlu']);
|
||||||
$openaiKey = trim($_POST['openai_api_key'] ?? '');
|
$openaiKey = trim($_POST['openai_api_key'] ?? '');
|
||||||
if ($openaiKey !== '') $config['openai_api_key'] = $openaiKey;
|
if ($openaiKey !== '') $config['openai_api_key'] = $openaiKey;
|
||||||
$geminiKey = trim($_POST['gemini_api_key'] ?? '');
|
$geminiKey = trim($_POST['gemini_api_key'] ?? '');
|
||||||
if ($geminiKey !== '') $config['gemini_api_key'] = $geminiKey;
|
if ($geminiKey !== '') $config['gemini_api_key'] = $geminiKey;
|
||||||
$geminiModel = trim($_POST['gemini_model'] ?? '');
|
$geminiModel = trim($_POST['gemini_model'] ?? '');
|
||||||
if ($geminiModel !== '') $config['gemini_model'] = $geminiModel;
|
if ($geminiModel !== '') $config['gemini_model'] = $geminiModel;
|
||||||
|
$claudeKey = trim($_POST['claude_api_key'] ?? '');
|
||||||
|
if ($claudeKey !== '') $config['claude_api_key'] = $claudeKey;
|
||||||
|
$claudeModel = trim($_POST['claude_model'] ?? '');
|
||||||
|
if ($claudeModel !== '') $config['claude_model'] = $claudeModel;
|
||||||
|
|
||||||
// per_type is managed exclusively via /admin/bot-config/save-per-type — copy as-is from DB
|
// per_type is managed exclusively via /admin/bot-config/save-per-type — copy as-is from DB
|
||||||
if (isset($existingConfig['per_type'])) {
|
if (isset($existingConfig['per_type'])) {
|
||||||
@@ -562,7 +576,8 @@ $routes = [
|
|||||||
// Preserve any DB keys the form doesn't explicitly manage (welcome_menu, per_type, etc.)
|
// Preserve any DB keys the form doesn't explicitly manage (welcome_menu, per_type, etc.)
|
||||||
$formManagedKeys = ['commands', 'menus', 'flows', 'ai_prompt', 'ai_model', 'ai_temperature',
|
$formManagedKeys = ['commands', 'menus', 'flows', 'ai_prompt', 'ai_model', 'ai_temperature',
|
||||||
'ai_provider', 'openai_api_key', 'gemini_api_key', 'gemini_model',
|
'ai_provider', 'openai_api_key', 'gemini_api_key', 'gemini_model',
|
||||||
'ai_for_media', 'greeting', 'fallback', 'approval_webhook',
|
'claude_api_key', 'claude_model',
|
||||||
|
'ai_for_media', 'nlu', 'greeting', 'fallback', 'approval_webhook',
|
||||||
'ignore_prefixes', 'welcome_menu', 'per_type'];
|
'ignore_prefixes', 'welcome_menu', 'per_type'];
|
||||||
foreach ($existingConfig as $k => $v) {
|
foreach ($existingConfig as $k => $v) {
|
||||||
if (!in_array($k, $formManagedKeys, true) && !isset($config[$k])) {
|
if (!in_array($k, $formManagedKeys, true) && !isset($config[$k])) {
|
||||||
@@ -579,6 +594,60 @@ $routes = [
|
|||||||
exit;
|
exit;
|
||||||
})()],
|
})()],
|
||||||
|
|
||||||
|
// ─── Admin: guardar solo campos IA (endpoint separado) ───────────────────
|
||||||
|
['POST', '/admin/bot-config/save-ai', fn() => (function () {
|
||||||
|
SessionAuth::require();
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
$companyId = (int)($_POST['company_id'] ?? 0);
|
||||||
|
if ($companyId === 0) { echo json_encode(['ok' => false, 'error' => 'missing company_id']); exit; }
|
||||||
|
|
||||||
|
$company = CompanyRepository::findById($companyId);
|
||||||
|
if (!$company) { echo json_encode(['ok' => false, 'error' => 'not found']); exit; }
|
||||||
|
|
||||||
|
$config = json_decode($company['config_json'] ?? '{}', true) ?: [];
|
||||||
|
|
||||||
|
$aiProvider = trim($_POST['ai_provider'] ?? '');
|
||||||
|
if ($aiProvider !== '') $config['ai_provider'] = $aiProvider; else unset($config['ai_provider']);
|
||||||
|
|
||||||
|
$aiModel = trim($_POST['ai_model'] ?? '');
|
||||||
|
if ($aiModel !== '') $config['ai_model'] = $aiModel;
|
||||||
|
|
||||||
|
$aiTemp = trim($_POST['ai_temperature'] ?? '');
|
||||||
|
if ($aiTemp !== '') $config['ai_temperature'] = (float)$aiTemp;
|
||||||
|
|
||||||
|
$aiPrompt = trim($_POST['ai_prompt'] ?? '');
|
||||||
|
$config['ai_prompt'] = $aiPrompt;
|
||||||
|
|
||||||
|
$config['ai_for_media'] = isset($_POST['ai_for_media']);
|
||||||
|
$config['nlu'] = isset($_POST['nlu']);
|
||||||
|
|
||||||
|
$openaiKey = trim($_POST['openai_api_key'] ?? '');
|
||||||
|
if ($openaiKey !== '') $config['openai_api_key'] = $openaiKey;
|
||||||
|
|
||||||
|
$geminiKey = trim($_POST['gemini_api_key'] ?? '');
|
||||||
|
if ($geminiKey !== '') $config['gemini_api_key'] = $geminiKey;
|
||||||
|
|
||||||
|
$geminiModel = trim($_POST['gemini_model'] ?? '');
|
||||||
|
if ($geminiModel !== '') $config['gemini_model'] = $geminiModel;
|
||||||
|
|
||||||
|
$claudeKey = trim($_POST['claude_api_key'] ?? '');
|
||||||
|
if ($claudeKey !== '') $config['claude_api_key'] = $claudeKey;
|
||||||
|
|
||||||
|
$claudeModel = trim($_POST['claude_model'] ?? '');
|
||||||
|
if ($claudeModel !== '') $config['claude_model'] = $claudeModel;
|
||||||
|
|
||||||
|
$whisperUrl = trim($_POST['whisper_url'] ?? '');
|
||||||
|
$config['whisper_url'] = $whisperUrl;
|
||||||
|
$whisperUser = trim($_POST['whisper_user'] ?? '');
|
||||||
|
$config['whisper_user'] = $whisperUser;
|
||||||
|
$whisperPass = trim($_POST['whisper_pass'] ?? '');
|
||||||
|
if ($whisperPass !== '') $config['whisper_pass'] = $whisperPass;
|
||||||
|
|
||||||
|
CompanyRepository::save(['id' => $companyId, 'config_json' => json_encode($config, JSON_UNESCAPED_UNICODE)]);
|
||||||
|
echo json_encode(['ok' => true]);
|
||||||
|
exit;
|
||||||
|
})()],
|
||||||
|
|
||||||
// ─── Admin: guardar per_type por categoría (endpoint separado) ───────────
|
// ─── Admin: guardar per_type por categoría (endpoint separado) ───────────
|
||||||
['POST', '/admin/bot-config/save-per-type', fn() => (function () {
|
['POST', '/admin/bot-config/save-per-type', fn() => (function () {
|
||||||
SessionAuth::require();
|
SessionAuth::require();
|
||||||
@@ -591,22 +660,47 @@ $routes = [
|
|||||||
|
|
||||||
$config = json_decode($company['config_json'] ?? '{}', true) ?: [];
|
$config = json_decode($company['config_json'] ?? '{}', true) ?: [];
|
||||||
|
|
||||||
$rawPt = $config['per_type'] ?? [];
|
$rawPt = $config['per_type'] ?? [];
|
||||||
$perType = (is_array($rawPt) && count(array_filter(array_keys($rawPt), 'is_string')) > 0)
|
$perType = (is_array($rawPt) && count(array_filter(array_keys($rawPt), 'is_string')) > 0)
|
||||||
? $rawPt : [];
|
? $rawPt : [];
|
||||||
|
|
||||||
$perTypeMenus = $_POST['per_type_menu'] ?? [];
|
$ptGreetFlow = $_POST['per_type_greeting_flow'] ?? [];
|
||||||
|
$ptMenus = $_POST['per_type_menu'] ?? [];
|
||||||
|
$ptFallback = $_POST['per_type_fallback'] ?? [];
|
||||||
|
|
||||||
foreach ([1, 2, 3] as $cat) {
|
foreach ([1, 2, 3] as $cat) {
|
||||||
$mk = trim($perTypeMenus[$cat] ?? '');
|
$key = (string)$cat;
|
||||||
$key = (string)$cat;
|
$current = $perType[$key] ?? []; // preserve menus/flows/commands from seed
|
||||||
if ($mk !== '') {
|
|
||||||
$perType[$key] = array_merge($perType[$key] ?? [], ['greeting_menu' => $mk]);
|
$gf = trim($ptGreetFlow[$cat] ?? '');
|
||||||
|
if ($gf !== '') {
|
||||||
|
// Si tiene greeting_flow → greeting='' para que el flow se dispare al primer mensaje
|
||||||
|
$current['greeting'] = '';
|
||||||
|
$current['greeting_flow'] = $gf;
|
||||||
|
unset($current['greeting_menu']);
|
||||||
} else {
|
} else {
|
||||||
unset($perType[$key]['greeting_menu']);
|
// Sin greeting_flow → usa greeting_menu (cat 1)
|
||||||
if (empty($perType[$key])) unset($perType[$key]);
|
$current['greeting'] = null;
|
||||||
|
unset($current['greeting_flow']);
|
||||||
|
$mk = trim($ptMenus[$cat] ?? '');
|
||||||
|
if ($mk !== '') {
|
||||||
|
$current['greeting_menu'] = $mk;
|
||||||
|
} else {
|
||||||
|
unset($current['greeting_menu']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$fb = trim($ptFallback[$cat] ?? '');
|
||||||
|
if ($fb !== '') {
|
||||||
|
$current['fallback_flow'] = $fb;
|
||||||
|
} else {
|
||||||
|
unset($current['fallback_flow']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$perType[$key] = $current;
|
||||||
}
|
}
|
||||||
$config['per_type'] = empty($perType) ? new stdClass() : $perType;
|
|
||||||
|
$config['per_type'] = $perType;
|
||||||
|
|
||||||
CompanyRepository::save(['id' => $companyId, 'config_json' => json_encode($config, JSON_UNESCAPED_UNICODE)]);
|
CompanyRepository::save(['id' => $companyId, 'config_json' => json_encode($config, JSON_UNESCAPED_UNICODE)]);
|
||||||
echo json_encode(['ok' => true]);
|
echo json_encode(['ok' => true]);
|
||||||
@@ -615,13 +709,15 @@ $routes = [
|
|||||||
|
|
||||||
// ─── Admin: configuración general ──────────────────────────────────────
|
// ─── Admin: configuración general ──────────────────────────────────────
|
||||||
['GET', '/admin/settings', fn() => DashboardController::settings()],
|
['GET', '/admin/settings', fn() => DashboardController::settings()],
|
||||||
|
['GET', '/admin/ai-logs', fn() => DashboardController::aiLogs()],
|
||||||
|
|
||||||
['POST', '/admin/settings/save', fn() => (function () {
|
['POST', '/admin/settings/save', fn() => (function () {
|
||||||
SessionAuth::require();
|
SessionAuth::require();
|
||||||
$allowed = [
|
$allowed = [
|
||||||
'whatsapp_access_token', 'whatsapp_app_secret', 'whatsapp_verify_token',
|
'whatsapp_access_token', 'whatsapp_app_secret', 'whatsapp_verify_token',
|
||||||
'whatsapp_business_account_id', 'whatsapp_default_phone_number_id',
|
'whatsapp_business_account_id', 'whatsapp_default_phone_number_id',
|
||||||
'ai_provider', 'openai_api_key', 'openai_model', 'gemini_api_key', 'gemini_model', 'ai_max_tokens', 'ai_default_prompt',
|
'ai_provider', 'openai_api_key', 'openai_model', 'gemini_api_key', 'gemini_model',
|
||||||
|
'claude_api_key', 'claude_model', 'ai_max_tokens', 'ai_default_prompt',
|
||||||
'verify_hmac_signature',
|
'verify_hmac_signature',
|
||||||
];
|
];
|
||||||
// Checkbox: si no está presente, desactivar
|
// Checkbox: si no está presente, desactivar
|
||||||
@@ -651,32 +747,44 @@ $routes = [
|
|||||||
|
|
||||||
['POST', '/admin/settings/test-ai', fn() => (function () {
|
['POST', '/admin/settings/test-ai', fn() => (function () {
|
||||||
SessionAuth::require();
|
SessionAuth::require();
|
||||||
$provider = Settings::get('ai_provider', 'mock');
|
// Accept provider/api_key/model from POST (live test), or fall back to saved settings
|
||||||
|
$provider = trim($_POST['provider'] ?? '') ?: Settings::get('ai_provider', 'mock');
|
||||||
|
$apiKey = trim($_POST['api_key'] ?? '');
|
||||||
|
$model = trim($_POST['model'] ?? '');
|
||||||
|
|
||||||
if ($provider === 'gemini') {
|
if ($provider === 'gemini') {
|
||||||
$apiKey = Settings::get('gemini_api_key', '');
|
if ($apiKey === '') $apiKey = Settings::get('gemini_api_key', '');
|
||||||
$model = Settings::get('gemini_model', 'gemini-2.0-flash');
|
if ($model === '') $model = Settings::get('gemini_model', 'gemini-2.5-flash');
|
||||||
if ($apiKey === '') { jsonResponse(200, ['ok' => false, 'provider' => 'gemini', 'error' => 'API Key no configurada. Guarda primero.']); return; }
|
if ($apiKey === '') { jsonResponse(200, ['ok' => false, 'provider' => 'gemini', 'error' => 'API Key vacía']); return; }
|
||||||
$url = "https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent?key={$apiKey}";
|
$url = "https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent?key={$apiKey}";
|
||||||
$payload = json_encode(['contents' => [['role' => 'user', 'parts' => [['text' => 'Responde solo: OK']]]]]);
|
$body = json_encode(['contents' => [['role' => 'user', 'parts' => [['text' => 'Responde solo: OK']]]]]);
|
||||||
$ch = curl_init($url);
|
$ch = curl_init($url);
|
||||||
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => $payload, CURLOPT_HTTPHEADER => ['Content-Type: application/json'], CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 15]);
|
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body, CURLOPT_HTTPHEADER => ['Content-Type: application/json'], CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 15]);
|
||||||
$resp = curl_exec($ch); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); $err = curl_error($ch); curl_close($ch);
|
$resp = curl_exec($ch); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); $err = curl_error($ch); curl_close($ch);
|
||||||
if ($code !== 200) { $msg = $err ?: (json_decode($resp, true)['error']['message'] ?? "HTTP {$code}"); jsonResponse(200, ['ok' => false, 'provider' => 'gemini', 'error' => $msg]); return; }
|
if ($code !== 200) { jsonResponse(200, ['ok' => false, 'provider' => 'gemini', 'error' => $err ?: (json_decode($resp, true)['error']['message'] ?? "HTTP {$code}")]); return; }
|
||||||
$text = json_decode($resp, true)['candidates'][0]['content']['parts'][0]['text'] ?? '(sin respuesta)';
|
jsonResponse(200, ['ok' => true, 'provider' => 'gemini', 'response' => trim(json_decode($resp, true)['candidates'][0]['content']['parts'][0]['text'] ?? '(sin respuesta)')]);
|
||||||
jsonResponse(200, ['ok' => true, 'provider' => 'gemini', 'response' => trim($text)]);
|
|
||||||
} elseif ($provider === 'openai') {
|
} elseif ($provider === 'openai') {
|
||||||
$apiKey = Settings::get('openai_api_key', '');
|
if ($apiKey === '') $apiKey = Settings::get('openai_api_key', '');
|
||||||
$model = Settings::get('openai_model', 'gpt-4o-mini');
|
if ($model === '') $model = Settings::get('openai_model', 'gpt-4o-mini');
|
||||||
if ($apiKey === '') { jsonResponse(200, ['ok' => false, 'provider' => 'openai', 'error' => 'API Key no configurada. Guarda primero.']); return; }
|
if ($apiKey === '') { jsonResponse(200, ['ok' => false, 'provider' => 'openai', 'error' => 'API Key vacía']); return; }
|
||||||
$payload = json_encode(['model' => $model, 'messages' => [['role' => 'user', 'content' => 'Responde solo: OK']], 'max_tokens' => 5]);
|
$body = json_encode(['model' => $model, 'messages' => [['role' => 'user', 'content' => 'Responde solo: OK']], 'max_tokens' => 5]);
|
||||||
$ch = curl_init('https://api.openai.com/v1/chat/completions');
|
$ch = curl_init('https://api.openai.com/v1/chat/completions');
|
||||||
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => $payload, CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Authorization: Bearer ' . $apiKey], CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 15]);
|
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body, CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Authorization: Bearer ' . $apiKey], CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 15]);
|
||||||
$resp = curl_exec($ch); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); $err = curl_error($ch); curl_close($ch);
|
$resp = curl_exec($ch); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); $err = curl_error($ch); curl_close($ch);
|
||||||
if ($code !== 200) { $msg = $err ?: (json_decode($resp, true)['error']['message'] ?? "HTTP {$code}"); jsonResponse(200, ['ok' => false, 'provider' => 'openai', 'error' => $msg]); return; }
|
if ($code !== 200) { jsonResponse(200, ['ok' => false, 'provider' => 'openai', 'error' => $err ?: (json_decode($resp, true)['error']['message'] ?? "HTTP {$code}")]); return; }
|
||||||
$text = json_decode($resp, true)['choices'][0]['message']['content'] ?? '(sin respuesta)';
|
jsonResponse(200, ['ok' => true, 'provider' => 'openai', 'response' => trim(json_decode($resp, true)['choices'][0]['message']['content'] ?? '(sin respuesta)')]);
|
||||||
jsonResponse(200, ['ok' => true, 'provider' => 'openai', 'response' => trim($text)]);
|
} elseif ($provider === 'claude') {
|
||||||
|
if ($apiKey === '') $apiKey = Settings::get('claude_api_key', '');
|
||||||
|
if ($model === '') $model = Settings::get('claude_model', 'claude-haiku-4-5');
|
||||||
|
if ($apiKey === '') { jsonResponse(200, ['ok' => false, 'provider' => 'claude', 'error' => 'API Key vacía']); return; }
|
||||||
|
$body = json_encode(['model' => $model, 'max_tokens' => 10, 'messages' => [['role' => 'user', 'content' => 'Responde solo: OK']]]);
|
||||||
|
$ch = curl_init('https://api.anthropic.com/v1/messages');
|
||||||
|
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body, CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'x-api-key: ' . $apiKey, 'anthropic-version: 2023-06-01'], CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 15]);
|
||||||
|
$resp = curl_exec($ch); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); $err = curl_error($ch); curl_close($ch);
|
||||||
|
if ($code !== 200) { jsonResponse(200, ['ok' => false, 'provider' => 'claude', 'error' => $err ?: (json_decode($resp, true)['error']['message'] ?? "HTTP {$code}")]); return; }
|
||||||
|
jsonResponse(200, ['ok' => true, 'provider' => 'claude', 'response' => trim(json_decode($resp, true)['content'][0]['text'] ?? '(sin respuesta)')]);
|
||||||
} else {
|
} else {
|
||||||
jsonResponse(200, ['ok' => true, 'provider' => 'mock', 'response' => 'Mock activo — no hay proveedor real configurado.']);
|
jsonResponse(200, ['ok' => true, 'provider' => 'mock', 'response' => 'Mock activo.']);
|
||||||
}
|
}
|
||||||
})()],
|
})()],
|
||||||
|
|
||||||
|
|||||||
+30
-10
@@ -167,6 +167,10 @@ $menuConfig = [
|
|||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Claves de flows y menus definidas en este script (las únicas que se actualizan)
|
||||||
|
$baseFlowKeys = array_keys($menuConfig['flows']);
|
||||||
|
$baseMenuKeys = array_keys($menuConfig['menus']);
|
||||||
|
|
||||||
$companies = CompanyRepository::findAll(true);
|
$companies = CompanyRepository::findAll(true);
|
||||||
$count = 0;
|
$count = 0;
|
||||||
|
|
||||||
@@ -179,27 +183,43 @@ foreach ($companies as $company) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$preserveKeys = ['greeting', 'fallback', 'ai_prompt', 'ai_model', 'ai_temperature', 'ai_provider', 'approval_webhook', 'ignore_prefixes', 'ai_max_tokens'];
|
// Partir del config existente para preservar TODO lo custom (nlu, whisper_*, gemini_*, etc.)
|
||||||
foreach ($preserveKeys as $key) {
|
$merged = $existing;
|
||||||
if (array_key_exists($key, $existing)) {
|
|
||||||
$menuConfig[$key] = $existing[$key];
|
// Actualizar SOLO los flows base definidos aquí; los flows custom se preservan
|
||||||
|
foreach ($menuConfig['flows'] as $key => $flow) {
|
||||||
|
$merged['flows'][$key] = $flow;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actualizar SOLO los menus base; los menus custom se preservan
|
||||||
|
foreach ($menuConfig['menus'] as $key => $menu) {
|
||||||
|
$merged['menus'][$key] = $menu;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actualizar commands, per_type y otros campos estructurales del template
|
||||||
|
foreach (['commands', 'per_type'] as $structKey) {
|
||||||
|
if (isset($menuConfig[$structKey])) {
|
||||||
|
$merged[$structKey] = $menuConfig[$structKey];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isset($menuConfig['greeting'])) {
|
// Aplicar defaults solo si no existen
|
||||||
$menuConfig['greeting'] = '¡Bienvenido! Escribe *menu* para ver las opciones disponibles.';
|
if (!isset($merged['greeting'])) {
|
||||||
|
$merged['greeting'] = '¡Bienvenido! Escribe *menu* para ver las opciones disponibles.';
|
||||||
}
|
}
|
||||||
if (!isset($menuConfig['fallback'])) {
|
if (!isset($merged['fallback'])) {
|
||||||
$menuConfig['fallback'] = 'No entendí. Escribe *menu* para ver las opciones disponibles.';
|
$merged['fallback'] = 'No entendí. Escribe *menu* para ver las opciones disponibles.';
|
||||||
}
|
}
|
||||||
|
|
||||||
CompanyRepository::save([
|
CompanyRepository::save([
|
||||||
'id' => (int)$company['id'],
|
'id' => (int)$company['id'],
|
||||||
'config_json' => json_encode($menuConfig, JSON_UNESCAPED_UNICODE),
|
'config_json' => json_encode($merged, JSON_UNESCAPED_UNICODE),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$name = $company['display_name'] ?: $company['name'];
|
$name = $company['display_name'] ?: $company['name'];
|
||||||
echo "✔ {$name} actualizada\n";
|
$customFlows = count(array_diff(array_keys($merged['flows'] ?? []), $baseFlowKeys));
|
||||||
|
$customMenus = count(array_diff(array_keys($merged['menus'] ?? []), $baseMenuKeys));
|
||||||
|
echo "✔ {$name} actualizada (preservados: {$customFlows} flows custom, {$customMenus} menus custom)\n";
|
||||||
$count++;
|
$count++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../config/env.php';
|
||||||
|
require_once __DIR__ . '/../config/db.php';
|
||||||
|
|
||||||
|
$isDry = ($_GET['run'] ?? '') !== '1';
|
||||||
|
|
||||||
|
header('Content-Type: text/html; charset=utf-8');
|
||||||
|
echo '<pre style="font-family:monospace;font-size:13px;padding:20px">';
|
||||||
|
echo ($isDry ? "🔍 DRY RUN — ningún cambio se aplicará\n" : "🚀 EJECUTANDO — actualizando la DB\n");
|
||||||
|
echo str_repeat('─', 60) . "\n\n";
|
||||||
|
|
||||||
|
$companies = db()->query("SELECT id, name, api_base_url FROM companies WHERE api_base_url IS NOT NULL AND api_base_url != ''")->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
$totalUpdated = 0;
|
||||||
|
|
||||||
|
foreach ($companies as $co) {
|
||||||
|
$base = rtrim($co['api_base_url'], '/');
|
||||||
|
$eps = db()->prepare("SELECT id, endpoint_key, url FROM company_endpoints WHERE company_id = ? AND url IS NOT NULL AND url != ''");
|
||||||
|
$eps->execute([$co['id']]);
|
||||||
|
$rows = $eps->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
$hits = [];
|
||||||
|
foreach ($rows as $ep) {
|
||||||
|
$url = $ep['url'];
|
||||||
|
if (!str_starts_with($url, $base)) continue;
|
||||||
|
$suffix = substr($url, strlen($base));
|
||||||
|
if ($suffix === '') $suffix = '/';
|
||||||
|
$hits[] = ['id' => $ep['id'], 'key' => $ep['endpoint_key'], 'before' => $url, 'after' => $suffix];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($hits)) continue;
|
||||||
|
|
||||||
|
echo "🏢 {$co['name']} (id={$co['id']})\n";
|
||||||
|
echo " Base: {$base}\n";
|
||||||
|
foreach ($hits as $h) {
|
||||||
|
echo " [{$h['key']}]\n";
|
||||||
|
echo " antes: {$h['before']}\n";
|
||||||
|
echo " después: {$h['after']}\n";
|
||||||
|
if (!$isDry) {
|
||||||
|
db()->prepare("UPDATE company_endpoints SET url = ? WHERE id = ?")->execute([$h['after'], $h['id']]);
|
||||||
|
}
|
||||||
|
$totalUpdated++;
|
||||||
|
}
|
||||||
|
echo "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
echo str_repeat('─', 60) . "\n";
|
||||||
|
if ($totalUpdated === 0) {
|
||||||
|
echo "✅ Ningún endpoint tiene la base URL como prefijo. Nada que cambiar.\n";
|
||||||
|
} elseif ($isDry) {
|
||||||
|
echo "📋 {$totalUpdated} endpoint(s) serían modificados.\n";
|
||||||
|
echo " → Para aplicar: abre este script con ?run=1\n";
|
||||||
|
} else {
|
||||||
|
echo "✅ {$totalUpdated} endpoint(s) actualizados.\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
echo '</pre>';
|
||||||
+298
-11
@@ -3,6 +3,11 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
class AiBot
|
class AiBot
|
||||||
{
|
{
|
||||||
|
// Context forwarded to provider methods so AiLogger has phone + call type
|
||||||
|
private static string $logPhone = '';
|
||||||
|
private static string $logType = 'chat';
|
||||||
|
private static int $logCompany = 0;
|
||||||
|
|
||||||
public static function processMedia(array $company, array $context, string $mediaType, string $caption): ?array
|
public static function processMedia(array $company, array $context, string $mediaType, string $caption): ?array
|
||||||
{
|
{
|
||||||
$permType = (int)($context['permission_type'] ?? 1);
|
$permType = (int)($context['permission_type'] ?? 1);
|
||||||
@@ -17,7 +22,7 @@ class AiBot
|
|||||||
default => 'un archivo',
|
default => 'un archivo',
|
||||||
};
|
};
|
||||||
|
|
||||||
$canUpload = $permType === 3;
|
$canUpload = in_array($permType, [1, 3], true); // 1=solo reporta, 3=ambos
|
||||||
$permDesc = $canUpload
|
$permDesc = $canUpload
|
||||||
? 'El usuario tiene permisos para subir información y reportes.'
|
? 'El usuario tiene permisos para subir información y reportes.'
|
||||||
: 'El usuario solo puede recibir información o descargar informes, NO puede subir archivos.';
|
: 'El usuario solo puede recibir información o descargar informes, NO puede subir archivos.';
|
||||||
@@ -36,7 +41,7 @@ class AiBot
|
|||||||
$ctxId = (int)$botCtx['id'];
|
$ctxId = (int)$botCtx['id'];
|
||||||
|
|
||||||
ConversationContext::addAiMessage($ctxId, ['role' => 'user', 'content' => $userMessage]);
|
ConversationContext::addAiMessage($ctxId, ['role' => 'user', 'content' => $userMessage]);
|
||||||
$result = self::callLlm($systemPrompt, $ctxId, $company);
|
$result = self::callLlm($systemPrompt, $ctxId, $company, 'media_response', $context['from']);
|
||||||
if ($result === null) {
|
if ($result === null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -61,7 +66,7 @@ class AiBot
|
|||||||
|
|
||||||
ConversationContext::addAiMessage($ctxId, ['role' => 'user', 'content' => $input]);
|
ConversationContext::addAiMessage($ctxId, ['role' => 'user', 'content' => $input]);
|
||||||
|
|
||||||
$result = self::callLlm($systemPrompt, $ctxId, $company);
|
$result = self::callLlm($systemPrompt, $ctxId, $company, 'chat', $context['from']);
|
||||||
|
|
||||||
if ($result === null) {
|
if ($result === null) {
|
||||||
return null;
|
return null;
|
||||||
@@ -77,14 +82,21 @@ class AiBot
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function callLlm(string $systemPrompt, int $ctxId, array $company): ?array
|
private static function callLlm(string $systemPrompt, int $ctxId, array $company, string $callType = 'chat', string $phone = ''): ?array
|
||||||
{
|
{
|
||||||
$provider = env('AI_PROVIDER', 'openai');
|
self::$logType = $callType;
|
||||||
|
self::$logPhone = $phone;
|
||||||
|
self::$logCompany = (int)($company['id'] ?? 0);
|
||||||
|
|
||||||
|
$cfg = self::getConfig($company);
|
||||||
|
// Per-company provider overrides global; empty = use global
|
||||||
|
$provider = $cfg['ai_provider'] ?? env('AI_PROVIDER', 'openai');
|
||||||
$history = ConversationContext::getAiHistory($ctxId);
|
$history = ConversationContext::getAiHistory($ctxId);
|
||||||
|
|
||||||
return match ($provider) {
|
return match ($provider) {
|
||||||
'openai' => self::callOpenAI($systemPrompt, $history, $company),
|
'openai' => self::callOpenAI($systemPrompt, $history, $company),
|
||||||
'gemini' => self::callGemini($systemPrompt, $history),
|
'gemini' => self::callGemini($systemPrompt, $history, $company),
|
||||||
|
'claude' => self::callClaude($systemPrompt, $history, $company),
|
||||||
'mock' => self::mockResponse($history),
|
'mock' => self::mockResponse($history),
|
||||||
default => self::callOpenAI($systemPrompt, $history, $company),
|
default => self::callOpenAI($systemPrompt, $history, $company),
|
||||||
};
|
};
|
||||||
@@ -92,12 +104,13 @@ class AiBot
|
|||||||
|
|
||||||
private static function callOpenAI(string $systemPrompt, array $history, array $company): ?array
|
private static function callOpenAI(string $systemPrompt, array $history, array $company): ?array
|
||||||
{
|
{
|
||||||
$apiKey = env('OPENAI_API_KEY', '');
|
$cfg = self::getConfig($company);
|
||||||
|
$apiKey = $cfg['openai_api_key'] ?? env('OPENAI_API_KEY', '');
|
||||||
if ($apiKey === '') {
|
if ($apiKey === '') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$model = env('OPENAI_MODEL', 'gpt-4o-mini');
|
$model = $cfg['ai_model'] ?? env('OPENAI_MODEL', 'gpt-4o-mini');
|
||||||
|
|
||||||
$messages = [
|
$messages = [
|
||||||
['role' => 'system', 'content' => $systemPrompt],
|
['role' => 'system', 'content' => $systemPrompt],
|
||||||
@@ -117,6 +130,7 @@ class AiBot
|
|||||||
'temperature' => 0.7,
|
'temperature' => 0.7,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$t0 = (int)(microtime(true) * 1000);
|
||||||
$ch = curl_init('https://api.openai.com/v1/chat/completions');
|
$ch = curl_init('https://api.openai.com/v1/chat/completions');
|
||||||
curl_setopt_array($ch, [
|
curl_setopt_array($ch, [
|
||||||
CURLOPT_POST => true,
|
CURLOPT_POST => true,
|
||||||
@@ -133,6 +147,7 @@ class AiBot
|
|||||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
$error = curl_error($ch);
|
$error = curl_error($ch);
|
||||||
curl_close($ch);
|
curl_close($ch);
|
||||||
|
$ms = (int)(microtime(true) * 1000) - $t0;
|
||||||
|
|
||||||
if ($httpCode !== 200 || $response === false) {
|
if ($httpCode !== 200 || $response === false) {
|
||||||
WpWebhook::log('ERROR', "OpenAI API error: {$error} HTTP:{$httpCode}");
|
WpWebhook::log('ERROR', "OpenAI API error: {$error} HTTP:{$httpCode}");
|
||||||
@@ -142,15 +157,19 @@ class AiBot
|
|||||||
$data = json_decode($response, true);
|
$data = json_decode($response, true);
|
||||||
$text = $data['choices'][0]['message']['content'] ?? null;
|
$text = $data['choices'][0]['message']['content'] ?? null;
|
||||||
|
|
||||||
|
$lastUser = end($history)['content'] ?? '';
|
||||||
|
AiLogger::log(self::$logCompany, 'openai', $model, self::$logType, $lastUser, $text ?? '', $ms, self::$logPhone, $data['usage']['prompt_tokens'] ?? null, $data['usage']['completion_tokens'] ?? null);
|
||||||
|
|
||||||
return $text !== null ? ['content' => $text] : null;
|
return $text !== null ? ['content' => $text] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function callGemini(string $systemPrompt, array $history): ?array
|
private static function callGemini(string $systemPrompt, array $history, array $company = []): ?array
|
||||||
{
|
{
|
||||||
$apiKey = env('GEMINI_API_KEY', '');
|
$cfg = self::getConfig($company);
|
||||||
|
$apiKey = $cfg['gemini_api_key'] ?? env('GEMINI_API_KEY', '');
|
||||||
if ($apiKey === '') return null;
|
if ($apiKey === '') return null;
|
||||||
|
|
||||||
$model = env('GEMINI_MODEL', 'gemini-2.0-flash');
|
$model = $cfg['gemini_model'] ?? env('GEMINI_MODEL', 'gemini-2.5-flash');
|
||||||
$maxTokens = (int)env('AI_MAX_TOKENS', '500');
|
$maxTokens = (int)env('AI_MAX_TOKENS', '500');
|
||||||
|
|
||||||
// Gemini usa "contents" con roles "user"/"model"
|
// Gemini usa "contents" con roles "user"/"model"
|
||||||
@@ -170,6 +189,7 @@ class AiBot
|
|||||||
'generationConfig' => ['maxOutputTokens' => $maxTokens, 'temperature' => 0.7],
|
'generationConfig' => ['maxOutputTokens' => $maxTokens, 'temperature' => 0.7],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$t0 = (int)(microtime(true) * 1000);
|
||||||
$url = "https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent?key={$apiKey}";
|
$url = "https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent?key={$apiKey}";
|
||||||
$ch = curl_init($url);
|
$ch = curl_init($url);
|
||||||
curl_setopt_array($ch, [
|
curl_setopt_array($ch, [
|
||||||
@@ -184,6 +204,7 @@ class AiBot
|
|||||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
$error = curl_error($ch);
|
$error = curl_error($ch);
|
||||||
curl_close($ch);
|
curl_close($ch);
|
||||||
|
$ms = (int)(microtime(true) * 1000) - $t0;
|
||||||
|
|
||||||
if ($httpCode !== 200 || $response === false) {
|
if ($httpCode !== 200 || $response === false) {
|
||||||
WpWebhook::log('ERROR', "Gemini API error: {$error} HTTP:{$httpCode}");
|
WpWebhook::log('ERROR', "Gemini API error: {$error} HTTP:{$httpCode}");
|
||||||
@@ -193,6 +214,69 @@ class AiBot
|
|||||||
$data = json_decode($response, true);
|
$data = json_decode($response, true);
|
||||||
$text = $data['candidates'][0]['content']['parts'][0]['text'] ?? null;
|
$text = $data['candidates'][0]['content']['parts'][0]['text'] ?? null;
|
||||||
|
|
||||||
|
$lastUser = end($history)['content'] ?? '';
|
||||||
|
$tokIn = $data['usageMetadata']['promptTokenCount'] ?? null;
|
||||||
|
$tokOut = $data['usageMetadata']['candidatesTokenCount'] ?? null;
|
||||||
|
AiLogger::log(self::$logCompany, 'gemini', $model, self::$logType, $lastUser, $text ?? '', $ms, self::$logPhone, $tokIn, $tokOut);
|
||||||
|
|
||||||
|
return $text !== null ? ['content' => $text] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function callClaude(string $systemPrompt, array $history, array $company = []): ?array
|
||||||
|
{
|
||||||
|
$cfg = self::getConfig($company);
|
||||||
|
$apiKey = $cfg['claude_api_key'] ?? env('CLAUDE_API_KEY', '');
|
||||||
|
if ($apiKey === '') return null;
|
||||||
|
|
||||||
|
$model = $cfg['claude_model'] ?? env('CLAUDE_MODEL', 'claude-haiku-4-5');
|
||||||
|
$maxTokens = (int)env('AI_MAX_TOKENS', '500');
|
||||||
|
|
||||||
|
$messages = [];
|
||||||
|
foreach ($history as $msg) {
|
||||||
|
$messages[] = ['role' => $msg['role'] ?? 'user', 'content' => $msg['content'] ?? ''];
|
||||||
|
}
|
||||||
|
if (empty($messages)) {
|
||||||
|
$messages[] = ['role' => 'user', 'content' => ''];
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = json_encode([
|
||||||
|
'model' => $model,
|
||||||
|
'max_tokens' => $maxTokens,
|
||||||
|
'system' => $systemPrompt,
|
||||||
|
'messages' => $messages,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$t0 = (int)(microtime(true) * 1000);
|
||||||
|
$ch = curl_init('https://api.anthropic.com/v1/messages');
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_POSTFIELDS => $payload,
|
||||||
|
CURLOPT_HTTPHEADER => [
|
||||||
|
'Content-Type: application/json',
|
||||||
|
'x-api-key: ' . $apiKey,
|
||||||
|
'anthropic-version: 2023-06-01',
|
||||||
|
],
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_TIMEOUT => 30,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = curl_exec($ch);
|
||||||
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
$error = curl_error($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
$ms = (int)(microtime(true) * 1000) - $t0;
|
||||||
|
|
||||||
|
if ($httpCode !== 200 || $response === false) {
|
||||||
|
WpWebhook::log('ERROR', "Claude API error: {$error} HTTP:{$httpCode}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode($response, true);
|
||||||
|
$text = $data['content'][0]['text'] ?? null;
|
||||||
|
|
||||||
|
$lastUser = end($history)['content'] ?? '';
|
||||||
|
AiLogger::log(self::$logCompany, 'claude', $model, self::$logType, $lastUser, $text ?? '', $ms, self::$logPhone, $data['usage']['input_tokens'] ?? null, $data['usage']['output_tokens'] ?? null);
|
||||||
|
|
||||||
return $text !== null ? ['content' => $text] : null;
|
return $text !== null ? ['content' => $text] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,6 +328,209 @@ PROMPT;
|
|||||||
. "Responde de forma breve y apropiada según los permisos del usuario. Sin saludos largos.";
|
. "Responde de forma breve y apropiada según los permisos del usuario. Sin saludos largos.";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── NLU: enrutamiento inteligente ────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dado un mensaje de texto, decide si enrutar a un flow existente o responder en chat libre.
|
||||||
|
* Devuelve: ['action'=>'route','key'=>'flow_key'] | ['action'=>'chat','text'=>'...']
|
||||||
|
*/
|
||||||
|
public static function routeOrChat(array $company, array $context, string $input, array $modulosPermitidos = []): array
|
||||||
|
{
|
||||||
|
$config = self::getConfig($company);
|
||||||
|
$permType = (int)($context['permission_type'] ?? 1);
|
||||||
|
$catalog = self::buildFlowCatalog($config, $permType, $modulosPermitidos);
|
||||||
|
|
||||||
|
if (empty($catalog)) {
|
||||||
|
return ['action' => 'chat', 'text' => ''];
|
||||||
|
}
|
||||||
|
|
||||||
|
$systemPrompt = self::buildNluPrompt(
|
||||||
|
$company['display_name'] ?? $company['name'] ?? 'la empresa',
|
||||||
|
$catalog,
|
||||||
|
$permType
|
||||||
|
);
|
||||||
|
|
||||||
|
$raw = self::callLlmOnce($systemPrompt, $input, $company, 'nlu', $context['from'] ?? '');
|
||||||
|
if ($raw === null) {
|
||||||
|
return ['action' => 'chat', 'text' => ''];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip markdown fences if AI wrapped JSON
|
||||||
|
$clean = trim(preg_replace('/^```(?:json)?\s*/i', '', preg_replace('/\s*```$/m', '', $raw)));
|
||||||
|
$json = json_decode($clean, true);
|
||||||
|
|
||||||
|
if (!is_array($json) || !isset($json['action'])) {
|
||||||
|
// JSON inválido o truncado. Mandar $clean tal cual le escupía el
|
||||||
|
// {"action":"chat",...} al usuario, así que se rescata solo el texto.
|
||||||
|
self::log("NLU: respuesta no parseable → " . mb_substr($clean, 0, 200));
|
||||||
|
return ['action' => 'chat', 'text' => self::rescatarTexto($clean)];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($json['action'] === 'route' && isset($json['key'])) {
|
||||||
|
$allFlows = $config['flows'] ?? [];
|
||||||
|
foreach ($config['per_type'] ?? [] as $pt) {
|
||||||
|
$allFlows = array_merge($allFlows, $pt['flows'] ?? []);
|
||||||
|
}
|
||||||
|
if (isset($allFlows[$json['key']])) {
|
||||||
|
return [
|
||||||
|
'action' => 'route',
|
||||||
|
'key' => $json['key'],
|
||||||
|
'entities' => is_array($json['entities'] ?? null) ? $json['entities'] : [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['action' => 'chat', 'text' => self::rescatarTexto((string)($json['text'] ?? $clean))];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extrae el texto legible de una respuesta que no parseó (típicamente JSON
|
||||||
|
* truncado por límite de tokens). Si no hay nada rescatable devuelve '',
|
||||||
|
* y NormalBot cae al menú de fallback — mejor eso que mostrarle JSON.
|
||||||
|
*/
|
||||||
|
private static function rescatarTexto(string $crudo): string
|
||||||
|
{
|
||||||
|
$crudo = trim($crudo);
|
||||||
|
if ($crudo === '') return '';
|
||||||
|
|
||||||
|
// "text":"lo que sirve → rescatar aunque falte el cierre
|
||||||
|
if (preg_match('/"text"\s*:\s*"(.*?)(?:"\s*[,}]|$)/s', $crudo, $m)) {
|
||||||
|
$texto = stripcslashes($m[1]);
|
||||||
|
return str_contains($texto, '{"action"') ? '' : trim($texto);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cualquier resto con pinta de JSON o de key interna no se muestra
|
||||||
|
if (str_contains($crudo, '{"') || str_contains($crudo, '"action"')) return '';
|
||||||
|
|
||||||
|
return $crudo;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function buildFlowCatalog(array $config, int $permType, array $modulosPermitidos = []): array
|
||||||
|
{
|
||||||
|
$flows = $config['flows'] ?? [];
|
||||||
|
$menus = $config['menus'] ?? [];
|
||||||
|
|
||||||
|
// Build human labels from menu rows/buttons
|
||||||
|
$labels = [];
|
||||||
|
foreach ($menus as $menu) {
|
||||||
|
foreach ($menu['sections'] ?? [] as $section) {
|
||||||
|
foreach ($section['rows'] ?? [] as $row) {
|
||||||
|
if (($row['id'] ?? '') !== '') $labels[$row['id']] = $row['title'] ?? $row['id'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($menu['buttons'] ?? [] as $btn) {
|
||||||
|
if (($btn['id'] ?? '') !== '') $labels[$btn['id']] = $btn['title'] ?? $btn['id'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$catalog = [];
|
||||||
|
foreach ($flows as $key => $flow) {
|
||||||
|
$type = $flow['type'] ?? 'text';
|
||||||
|
$fn = $flow['function'] ?? '';
|
||||||
|
|
||||||
|
// nlu_dir deja que un menú de categoría declare su dirección: por tipo
|
||||||
|
// no es ni subida ni descarga, y sin eso el filtro de permisos no lo
|
||||||
|
// tocaba y se le ofrecían informes a quien solo reporta.
|
||||||
|
$dirDeclarada = $flow['nlu_dir'] ?? '';
|
||||||
|
$isUpload = in_array($type, ['collect_and_post', 'collect_for_each', 'submit_form'], true)
|
||||||
|
|| ($type === 'function' && $fn === 'upload_ciclo')
|
||||||
|
|| $dirDeclarada === 'subida';
|
||||||
|
$isDownload = ($type === 'function' && $fn === 'api_report')
|
||||||
|
|| $dirDeclarada === 'descarga';
|
||||||
|
|
||||||
|
// Permission filter:
|
||||||
|
// type 1 = solo reporta (upload), type 2 = solo recibe (download), type 3 = ambos
|
||||||
|
if ($isUpload && !in_array($permType, [1, 3], true)) continue;
|
||||||
|
if ($isDownload && !in_array($permType, [2, 3], true)) continue;
|
||||||
|
|
||||||
|
// Flujos marcados como internos — no exponer al NLU
|
||||||
|
if (!empty($flow['nlu_skip'])) continue;
|
||||||
|
|
||||||
|
// El perfil del número acota qué módulos puede usar: lo que el menú
|
||||||
|
// no muestra, el NLU tampoco lo ofrece
|
||||||
|
if ($modulosPermitidos) {
|
||||||
|
$mod = NormalBot::moduloDeFlow($key, $flow);
|
||||||
|
if ($mod !== null && !in_array($mod, $modulosPermitidos, true)) continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip internal/navigation flows unless they have an explicit NLU description
|
||||||
|
$hasNluDesc = isset($flow['nlu_description']) && $flow['nlu_description'] !== '';
|
||||||
|
if (in_array($type, ['text', 'image'], true) && !$isUpload && !$isDownload && !$hasNluDesc) continue;
|
||||||
|
|
||||||
|
$label = $flow['nlu_description'] ?? $labels[$key] ?? $key;
|
||||||
|
$dir = $isUpload ? 'subida' : ($isDownload ? 'descarga' : '');
|
||||||
|
|
||||||
|
$catalog[] = ['key' => $key, 'label' => $label, 'dir' => $dir];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $catalog;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function buildNluPrompt(string $company, array $catalog, int $permType): string
|
||||||
|
{
|
||||||
|
$permLabel = match ($permType) {
|
||||||
|
1 => 'puede subir/reportar datos al sistema',
|
||||||
|
2 => 'puede descargar reportes e informes',
|
||||||
|
3 => 'puede subir datos y descargar reportes',
|
||||||
|
default => 'acceso básico',
|
||||||
|
};
|
||||||
|
|
||||||
|
$lines = array_map(fn($item) =>
|
||||||
|
'- ' . $item['key'] . ': "' . $item['label'] . '"' . ($item['dir'] !== '' ? ' [' . $item['dir'] . ']' : ''),
|
||||||
|
$catalog
|
||||||
|
);
|
||||||
|
|
||||||
|
return "Eres el asistente de {$company}. El usuario {$permLabel}.\n\n"
|
||||||
|
. "Opciones disponibles:\n" . implode("\n", $lines) . "\n\n"
|
||||||
|
. "Analiza el mensaje y determina si se refiere claramente a una opción.\n\n"
|
||||||
|
. "Si sí → responde SOLO este JSON (sin markdown):\n"
|
||||||
|
. "{\"action\":\"route\",\"key\":\"<key_exacto>\",\"entities\":{\"campo\":\"valor\",...}}\n\n"
|
||||||
|
. "En 'entities' incluye los datos que el usuario ya mencionó: nombre de finca, grupo, filtro, fecha, valor numérico, etc. Usa el nombre tal como lo dijo el usuario.\n"
|
||||||
|
. "Si no mencionó datos extra, omite entities o usa {}.\n\n"
|
||||||
|
. "IMPORTANTE — no adivines cuál informe quiere.\n"
|
||||||
|
. "Poné siempre el filtro (grupo, finca) en entities: el sistema lo resuelve solo y se saltea la lista.\n"
|
||||||
|
. "Pero elegí el key así:\n"
|
||||||
|
. " · Dijo QUÉ informe (\"todos los lotes\", \"más atrasados\", \"histórico\") → ruteá a ese informe.\n"
|
||||||
|
. " · Solo nombró el tema y el filtro → ruteá al menú del tema, para que él elija el informe.\n"
|
||||||
|
. " · Nombró solo una categoría (\"ausentismo\", \"producción\", \"sanidad\", \"ciclos\") → ruteá al\n"
|
||||||
|
. " menú de esa categoría. NUNCA respondas por chat pidiendo que aclare: el menú ya pregunta.\n"
|
||||||
|
. "Ejemplos:\n"
|
||||||
|
. " \"quiero el informe de mantenimiento de plateo\" → {\"action\":\"route\",\"key\":\"ciclo_mantenimiento\",\"entities\":{\"grupo\":\"plateo\"}}\n"
|
||||||
|
. " \"mantenimiento de plateo, todos los lotes\" → {\"action\":\"route\",\"key\":\"ciclo_mantenimiento_todos\",\"entities\":{\"grupo\":\"plateo\"}}\n"
|
||||||
|
. " \"qué lotes llevan más tiempo sin corona\" → {\"action\":\"route\",\"key\":\"ciclo_mantenimiento_top\",\"entities\":{\"grupo\":\"corona\"}}\n"
|
||||||
|
. " \"mantenimiento\" (sin grupo) → {\"action\":\"route\",\"key\":\"ciclo_mantenimiento\"}\n"
|
||||||
|
. " \"quiero cambiar de finca\" → {\"action\":\"route\",\"key\":\"reset_finca\"}\n\n"
|
||||||
|
. "NUNCA preguntes por un dato que una opción ya pide (grupo, finca, período):\n"
|
||||||
|
. "ruteá a esa opción y el bot lo pregunta con su propia lista.\n"
|
||||||
|
. " \"descargar informe de mantenimiento\" → route a ciclo_mantenimiento, NO preguntar el grupo.\n\n"
|
||||||
|
. "Usá chat solo si el mensaje no corresponde a ninguna opción → responde SOLO:\n"
|
||||||
|
. "{\"action\":\"chat\",\"text\":\"<respuesta corta en español, máx 2 oraciones>\"}\n\n"
|
||||||
|
. "No inventes keys. Usa exactamente los keys de la lista.";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Llamada de 1 turno sin historial — para NLU routing. */
|
||||||
|
private static function callLlmOnce(string $systemPrompt, string $userMessage, array $company, string $callType = 'nlu', string $phone = ''): ?string
|
||||||
|
{
|
||||||
|
self::$logType = $callType;
|
||||||
|
self::$logPhone = $phone;
|
||||||
|
self::$logCompany = (int)($company['id'] ?? 0);
|
||||||
|
|
||||||
|
$cfg = self::getConfig($company);
|
||||||
|
$provider = $cfg['ai_provider'] ?? env('AI_PROVIDER', 'openai');
|
||||||
|
$history = [['role' => 'user', 'content' => $userMessage]];
|
||||||
|
|
||||||
|
$result = match ($provider) {
|
||||||
|
'openai' => self::callOpenAI($systemPrompt, $history, $company),
|
||||||
|
'gemini' => self::callGemini($systemPrompt, $history, $company),
|
||||||
|
'claude' => self::callClaude($systemPrompt, $history, $company),
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
return $result['content'] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Shared helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private static function getConfig(array $company): array
|
private static function getConfig(array $company): array
|
||||||
{
|
{
|
||||||
$json = $company['config_json'] ?? '';
|
$json = $company['config_json'] ?? '';
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
class AiLogger
|
||||||
|
{
|
||||||
|
public static function log(
|
||||||
|
?int $companyId,
|
||||||
|
string $provider,
|
||||||
|
string $model,
|
||||||
|
string $callType,
|
||||||
|
string $inputPreview,
|
||||||
|
string $outputPreview,
|
||||||
|
int $durationMs,
|
||||||
|
string $phone = '',
|
||||||
|
?int $tokensIn = null,
|
||||||
|
?int $tokensOut = null
|
||||||
|
): void {
|
||||||
|
try {
|
||||||
|
db()->prepare("
|
||||||
|
INSERT INTO ai_logs
|
||||||
|
(company_id, phone_number, provider, model, call_type,
|
||||||
|
input_preview, output_preview, tokens_in, tokens_out, duration_ms)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?)
|
||||||
|
")->execute([
|
||||||
|
$companyId ?: null,
|
||||||
|
$phone !== '' ? $phone : null,
|
||||||
|
$provider,
|
||||||
|
$model,
|
||||||
|
$callType,
|
||||||
|
mb_substr($inputPreview, 0, 500),
|
||||||
|
mb_substr($outputPreview, 0, 500),
|
||||||
|
$tokensIn,
|
||||||
|
$tokensOut,
|
||||||
|
$durationMs,
|
||||||
|
]);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// Never break the bot for a log failure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+83
-8
@@ -87,12 +87,12 @@ class BotRouter
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function runNormalBot(array $company, array $context, string $input, string $inputType): ?array
|
private static function runNormalBot(array $company, array $context, string $input, string $inputType, bool $suppressFallback = false): ?array
|
||||||
{
|
{
|
||||||
if ($inputType === 'interactive' || $inputType === 'button') {
|
if ($inputType === 'interactive' || $inputType === 'button') {
|
||||||
return NormalBot::processInteractive($company, $context, $input);
|
return NormalBot::processInteractive($company, $context, $input);
|
||||||
}
|
}
|
||||||
return NormalBot::process($company, $context, $input);
|
return NormalBot::process($company, $context, $input, $suppressFallback);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function runAiBot(array $company, array $context, string $input): ?array
|
private static function runAiBot(array $company, array $context, string $input): ?array
|
||||||
@@ -107,24 +107,99 @@ class BotRouter
|
|||||||
|
|
||||||
private static function runHybridBot(array $company, array $context, string $input, string $inputType): ?array
|
private static function runHybridBot(array $company, array $context, string $input, string $inputType): ?array
|
||||||
{
|
{
|
||||||
$config = self::getConfig($company);
|
$config = self::getConfig($company);
|
||||||
|
$nluEnabled = (bool)($config['nlu'] ?? false);
|
||||||
|
$aiEnabled = (bool)($config['ai_for_media'] ?? true);
|
||||||
|
|
||||||
// Media (image/audio/video/document) → AI handles it if enabled
|
// ── Media (audio/imagen) ──────────────────────────────────────────────
|
||||||
if (self::isMediaType($inputType)) {
|
if (self::isMediaType($inputType)) {
|
||||||
$aiEnabled = (bool)($config['ai_for_media'] ?? true);
|
|
||||||
if (!$aiEnabled) {
|
if (!$aiEnabled) {
|
||||||
return self::categoryMenuFallback($company, $context, $config);
|
return self::categoryMenuFallback($company, $context, $config);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($nluEnabled && in_array($inputType, ['audio', 'image'], true)) {
|
||||||
|
$mediaId = $context['media_id'] ?? $input; // injected by WpWebhook::handleMedia
|
||||||
|
$text = $inputType === 'audio'
|
||||||
|
? MediaTranscriber::transcribeAudio($mediaId, $company)
|
||||||
|
: MediaTranscriber::extractFromImage($mediaId, $company);
|
||||||
|
|
||||||
|
if ($text !== null && trim($text) !== '') {
|
||||||
|
self::log("NLU media [{$inputType}]: texto extraído → \"{$text}\"");
|
||||||
|
$prefix = $inputType === 'audio' ? '🎤 Escuché' : '🖼️ Imagen analizada';
|
||||||
|
WhatsAppSender::sendText(
|
||||||
|
$context['from'],
|
||||||
|
"{$prefix}: _{$text}_",
|
||||||
|
$context['phone_number_id'] ?? ''
|
||||||
|
);
|
||||||
|
|
||||||
|
// Si el usuario está en medio de un flujo activo, el texto va directo
|
||||||
|
// a NormalBot como respuesta al paso actual — no al NLU
|
||||||
|
if (self::hasActiveFlow((int)$company['id'], $context['from'])) {
|
||||||
|
self::log("NLU media: flujo activo detectado → NormalBot recibe \"{$text}\"");
|
||||||
|
return self::runNormalBot($company, $context, $text, 'text', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::runNluOnText($company, $context, $text, $config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return AiBot::processMedia($company, $context, $inputType, $input);
|
return AiBot::processMedia($company, $context, $inputType, $input);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Text / interactive / button → NormalBot only, never AI
|
// ── Texto / interactivo: NormalBot primero ────────────────────────────
|
||||||
$response = self::runNormalBot($company, $context, $input, $inputType);
|
// suppressFallback=true cuando NLU activo: si NormalBot no reconoce, retorna null
|
||||||
|
// y dejamos que NLU decida en vez de mostrar el menú de bienvenida como fallback
|
||||||
|
$response = self::runNormalBot($company, $context, $input, $inputType, $nluEnabled);
|
||||||
if ($response !== null) {
|
if ($response !== null) {
|
||||||
return $response;
|
return $response;
|
||||||
}
|
}
|
||||||
|
|
||||||
// NormalBot returned null → show the category greeting menu as fallback
|
// NormalBot no reconoció → NLU si está activo, si no menú categoría
|
||||||
|
if ($nluEnabled && $inputType === 'text') {
|
||||||
|
return self::runNluOnText($company, $context, $input, $config);
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::categoryMenuFallback($company, $context, $config);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function hasActiveFlow(int $companyId, string $phone): bool
|
||||||
|
{
|
||||||
|
$botCtx = ConversationContext::getOrCreate($companyId, $phone, 'normal');
|
||||||
|
$meta = ConversationContext::getMetadata((int)$botCtx['id']);
|
||||||
|
return !empty($meta['__cap']) || !empty($meta['__foreach']) || !empty($meta['collecting']) || !empty($meta['__api_vars']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function runNluOnText(array $company, array $context, string $input, array $config): ?array
|
||||||
|
{
|
||||||
|
$result = AiBot::routeOrChat($company, $context, $input);
|
||||||
|
|
||||||
|
if ($result['action'] === 'route') {
|
||||||
|
$key = $result['key'];
|
||||||
|
$botCtx = ConversationContext::getOrCreate((int)$company['id'], $context['from'], 'normal');
|
||||||
|
$ctxId = (int)$botCtx['id'];
|
||||||
|
$entities = $result['entities'] ?? [];
|
||||||
|
if (!empty($entities)) {
|
||||||
|
$m = ConversationContext::getMetadata($ctxId);
|
||||||
|
$m['__nlu_entities'] = $entities;
|
||||||
|
ConversationContext::updateMetadata($ctxId, $m);
|
||||||
|
self::log("NLU: entities extraídas → " . json_encode($entities));
|
||||||
|
}
|
||||||
|
ConversationContext::updateNode($ctxId, $key);
|
||||||
|
self::log("NLU: {$context['from']} → flow [{$key}]");
|
||||||
|
return NormalBot::process($company, $context, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
$text = trim($result['text'] ?? '');
|
||||||
|
if ($text !== '') {
|
||||||
|
self::log("NLU: {$context['from']} → chat libre");
|
||||||
|
return [
|
||||||
|
'action' => 'send',
|
||||||
|
'type' => 'text',
|
||||||
|
'to' => $context['from'],
|
||||||
|
'payload' => json_encode(['text' => $text]),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
return self::categoryMenuFallback($company, $context, $config);
|
return self::categoryMenuFallback($company, $context, $config);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+19
-3
@@ -17,9 +17,24 @@ class ErpMonitor
|
|||||||
|
|
||||||
public static function check(array $company): array
|
public static function check(array $company): array
|
||||||
{
|
{
|
||||||
$baseUrl = rtrim($company['api_base_url'] ?? '', '/');
|
// El ERP no expone /health sino ?peticion=ping, y la URL sale de
|
||||||
$healthUrl = $baseUrl !== '' ? $baseUrl . '/health' : '';
|
// company_endpoints como el resto de las llamadas: api_base_url no
|
||||||
|
// sirve para esto. Sin esto el panel mostraba el ERP siempre caído.
|
||||||
$apiKey = $company['api_key'] ?? '';
|
$apiKey = $company['api_key'] ?? '';
|
||||||
|
$healthUrl = '';
|
||||||
|
try {
|
||||||
|
$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'], 'numeros_dn']);
|
||||||
|
$url = (string)($stmt->fetchColumn() ?: '');
|
||||||
|
if ($url !== '') {
|
||||||
|
// Cualquier endpoint sirve de referencia; se reemplaza por ping
|
||||||
|
$healthUrl = preg_replace('/peticion=[^&]*/', 'peticion=ping', $url);
|
||||||
|
}
|
||||||
|
} catch (\PDOException $e) {
|
||||||
|
$healthUrl = '';
|
||||||
|
}
|
||||||
|
|
||||||
if ($healthUrl === '') {
|
if ($healthUrl === '') {
|
||||||
return [
|
return [
|
||||||
@@ -27,7 +42,7 @@ class ErpMonitor
|
|||||||
'company_name' => $company['name'] ?? '',
|
'company_name' => $company['name'] ?? '',
|
||||||
'status' => 'unknown',
|
'status' => 'unknown',
|
||||||
'latency_ms' => null,
|
'latency_ms' => null,
|
||||||
'error' => 'Sin URL configurada',
|
'error' => 'Sin endpoint registrado para esta empresa',
|
||||||
'last_check' => date('Y-m-d H:i:s'),
|
'last_check' => date('Y-m-d H:i:s'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -40,6 +55,7 @@ class ErpMonitor
|
|||||||
CURLOPT_TIMEOUT => 10,
|
CURLOPT_TIMEOUT => 10,
|
||||||
CURLOPT_CONNECTTIMEOUT => 5,
|
CURLOPT_CONNECTTIMEOUT => 5,
|
||||||
CURLOPT_HTTPHEADER => [
|
CURLOPT_HTTPHEADER => [
|
||||||
|
'Authorization: Bearer ' . $apiKey,
|
||||||
'X-API-Key: ' . $apiKey,
|
'X-API-Key: ' . $apiKey,
|
||||||
'User-Agent: bot-palmas360-monitor/1.0',
|
'User-Agent: bot-palmas360-monitor/1.0',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,356 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Descarga media de WhatsApp y la transcribe/analiza con el proveedor de IA activo.
|
||||||
|
* Retorna siempre texto plano para pasar al NluRouter.
|
||||||
|
*/
|
||||||
|
class MediaTranscriber
|
||||||
|
{
|
||||||
|
// ── WhatsApp media download ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static function downloadMedia(string $mediaId): ?array
|
||||||
|
{
|
||||||
|
$token = env('WHATSAPP_ACCESS_TOKEN', '');
|
||||||
|
if ($token === '') return null;
|
||||||
|
|
||||||
|
// 1) Obtener URL del archivo
|
||||||
|
$ch = curl_init("https://graph.facebook.com/v18.0/{$mediaId}");
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_HTTPHEADER => ["Authorization: Bearer {$token}", "User-Agent: bot-palmas360/1.0"],
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_TIMEOUT => 15,
|
||||||
|
]);
|
||||||
|
$resp = curl_exec($ch);
|
||||||
|
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($code !== 200 || !$resp) return null;
|
||||||
|
|
||||||
|
$data = json_decode($resp, true);
|
||||||
|
$url = $data['url'] ?? '';
|
||||||
|
$mime = $data['mime_type'] ?? 'application/octet-stream';
|
||||||
|
if ($url === '') return null;
|
||||||
|
|
||||||
|
// 2) Descargar binario
|
||||||
|
$ch = curl_init($url);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_HTTPHEADER => ["Authorization: Bearer {$token}", "User-Agent: bot-palmas360/1.0"],
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_FOLLOWLOCATION => true,
|
||||||
|
CURLOPT_TIMEOUT => 45,
|
||||||
|
]);
|
||||||
|
$bytes = curl_exec($ch);
|
||||||
|
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($code !== 200 || !$bytes || strlen($bytes) < 100) return null;
|
||||||
|
|
||||||
|
return ['bytes' => $bytes, 'mime' => $mime];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Audio → texto ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public static function transcribeAudio(string $mediaId, array $company): ?string
|
||||||
|
{
|
||||||
|
$cfg = self::getConfig($company);
|
||||||
|
$cfg['__company_id'] = (int)($company['id'] ?? 0);
|
||||||
|
$provider = $cfg['ai_provider'] ?? env('AI_PROVIDER', 'openai');
|
||||||
|
|
||||||
|
$media = self::downloadMedia($mediaId);
|
||||||
|
if ($media === null) return null;
|
||||||
|
|
||||||
|
// Servidor Whisper propio tiene prioridad sobre el proveedor IA
|
||||||
|
if (trim($cfg['whisper_url'] ?? '') !== '') {
|
||||||
|
return self::whisper($media['bytes'], $media['mime'], $cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
return match ($provider) {
|
||||||
|
'openai' => self::whisper($media['bytes'], $media['mime'], $cfg),
|
||||||
|
'gemini' => self::geminiAudio($media['bytes'], $media['mime'], $cfg),
|
||||||
|
default => null, // Claude no soporta audio
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function whisper(string $bytes, string $mime, array $cfg): ?string
|
||||||
|
{
|
||||||
|
$ext = str_contains($mime, 'ogg') ? 'ogg' : (str_contains($mime, 'mp4') ? 'mp4' : 'ogg');
|
||||||
|
$tmpFile = tempnam(sys_get_temp_dir(), 'wa_audio_') . '.' . $ext;
|
||||||
|
file_put_contents($tmpFile, $bytes);
|
||||||
|
|
||||||
|
$whisperUrl = trim($cfg['whisper_url'] ?? '');
|
||||||
|
$whisperUser = trim($cfg['whisper_user'] ?? '');
|
||||||
|
$whisperPass = trim($cfg['whisper_pass'] ?? '');
|
||||||
|
$useOwn = $whisperUrl !== '';
|
||||||
|
|
||||||
|
if ($useOwn) {
|
||||||
|
$t0 = (int)(microtime(true) * 1000);
|
||||||
|
$ch = curl_init($whisperUrl);
|
||||||
|
$opts = [
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_POSTFIELDS => [
|
||||||
|
'audio_file' => new CURLFile($tmpFile, $mime, 'audio.' . $ext),
|
||||||
|
'response_format' => 'json',
|
||||||
|
],
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_TIMEOUT => 120,
|
||||||
|
];
|
||||||
|
if ($whisperUser !== '') {
|
||||||
|
$opts[CURLOPT_USERPWD] = "{$whisperUser}:{$whisperPass}";
|
||||||
|
}
|
||||||
|
curl_setopt_array($ch, $opts);
|
||||||
|
$resp = curl_exec($ch);
|
||||||
|
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
$ms = (int)(microtime(true) * 1000) - $t0;
|
||||||
|
@unlink($tmpFile);
|
||||||
|
|
||||||
|
if ($code !== 200 || !$resp) return null;
|
||||||
|
|
||||||
|
// Respuesta puede ser JSON {"text":"..."} o texto plano
|
||||||
|
$data = json_decode($resp, true);
|
||||||
|
$text = is_array($data) ? trim($data['text'] ?? '') : trim($resp);
|
||||||
|
if ($text === '') return null;
|
||||||
|
AiLogger::log($cfg['__company_id'] ?? null, 'whisper-own', 'whisper', 'transcribe', '[audio]', $text, $ms);
|
||||||
|
return $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: OpenAI cloud Whisper
|
||||||
|
$apiKey = $cfg['openai_api_key'] ?? env('OPENAI_API_KEY', '');
|
||||||
|
if ($apiKey === '') { @unlink($tmpFile); return null; }
|
||||||
|
|
||||||
|
$t0 = (int)(microtime(true) * 1000);
|
||||||
|
$ch = curl_init('https://api.openai.com/v1/audio/transcriptions');
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_POSTFIELDS => [
|
||||||
|
'file' => new CURLFile($tmpFile, $mime, 'audio.' . $ext),
|
||||||
|
'model' => 'whisper-1',
|
||||||
|
'language' => 'es',
|
||||||
|
],
|
||||||
|
CURLOPT_HTTPHEADER => ["Authorization: Bearer {$apiKey}"],
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_TIMEOUT => 60,
|
||||||
|
]);
|
||||||
|
$resp = curl_exec($ch);
|
||||||
|
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
$ms = (int)(microtime(true) * 1000) - $t0;
|
||||||
|
@unlink($tmpFile);
|
||||||
|
|
||||||
|
if ($code !== 200 || !$resp) return null;
|
||||||
|
|
||||||
|
$data = json_decode($resp, true);
|
||||||
|
$text = isset($data['text']) ? trim($data['text']) : null;
|
||||||
|
if ($text !== null) {
|
||||||
|
AiLogger::log($cfg['__company_id'] ?? null, 'openai', 'whisper-1', 'transcribe', '[audio]', $text, $ms);
|
||||||
|
}
|
||||||
|
return $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function geminiAudio(string $bytes, string $mime, array $cfg): ?string
|
||||||
|
{
|
||||||
|
$apiKey = $cfg['gemini_api_key'] ?? env('GEMINI_API_KEY', '');
|
||||||
|
$model = $cfg['gemini_model'] ?? env('GEMINI_MODEL', 'gemini-2.5-flash');
|
||||||
|
if ($apiKey === '') return null;
|
||||||
|
|
||||||
|
// Gemini acepta audio inline como base64
|
||||||
|
$payload = json_encode([
|
||||||
|
'contents' => [[
|
||||||
|
'parts' => [
|
||||||
|
['inline_data' => ['mime_type' => $mime, 'data' => base64_encode($bytes)]],
|
||||||
|
['text' => 'Transcribe este audio de WhatsApp en español. Devuelve SOLO el texto transcrito, sin explicaciones ni puntuación extra.'],
|
||||||
|
],
|
||||||
|
]],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$url = "https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent?key={$apiKey}";
|
||||||
|
$t0 = (int)(microtime(true) * 1000);
|
||||||
|
$ch = curl_init($url);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_POSTFIELDS => $payload,
|
||||||
|
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_TIMEOUT => 60,
|
||||||
|
]);
|
||||||
|
$resp = curl_exec($ch);
|
||||||
|
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
$ms = (int)(microtime(true) * 1000) - $t0;
|
||||||
|
|
||||||
|
if ($code !== 200 || !$resp) return null;
|
||||||
|
|
||||||
|
$data = json_decode($resp, true);
|
||||||
|
$text = $data['candidates'][0]['content']['parts'][0]['text'] ?? null;
|
||||||
|
$text = $text !== null ? trim($text) : null;
|
||||||
|
if ($text !== null) {
|
||||||
|
AiLogger::log($cfg['__company_id'] ?? null, 'gemini', $model, 'transcribe', '[audio]', $text, $ms);
|
||||||
|
}
|
||||||
|
return $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Imagen → intención/texto ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
public static function extractFromImage(string $mediaId, array $company): ?string
|
||||||
|
{
|
||||||
|
$cfg = self::getConfig($company);
|
||||||
|
$cfg['__company_id'] = (int)($company['id'] ?? 0);
|
||||||
|
$provider = $cfg['ai_provider'] ?? env('AI_PROVIDER', 'openai');
|
||||||
|
|
||||||
|
$media = self::downloadMedia($mediaId);
|
||||||
|
if ($media === null) return null;
|
||||||
|
|
||||||
|
$b64 = base64_encode($media['bytes']);
|
||||||
|
$mime = $media['mime'];
|
||||||
|
$prompt = 'Analiza esta imagen de WhatsApp. Si contiene texto, extráelo. '
|
||||||
|
. 'Describe en una oración qué quiere hacer el usuario o qué información contiene. '
|
||||||
|
. 'Responde en español, máximo 2 oraciones.';
|
||||||
|
|
||||||
|
return match ($provider) {
|
||||||
|
'openai' => self::openaiVision($b64, $mime, $prompt, $cfg),
|
||||||
|
'gemini' => self::geminiVision($b64, $mime, $prompt, $cfg),
|
||||||
|
'claude' => self::claudeVision($b64, $mime, $prompt, $cfg),
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function openaiVision(string $b64, string $mime, string $prompt, array $cfg): ?string
|
||||||
|
{
|
||||||
|
$apiKey = $cfg['openai_api_key'] ?? env('OPENAI_API_KEY', '');
|
||||||
|
$model = $cfg['ai_model'] ?? env('OPENAI_MODEL', 'gpt-4o');
|
||||||
|
// Vision requires gpt-4o or gpt-4.1 — downgrade if mini is selected
|
||||||
|
if (str_contains($model, 'mini') || str_contains($model, '3.5')) $model = 'gpt-4o';
|
||||||
|
if ($apiKey === '') return null;
|
||||||
|
|
||||||
|
$payload = json_encode([
|
||||||
|
'model' => $model,
|
||||||
|
'max_tokens' => 200,
|
||||||
|
'messages' => [[
|
||||||
|
'role' => 'user',
|
||||||
|
'content' => [
|
||||||
|
['type' => 'image_url', 'image_url' => ['url' => "data:{$mime};base64,{$b64}"]],
|
||||||
|
['type' => 'text', 'text' => $prompt],
|
||||||
|
],
|
||||||
|
]],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$t0 = (int)(microtime(true) * 1000);
|
||||||
|
$ch = curl_init('https://api.openai.com/v1/chat/completions');
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_POSTFIELDS => $payload,
|
||||||
|
CURLOPT_HTTPHEADER => ['Content-Type: application/json', "Authorization: Bearer {$apiKey}"],
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_TIMEOUT => 30,
|
||||||
|
]);
|
||||||
|
$resp = curl_exec($ch);
|
||||||
|
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
$ms = (int)(microtime(true) * 1000) - $t0;
|
||||||
|
|
||||||
|
if ($code !== 200 || !$resp) return null;
|
||||||
|
|
||||||
|
$data = json_decode($resp, true);
|
||||||
|
$text = $data['choices'][0]['message']['content'] ?? null;
|
||||||
|
$text = $text !== null ? trim($text) : null;
|
||||||
|
if ($text !== null) {
|
||||||
|
AiLogger::log($cfg['__company_id'] ?? null, 'openai', $model, 'vision', '[image]', $text, $ms);
|
||||||
|
}
|
||||||
|
return $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function geminiVision(string $b64, string $mime, string $prompt, array $cfg): ?string
|
||||||
|
{
|
||||||
|
$apiKey = $cfg['gemini_api_key'] ?? env('GEMINI_API_KEY', '');
|
||||||
|
$model = $cfg['gemini_model'] ?? env('GEMINI_MODEL', 'gemini-2.5-flash');
|
||||||
|
if ($apiKey === '') return null;
|
||||||
|
|
||||||
|
$payload = json_encode([
|
||||||
|
'contents' => [[
|
||||||
|
'parts' => [
|
||||||
|
['inline_data' => ['mime_type' => $mime, 'data' => $b64]],
|
||||||
|
['text' => $prompt],
|
||||||
|
],
|
||||||
|
]],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$url = "https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent?key={$apiKey}";
|
||||||
|
$t0 = (int)(microtime(true) * 1000);
|
||||||
|
$ch = curl_init($url);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_POSTFIELDS => $payload,
|
||||||
|
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_TIMEOUT => 30,
|
||||||
|
]);
|
||||||
|
$resp = curl_exec($ch);
|
||||||
|
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
$ms = (int)(microtime(true) * 1000) - $t0;
|
||||||
|
|
||||||
|
if ($code !== 200 || !$resp) return null;
|
||||||
|
|
||||||
|
$data = json_decode($resp, true);
|
||||||
|
$text = $data['candidates'][0]['content']['parts'][0]['text'] ?? null;
|
||||||
|
$text = $text !== null ? trim($text) : null;
|
||||||
|
if ($text !== null) {
|
||||||
|
AiLogger::log($cfg['__company_id'] ?? null, 'gemini', $model, 'vision', '[image]', $text, $ms);
|
||||||
|
}
|
||||||
|
return $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function claudeVision(string $b64, string $mime, string $prompt, array $cfg): ?string
|
||||||
|
{
|
||||||
|
$apiKey = $cfg['claude_api_key'] ?? env('CLAUDE_API_KEY', '');
|
||||||
|
$model = $cfg['claude_model'] ?? env('CLAUDE_MODEL', 'claude-haiku-4-5');
|
||||||
|
if ($apiKey === '') return null;
|
||||||
|
|
||||||
|
$payload = json_encode([
|
||||||
|
'model' => $model,
|
||||||
|
'max_tokens' => 200,
|
||||||
|
'messages' => [[
|
||||||
|
'role' => 'user',
|
||||||
|
'content' => [
|
||||||
|
['type' => 'image', 'source' => ['type' => 'base64', 'media_type' => $mime, 'data' => $b64]],
|
||||||
|
['type' => 'text', 'text' => $prompt],
|
||||||
|
],
|
||||||
|
]],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$t0 = (int)(microtime(true) * 1000);
|
||||||
|
$ch = curl_init('https://api.anthropic.com/v1/messages');
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_POSTFIELDS => $payload,
|
||||||
|
CURLOPT_HTTPHEADER => ['Content-Type: application/json', "x-api-key: {$apiKey}", 'anthropic-version: 2023-06-01'],
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_TIMEOUT => 30,
|
||||||
|
]);
|
||||||
|
$resp = curl_exec($ch);
|
||||||
|
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
$ms = (int)(microtime(true) * 1000) - $t0;
|
||||||
|
|
||||||
|
if ($code !== 200 || !$resp) return null;
|
||||||
|
|
||||||
|
$data = json_decode($resp, true);
|
||||||
|
$text = $data['content'][0]['text'] ?? null;
|
||||||
|
$text = $text !== null ? trim($text) : null;
|
||||||
|
if ($text !== null) {
|
||||||
|
AiLogger::log($cfg['__company_id'] ?? null, 'claude', $model, 'vision', '[image]', $text, $ms);
|
||||||
|
}
|
||||||
|
return $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static function getConfig(array $company): array
|
||||||
|
{
|
||||||
|
$json = $company['config_json'] ?? '';
|
||||||
|
if ($json === '') return [];
|
||||||
|
$cfg = json_decode($json, true);
|
||||||
|
return is_array($cfg) ? $cfg : [];
|
||||||
|
}
|
||||||
|
}
|
||||||
+1587
-110
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,126 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
class PhoneSync
|
||||||
|
{
|
||||||
|
/** Endpoint registrado en company_endpoints que devuelve los números habilitados */
|
||||||
|
private const ENDPOINT_KEY = 'numeros_dn';
|
||||||
|
|
||||||
|
public static function syncAll(): array
|
||||||
|
{
|
||||||
|
$companies = CompanyRepository::findAll();
|
||||||
|
$results = [];
|
||||||
|
|
||||||
|
foreach ($companies as $company) {
|
||||||
|
$results[] = self::syncCompany($company);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $results;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function syncCompany(array $company): array
|
||||||
|
{
|
||||||
|
$companyId = (int)$company['id'];
|
||||||
|
$name = $company['name'] ?? "company {$companyId}";
|
||||||
|
$apiKey = $company['api_key'] ?? '';
|
||||||
|
|
||||||
|
// La URL sale de company_endpoints, igual que el resto de llamadas al ERP.
|
||||||
|
// api_base_url no sirve acá: guarda el Graph API de WhatsApp, no el ERP.
|
||||||
|
$stmt = db()->prepare(
|
||||||
|
"SELECT url FROM company_endpoints WHERE company_id = ? AND endpoint_key = ? AND is_active = 1 LIMIT 1"
|
||||||
|
);
|
||||||
|
$stmt->execute([$companyId, self::ENDPOINT_KEY]);
|
||||||
|
$url = (string)($stmt->fetchColumn() ?: '');
|
||||||
|
|
||||||
|
if ($url === '') {
|
||||||
|
return ['company' => $name, 'status' => 'skip', 'reason' => 'sin endpoint ' . self::ENDPOINT_KEY];
|
||||||
|
}
|
||||||
|
|
||||||
|
$ch = curl_init($url);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_TIMEOUT => 15,
|
||||||
|
CURLOPT_HTTPHEADER => [
|
||||||
|
'Accept: application/json',
|
||||||
|
'Authorization: Bearer ' . $apiKey,
|
||||||
|
'X-API-Key: ' . $apiKey,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$body = curl_exec($ch);
|
||||||
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
$curlErr = curl_error($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($curlErr !== '') {
|
||||||
|
return ['company' => $name, 'status' => 'error', 'reason' => 'cURL: ' . $curlErr];
|
||||||
|
}
|
||||||
|
if ($httpCode !== 200) {
|
||||||
|
return ['company' => $name, 'status' => 'error', 'reason' => "HTTP {$httpCode}"];
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode((string)$body, true);
|
||||||
|
if (!is_array($data) || ($data['status'] ?? '') !== '1') {
|
||||||
|
$msg = $data['mensaje'] ?? $data['message'] ?? 'respuesta inválida';
|
||||||
|
return ['company' => $name, 'status' => 'error', 'reason' => $msg];
|
||||||
|
}
|
||||||
|
|
||||||
|
$numeros = $data['numeros'] ?? [];
|
||||||
|
if (empty($numeros)) {
|
||||||
|
return ['company' => $name, 'status' => 'ok', 'upserted' => 0, 'deactivated' => 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = db();
|
||||||
|
$upsert = $db->prepare("
|
||||||
|
INSERT INTO company_phones (company_id, wa_number, label, permission_type, tercero_id, modulos_json, es_supervisor, is_active)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, 1)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
label = VALUES(label),
|
||||||
|
permission_type = VALUES(permission_type),
|
||||||
|
tercero_id = VALUES(tercero_id),
|
||||||
|
modulos_json = VALUES(modulos_json),
|
||||||
|
es_supervisor = VALUES(es_supervisor),
|
||||||
|
is_active = 1
|
||||||
|
");
|
||||||
|
|
||||||
|
$activeNumbers = [];
|
||||||
|
foreach ($numeros as $n) {
|
||||||
|
$waNumber = trim($n['wa_number'] ?? '');
|
||||||
|
if ($waNumber === '') continue;
|
||||||
|
|
||||||
|
// Un supervisor reporta por otros, así que se le pregunta de quién
|
||||||
|
// es el registro aunque tenga su propio tercero vinculado.
|
||||||
|
$tercero = !empty($n['tercero_id']) ? (int)$n['tercero_id'] : null;
|
||||||
|
$modulos = (array)($n['modulos'] ?? []);
|
||||||
|
|
||||||
|
$upsert->execute([
|
||||||
|
$companyId,
|
||||||
|
$waNumber,
|
||||||
|
trim($n['nombre'] ?? ''),
|
||||||
|
(int)($n['permiso'] ?? 1),
|
||||||
|
$tercero,
|
||||||
|
$modulos ? json_encode($modulos) : null,
|
||||||
|
!empty($n['es_supervisor']) ? 1 : 0,
|
||||||
|
]);
|
||||||
|
$activeNumbers[] = $waNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deactivate numbers removed from PALMAS360
|
||||||
|
$deactivated = 0;
|
||||||
|
if (!empty($activeNumbers)) {
|
||||||
|
$placeholders = implode(',', array_fill(0, count($activeNumbers), '?'));
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
UPDATE company_phones SET is_active = 0
|
||||||
|
WHERE company_id = ? AND is_active = 1 AND wa_number NOT IN ({$placeholders})
|
||||||
|
");
|
||||||
|
$stmt->execute(array_merge([$companyId], $activeNumbers));
|
||||||
|
$deactivated = $stmt->rowCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'company' => $name,
|
||||||
|
'status' => 'ok',
|
||||||
|
'upserted' => count($activeNumbers),
|
||||||
|
'deactivated' => $deactivated,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Audita cada flujo de punta a punta, cruzando el seed del bot contra el
|
||||||
|
* controlador del ERP. Verifica lo que las pruebas de navegación no ven:
|
||||||
|
* que la peticion exista del otro lado, que los {placeholder} se puedan
|
||||||
|
* resolver, que el tipo de campo esté implementado y que el POST mande lo
|
||||||
|
* que el procesador exige.
|
||||||
|
*
|
||||||
|
* Uso: php setup/audit_flujos.php [ruta-a-PALMAS360]
|
||||||
|
*/
|
||||||
|
|
||||||
|
$rutaErp = $argv[1] ?? '/Users/lizandro/Documents/GitHub/PALMAS360';
|
||||||
|
|
||||||
|
// ─── Config del bot ──────────────────────────────────────────────────────────
|
||||||
|
$seed = file_get_contents(__DIR__ . '/seed_palmas.php');
|
||||||
|
$open = strpos($seed, '[', strpos($seed, '$configJson = ['));
|
||||||
|
$d = 0; $end = $open;
|
||||||
|
for ($i = $open, $n = strlen($seed); $i < $n; $i++) {
|
||||||
|
if ($seed[$i] === '[') $d++;
|
||||||
|
elseif ($seed[$i] === ']') { $d--; if (!$d) { $end = $i; break; } }
|
||||||
|
}
|
||||||
|
$config = eval('return ' . substr($seed, $open, $end - $open + 1) . ';');
|
||||||
|
|
||||||
|
// Endpoints registrados: clave => url
|
||||||
|
preg_match_all("/\['key' => '([^']+)',\s*'dir' => '([^']+)',\s*'url' => \\\$BASE \. '([^']*)'/", substr($seed, $end), $m);
|
||||||
|
$endpoints = [];
|
||||||
|
foreach ($m[1] as $i => $k) $endpoints[$k] = ['dir' => $m[2][$i], 'url' => $m[3][$i]];
|
||||||
|
|
||||||
|
// ─── Lo que expone el ERP ────────────────────────────────────────────────────
|
||||||
|
$apiPath = $rutaErp . '/php/controller/controller_api_externa.php';
|
||||||
|
$hayErp = is_file($apiPath);
|
||||||
|
$peticiones = [];
|
||||||
|
$procesador = [];
|
||||||
|
if ($hayErp) {
|
||||||
|
preg_match_all("/case '([a-z0-9_]+)':/i", file_get_contents($apiPath), $mp);
|
||||||
|
$peticiones = array_flip($mp[1]);
|
||||||
|
|
||||||
|
$procPath = $rutaErp . '/php/services/BotEntradaProcesador.php';
|
||||||
|
if (is_file($procPath)) {
|
||||||
|
$proc = file_get_contents($procPath);
|
||||||
|
// requeridos por tipo, para cruzar con lo que manda el bot
|
||||||
|
if (preg_match_all("/\\\$requeridos = \[([^\]]+)\]/", $proc, $mr)) $procesador['labores_up'] = $mr[1][0];
|
||||||
|
if (preg_match_all("/\\\$required = \[([^\]]+)\]/", $proc, $mq)) $procesador['otros'] = $mq[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tipos de campo y de flujo implementados ─────────────────────────────────
|
||||||
|
$bot = file_get_contents(dirname(__DIR__) . '/services/NormalBot.php');
|
||||||
|
$tiposFlujo = [];
|
||||||
|
if (preg_match_all("/'([a-z_]+)'\s*=> self::handle|'([a-z_]+)'\s*=> self::send|'([a-z_]+)'\s*=> self::build/", $bot, $mt)) {
|
||||||
|
$tiposFlujo = array_filter(array_merge($mt[1], $mt[2], $mt[3]));
|
||||||
|
}
|
||||||
|
$tiposFlujo = array_flip(array_merge($tiposFlujo, ['text', 'menu', 'function', 'image']));
|
||||||
|
|
||||||
|
$tiposCampo = ['text', 'lookup', 'select', 'multi_select', 'static_select', 'date_quick'];
|
||||||
|
|
||||||
|
// ─── Auditoría ───────────────────────────────────────────────────────────────
|
||||||
|
$prob = [];
|
||||||
|
$avis = [];
|
||||||
|
$ok = 0;
|
||||||
|
|
||||||
|
function grupoDeMeta(array $config, string $clave): bool {
|
||||||
|
// Claves que salen del metadata acumulado, no de un campo del flujo
|
||||||
|
foreach ($config['flows'] as $f) {
|
||||||
|
if (($f['meta_key'] ?? '') === $clave) return true;
|
||||||
|
}
|
||||||
|
return in_array($clave, ['finca_id', 'grupo_id', 'wa_number'], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (['global' => []] + $config['per_type'] as $cat => $pt) {
|
||||||
|
$flows = array_merge($config['flows'], $pt['flows'] ?? []);
|
||||||
|
$menus = array_merge($config['menus'], $pt['menus'] ?? []);
|
||||||
|
|
||||||
|
foreach ($flows as $key => $f) {
|
||||||
|
$tipo = $f['type'] ?? 'text';
|
||||||
|
$ref = "cat {$cat} · {$key}";
|
||||||
|
|
||||||
|
if (!isset($tiposFlujo[$tipo])) {
|
||||||
|
$prob[] = "{$ref}: tipo de flujo '{$tipo}' no implementado";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Endpoints que el flujo usa, con los placeholders que llevan
|
||||||
|
$usados = [];
|
||||||
|
foreach (['endpoint_key', 'source_endpoint_key', 'full_endpoint_key', 'source_endpoint_key_all'] as $k) {
|
||||||
|
if (!empty($f[$k])) $usados[] = $f[$k];
|
||||||
|
}
|
||||||
|
foreach ($f['fields'] ?? [] as $campo) {
|
||||||
|
foreach (['source_endpoint_key', 'full_endpoint_key', 'lookup_endpoint_key'] as $k) {
|
||||||
|
if (!empty($campo[$k])) $usados[] = $campo[$k];
|
||||||
|
}
|
||||||
|
$tc = $campo['type'] ?? 'text';
|
||||||
|
if (!in_array($tc, $tiposCampo, true)) {
|
||||||
|
$prob[] = "{$ref}: campo '{$campo['key']}' usa tipo '{$tc}', que no existe";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!empty($f['params']['endpoint_key'])) $usados[] = $f['params']['endpoint_key'];
|
||||||
|
if (!empty($f['params']['next_endpoint_key'])) $usados[] = $f['params']['next_endpoint_key'];
|
||||||
|
|
||||||
|
$clavesCampo = array_column($f['fields'] ?? [], 'key');
|
||||||
|
|
||||||
|
foreach (array_unique($usados) as $ek) {
|
||||||
|
// La clave puede depender de lo elegido: lotes_{accion}_dn
|
||||||
|
$variantes = [$ek];
|
||||||
|
if (preg_match('/\{(\w+)\}/', $ek, $mm)) {
|
||||||
|
$campo = $mm[1];
|
||||||
|
$opts = [];
|
||||||
|
foreach ($f['fields'] ?? [] as $c) {
|
||||||
|
if (($c['key'] ?? '') === $campo) $opts = array_column($c['options'] ?? [], 'id');
|
||||||
|
}
|
||||||
|
if (!$opts) {
|
||||||
|
$prob[] = "{$ref}: la clave '{$ek}' depende de '{$campo}', que no es un campo con opciones";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$variantes = array_map(fn($o) => str_replace($mm[0], $o, $ek), $opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($variantes as $v) {
|
||||||
|
if (!isset($endpoints[$v])) {
|
||||||
|
$prob[] = "{$ref}: endpoint '{$v}' sin registrar";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$ok++;
|
||||||
|
$url = $endpoints[$v]['url'];
|
||||||
|
|
||||||
|
// La peticion tiene que existir del otro lado
|
||||||
|
if ($hayErp && preg_match('/peticion=([a-z0-9_{}]+)/i', $url, $mu)) {
|
||||||
|
$pet = $mu[1];
|
||||||
|
$pets = [$pet];
|
||||||
|
if (preg_match('/\{(\w+)\}/', $pet, $mc)) {
|
||||||
|
$opts = [];
|
||||||
|
foreach ($f['fields'] ?? [] as $c) {
|
||||||
|
if (($c['key'] ?? '') === $mc[1]) $opts = array_column($c['options'] ?? [], 'id');
|
||||||
|
}
|
||||||
|
$pets = array_map(fn($o) => str_replace($mc[0], $o, $pet), $opts);
|
||||||
|
}
|
||||||
|
foreach ($pets as $p) {
|
||||||
|
if (!isset($peticiones[$p])) $prob[] = "{$ref}: el ERP no expone '?peticion={$p}'";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cada {placeholder} de la URL tiene que poder resolverse
|
||||||
|
preg_match_all('/\{(\w+)\}/', $url, $mph);
|
||||||
|
foreach (array_unique($mph[1]) as $ph) {
|
||||||
|
if (in_array($ph, $clavesCampo, true)) continue;
|
||||||
|
if (grupoDeMeta($config, $ph)) continue;
|
||||||
|
$prob[] = "{$ref}: '{$ph}' en la URL de '{$v}' no sale de ningún campo ni del contexto";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Un flujo que postea sin campos no manda nada
|
||||||
|
if ($tipo === 'collect_and_post' && empty($f['fields'])) {
|
||||||
|
$prob[] = "{$ref}: collect_and_post sin campos";
|
||||||
|
}
|
||||||
|
// requires necesita su resolver, y el resolver tiene que existir
|
||||||
|
if (!empty($f['requires'])) {
|
||||||
|
$r = $f['resolver'] ?? '';
|
||||||
|
if ($r === '') $prob[] = "{$ref}: declara requires sin resolver";
|
||||||
|
elseif (!isset($flows[$r])) $prob[] = "{$ref}: resolver '{$r}' no existe";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cada opción de menú debe llevar a algún lado
|
||||||
|
foreach ($menus as $mk => $menu) {
|
||||||
|
$ids = array_column($menu['buttons'] ?? [], 'id');
|
||||||
|
foreach ($menu['sections'] ?? [] as $sec) $ids = array_merge($ids, array_column($sec['rows'] ?? [], 'id'));
|
||||||
|
foreach ($ids as $id) {
|
||||||
|
if (!isset($flows[$id]) && !isset($menus[$id])) {
|
||||||
|
$prob[] = "cat {$cat} · menú {$mk}: la opción '{$id}' no lleva a ningún flujo";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// WhatsApp corta las listas largas
|
||||||
|
foreach ($menu['sections'] ?? [] as $sec) {
|
||||||
|
if (count($sec['rows'] ?? []) > 10) {
|
||||||
|
$avis[] = "cat {$cat} · menú {$mk}: " . count($sec['rows']) . ' filas, WhatsApp muestra 10';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (count($menu['buttons'] ?? []) > 3) {
|
||||||
|
$prob[] = "cat {$cat} · menú {$mk}: " . count($menu['buttons']) . ' botones, WhatsApp admite 3';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Endpoints registrados que nadie usa
|
||||||
|
$usadosTodos = [];
|
||||||
|
foreach ($config['flows'] as $f) {
|
||||||
|
foreach (['endpoint_key', 'source_endpoint_key', 'full_endpoint_key', 'source_endpoint_key_all'] as $k) {
|
||||||
|
if (!empty($f[$k])) $usadosTodos[] = $f[$k];
|
||||||
|
}
|
||||||
|
foreach ($f['fields'] ?? [] as $c) {
|
||||||
|
foreach (['source_endpoint_key', 'full_endpoint_key', 'lookup_endpoint_key'] as $k) {
|
||||||
|
if (!empty($c[$k])) $usadosTodos[] = $c[$k];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!empty($f['params']['endpoint_key'])) $usadosTodos[] = $f['params']['endpoint_key'];
|
||||||
|
if (!empty($f['params']['next_endpoint_key'])) $usadosTodos[] = $f['params']['next_endpoint_key'];
|
||||||
|
}
|
||||||
|
$patrones = array_map(fn($u) => '/^' . str_replace('\{accion\}', '\w+', preg_quote($u, '/')) . '$/', $usadosTodos);
|
||||||
|
foreach ($endpoints as $k => $e) {
|
||||||
|
$usado = false;
|
||||||
|
foreach ($patrones as $p) { if (preg_match($p, $k)) { $usado = true; break; } }
|
||||||
|
if (!$usado && $k !== 'numeros_dn') $avis[] = "endpoint '{$k}' registrado pero sin usar";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Resultado ───────────────────────────────────────────────────────────────
|
||||||
|
echo "\nAuditoría de flujos\n";
|
||||||
|
echo str_repeat('─', 60) . "\n";
|
||||||
|
echo "ERP: " . ($hayErp ? $apiPath : 'no encontrado — se omiten esos chequeos') . "\n";
|
||||||
|
echo "Endpoints: " . count($endpoints) . " registrados, {$ok} referencias resueltas\n";
|
||||||
|
echo "Peticiones: " . count($peticiones) . " expuestas por el ERP\n\n";
|
||||||
|
|
||||||
|
foreach (array_unique($avis) as $a) echo " aviso {$a}\n";
|
||||||
|
foreach (array_unique($prob) as $p) echo " PROBLEMA {$p}\n";
|
||||||
|
|
||||||
|
$n = count(array_unique($prob));
|
||||||
|
echo "\n" . ($n ? "{$n} problema(s)\n" : "Sin problemas: todos los flujos cierran de punta a punta\n");
|
||||||
|
exit($n ? 1 : 0);
|
||||||
+47
-1
@@ -200,7 +200,9 @@ $seedSettings = [
|
|||||||
'openai_api_key' => '',
|
'openai_api_key' => '',
|
||||||
'openai_model' => 'gpt-4o-mini',
|
'openai_model' => 'gpt-4o-mini',
|
||||||
'gemini_api_key' => '',
|
'gemini_api_key' => '',
|
||||||
'gemini_model' => 'gemini-2.0-flash',
|
'gemini_model' => 'gemini-2.5-flash',
|
||||||
|
'claude_api_key' => '',
|
||||||
|
'claude_model' => 'claude-haiku-4-5',
|
||||||
'ai_max_tokens' => '500',
|
'ai_max_tokens' => '500',
|
||||||
'ai_default_prompt' => 'Eres un asistente virtual de Palmas360. Responde de forma amable y profesional.',
|
'ai_default_prompt' => 'Eres un asistente virtual de Palmas360. Responde de forma amable y profesional.',
|
||||||
];
|
];
|
||||||
@@ -232,8 +234,11 @@ $db->exec("
|
|||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
company_id INT NOT NULL,
|
company_id INT NOT NULL,
|
||||||
endpoint_key VARCHAR(50) NOT NULL,
|
endpoint_key VARCHAR(50) NOT NULL,
|
||||||
|
name VARCHAR(100) DEFAULT '',
|
||||||
|
params TEXT DEFAULT NULL,
|
||||||
direction ENUM('upload','download') NOT NULL,
|
direction ENUM('upload','download') NOT NULL,
|
||||||
url VARCHAR(500) DEFAULT '',
|
url VARCHAR(500) DEFAULT '',
|
||||||
|
body_fields TEXT DEFAULT NULL,
|
||||||
method VARCHAR(10) DEFAULT 'GET',
|
method VARCHAR(10) DEFAULT 'GET',
|
||||||
last_response TEXT,
|
last_response TEXT,
|
||||||
last_called_at DATETIME,
|
last_called_at DATETIME,
|
||||||
@@ -244,6 +249,47 @@ $db->exec("
|
|||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
");
|
");
|
||||||
|
|
||||||
|
$db->exec("
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
id VARCHAR(128) NOT NULL PRIMARY KEY,
|
||||||
|
data LONGTEXT NOT NULL,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_sessions_updated (updated_at)
|
||||||
|
) 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;
|
||||||
|
");
|
||||||
|
|
||||||
|
// ─── AI usage logs ───────────────────────────────────────────────────────────
|
||||||
|
$db->exec("
|
||||||
|
CREATE TABLE IF NOT EXISTS ai_logs (
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
company_id INT DEFAULT NULL,
|
||||||
|
phone_number VARCHAR(20) DEFAULT NULL,
|
||||||
|
provider VARCHAR(20) NOT NULL,
|
||||||
|
model VARCHAR(60) NOT NULL DEFAULT '',
|
||||||
|
call_type VARCHAR(30) NOT NULL DEFAULT 'chat',
|
||||||
|
input_preview VARCHAR(500) DEFAULT NULL,
|
||||||
|
output_preview VARCHAR(500) DEFAULT NULL,
|
||||||
|
tokens_in INT DEFAULT NULL,
|
||||||
|
tokens_out INT DEFAULT NULL,
|
||||||
|
duration_ms INT DEFAULT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_company (company_id),
|
||||||
|
INDEX idx_provider (provider),
|
||||||
|
INDEX idx_created (created_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
");
|
||||||
|
|
||||||
// ─── Campo erp_active_users en companies ──────────────────────────────────────
|
// ─── Campo erp_active_users en companies ──────────────────────────────────────
|
||||||
try {
|
try {
|
||||||
$db->exec("ALTER TABLE companies ADD COLUMN erp_active_users INT DEFAULT 0 AFTER is_active");
|
$db->exec("ALTER TABLE companies ADD COLUMN erp_active_users INT DEFAULT 0 AFTER is_active");
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,660 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recorre en seco la máquina de navegación: intención pendiente, comando "atrás"
|
||||||
|
* y skip_if_set. Replica la lógica de NormalBot sin BD ni WhatsApp; si alguna
|
||||||
|
* regla cambia acá se rompe la prueba y no el bot en producción.
|
||||||
|
*
|
||||||
|
* Uso: php setup/test_navegacion.php
|
||||||
|
*/
|
||||||
|
|
||||||
|
$seed = file_get_contents(__DIR__ . '/seed_palmas.php');
|
||||||
|
$open = strpos($seed, '[', strpos($seed, '$configJson = ['));
|
||||||
|
$depth = 0; $end = $open;
|
||||||
|
for ($i = $open, $n = strlen($seed); $i < $n; $i++) {
|
||||||
|
if ($seed[$i] === '[') $depth++;
|
||||||
|
elseif ($seed[$i] === ']') { $depth--; if ($depth === 0) { $end = $i; break; } }
|
||||||
|
}
|
||||||
|
$config = eval('return ' . substr($seed, $open, $end - $open + 1) . ';');
|
||||||
|
|
||||||
|
function flowsDe(array $config, string $cat): array {
|
||||||
|
$pt = $config['per_type'][$cat] ?? [];
|
||||||
|
return array_merge($config['flows'] ?? [], $pt['flows'] ?? []);
|
||||||
|
}
|
||||||
|
function comandosDe(array $config, string $cat): array {
|
||||||
|
$pt = $config['per_type'][$cat] ?? [];
|
||||||
|
return array_merge($config['commands'] ?? [], $pt['commands'] ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Espejo de divertToResolver() + advanceAfterSelect() */
|
||||||
|
function ejecutar(array $flows, string $key, array $meta, array $seleccionaria = []): array {
|
||||||
|
$pasos = [];
|
||||||
|
for ($i = 0; $i < 5; $i++) {
|
||||||
|
$pasos[] = $key;
|
||||||
|
$flow = $flows[$key] ?? null;
|
||||||
|
if ($flow === null) break;
|
||||||
|
|
||||||
|
$falta = null;
|
||||||
|
foreach ($flow['requires'] ?? [] as $g => $k) {
|
||||||
|
if (($meta[$g][$k] ?? '') === '') { $falta = [$g, $k]; break; }
|
||||||
|
}
|
||||||
|
if ($falta === null) break;
|
||||||
|
|
||||||
|
if (($meta['__resolve_attempt'] ?? '') === $key) { $pasos[] = '*sin-resolver*'; break; }
|
||||||
|
$meta['__after_select'] = $key;
|
||||||
|
$meta['__resolve_attempt'] = $key;
|
||||||
|
|
||||||
|
$resolver = $flow['resolver'];
|
||||||
|
$pasos[] = $resolver;
|
||||||
|
|
||||||
|
// El resolver resuelve si tenemos con qué (entity o tap); si no, muestra lista
|
||||||
|
[$g, $k] = $falta;
|
||||||
|
if (!isset($seleccionaria[$g])) { $pasos[] = '*muestra-lista*'; break; }
|
||||||
|
$meta[$g][$k] = $seleccionaria[$g];
|
||||||
|
$key = $meta['__after_select'];
|
||||||
|
unset($meta['__after_select']);
|
||||||
|
}
|
||||||
|
return [$pasos, $meta];
|
||||||
|
}
|
||||||
|
|
||||||
|
$fallas = 0;
|
||||||
|
function check(string $nombre, $obtenido, $esperado): void {
|
||||||
|
global $fallas;
|
||||||
|
$ok = $obtenido === $esperado;
|
||||||
|
if (!$ok) $fallas++;
|
||||||
|
printf("%s %s\n", $ok ? ' ok ' : ' FALLA', $nombre);
|
||||||
|
if (!$ok) {
|
||||||
|
echo " esperaba: " . json_encode($esperado, JSON_UNESCAPED_UNICODE) . "\n";
|
||||||
|
echo " obtuvo: " . json_encode($obtenido, JSON_UNESCAPED_UNICODE) . "\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$flows = flowsDe($config, '2');
|
||||||
|
|
||||||
|
echo "\nIntención pendiente\n";
|
||||||
|
|
||||||
|
// "quiero informe de mantenimiento de plateo": el NLU rutea al informe y la
|
||||||
|
// entity resuelve el grupo, así que el submenú nunca se muestra.
|
||||||
|
[$pasos] = ejecutar($flows, 'ciclo_mantenimiento_todos', [], ['grupo_mant' => '7']);
|
||||||
|
check('con grupo por entity llega al informe',
|
||||||
|
$pasos, ['ciclo_mantenimiento_todos', 'ciclo_mantenimiento', 'ciclo_mantenimiento_todos']);
|
||||||
|
|
||||||
|
// Sin poder resolver el grupo, muestra la lista y espera al usuario
|
||||||
|
[$pasos] = ejecutar($flows, 'ciclo_mantenimiento_todos', []);
|
||||||
|
check('sin grupo pide la lista',
|
||||||
|
$pasos, ['ciclo_mantenimiento_todos', 'ciclo_mantenimiento', '*muestra-lista*']);
|
||||||
|
|
||||||
|
// Grupo ya elegido (viene del submenú): va directo, sin volver a preguntar
|
||||||
|
[$pasos] = ejecutar($flows, 'ciclo_mantenimiento_todos', ['grupo_mant' => ['grupo_id' => '7']]);
|
||||||
|
check('con grupo ya elegido no repregunta', $pasos, ['ciclo_mantenimiento_todos']);
|
||||||
|
|
||||||
|
// Un resolver que no deja el dato no puede rebotar para siempre
|
||||||
|
$roto = $flows;
|
||||||
|
$roto['resolver_roto'] = ['type' => 'menu', 'menu' => 'submenu_ciclos'];
|
||||||
|
$roto['informe_roto'] = ['type' => 'function', 'requires' => ['nada' => 'x'], 'resolver' => 'resolver_roto'];
|
||||||
|
[$pasos] = ejecutar($roto, 'informe_roto', [], ['otra_cosa' => '1']);
|
||||||
|
check('resolver mal configurado corta el bucle',
|
||||||
|
$pasos, ['informe_roto', 'resolver_roto', '*muestra-lista*']);
|
||||||
|
|
||||||
|
echo "\nComando atrás\n";
|
||||||
|
|
||||||
|
$atras = function (array $flows, ?string $nodo): string {
|
||||||
|
return ($nodo !== null ? ($flows[$nodo]['back'] ?? null) : null) ?? 'show_main_menu';
|
||||||
|
};
|
||||||
|
check('desde un ciclo de sanidad sube a sanidad', $atras($flows, 'ciclo_plagas'), 'submenu_ciclos_sanidad');
|
||||||
|
check('desde sanidad sube a ciclos', $atras($flows, 'submenu_ciclos_sanidad'), 'submenu_ciclos');
|
||||||
|
check('desde ciclos sube a la raíz', $atras($flows, 'submenu_ciclos'), 'show_main_menu');
|
||||||
|
check('desde un informe de mantenimiento al submenú',
|
||||||
|
$atras($flows, 'ciclo_mantenimiento_todos'), 'submenu_ciclo_mantenimiento');
|
||||||
|
check('sin nodo activo cae a la raíz', $atras($flows, null), 'show_main_menu');
|
||||||
|
|
||||||
|
echo "\nComandos\n";
|
||||||
|
|
||||||
|
foreach (['2', '3'] as $cat) {
|
||||||
|
$cmds = comandosDe($config, $cat);
|
||||||
|
check("cat {$cat}: 'atras' resuelve por mapa", $cmds['atras'] ?? null, '__back');
|
||||||
|
check("cat {$cat}: 'cambiar finca' existe", $cmds['cambiar finca'] ?? null, 'reset_finca');
|
||||||
|
check("cat {$cat}: 'salir' va a la raíz", $cmds['salir'] ?? null, 'show_main_menu');
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "\nQuién atiende el texto libre\n";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Espejo del orden de process(): qué paso agarra un mensaje de texto que no es
|
||||||
|
* comando. Lo que importa es que el NLU llegue a opinar cuando la sesión ya
|
||||||
|
* empezó — si no, "quiero informe de plateo" rebota al menú.
|
||||||
|
*/
|
||||||
|
$atiende = function (?string $nodo, $greeting, ?string $greetingMenu, array $flows): string {
|
||||||
|
$centinelas = ['collecting', '__greeted'];
|
||||||
|
if ($nodo !== null && !in_array($nodo, $centinelas, true) && isset($flows[$nodo])) {
|
||||||
|
if (($flows[$nodo]['type'] ?? '') !== 'menu') return 'nodo-activo';
|
||||||
|
}
|
||||||
|
if ($greetingMenu !== null && $nodo === null) return 'menu-bienvenida';
|
||||||
|
if ($greeting !== null && $nodo === null) return 'flujo-bienvenida';
|
||||||
|
return 'nlu';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cat 2/3 usan greeting = '' para disparar ask_finca en el primer mensaje
|
||||||
|
check('sesión nueva pide la finca',
|
||||||
|
$atiende(null, '', null, $flows), 'flujo-bienvenida');
|
||||||
|
check('tras un informe el NLU atiende',
|
||||||
|
$atiende('__greeted', '', null, $flows), 'nlu');
|
||||||
|
check('parado en un menú el NLU atiende',
|
||||||
|
$atiende('submenu_ciclos', '', null, $flows), 'nlu');
|
||||||
|
check('en medio de una lista no interfiere',
|
||||||
|
$atiende('collecting', '', null, $flows), 'nlu');
|
||||||
|
|
||||||
|
echo "\nEntities por campo\n";
|
||||||
|
|
||||||
|
/** Espejo del matching de handleDynamicList con entity_key */
|
||||||
|
$matchear = function (array $flow, array $entities, array $etiquetas): array {
|
||||||
|
$ek = $flow['entity_key'] ?? '';
|
||||||
|
$propias = [];
|
||||||
|
if ($ek !== '') {
|
||||||
|
foreach ($entities as $k => $v) if (mb_strtolower($k) === mb_strtolower($ek)) $propias[$k] = $v;
|
||||||
|
} else {
|
||||||
|
$propias = $entities;
|
||||||
|
}
|
||||||
|
foreach ($propias as $k => $v) {
|
||||||
|
foreach ($etiquetas as $etiqueta) {
|
||||||
|
$a = mb_strtolower($v); $b = mb_strtolower($etiqueta);
|
||||||
|
if ($a === $b || str_contains($a, $b) || str_contains($b, $a)) {
|
||||||
|
unset($entities[$k]);
|
||||||
|
return ['match' => $etiqueta, 'sobran' => $entities];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ['match' => null, 'sobran' => $entities];
|
||||||
|
};
|
||||||
|
|
||||||
|
$dichas = ['finca' => 'reposo', 'grupo' => 'plateo'];
|
||||||
|
$fincas = ['ROSA BLANCA', 'REPOSO', '🌐 Todas las fincas'];
|
||||||
|
$grupos = ['PLATEO', 'CORONA', 'PODA'];
|
||||||
|
|
||||||
|
// Cada lista toma lo suyo y deja el resto para la siguiente
|
||||||
|
$r1 = $matchear($flows['ask_finca'], $dichas, $fincas);
|
||||||
|
check('ask_finca toma la finca', $r1['match'], 'REPOSO');
|
||||||
|
check('y deja el grupo para después', $r1['sobran'], ['grupo' => 'plateo']);
|
||||||
|
|
||||||
|
$r2 = $matchear($flows['ciclo_mantenimiento'], $r1['sobran'], $grupos);
|
||||||
|
check('ciclo_mantenimiento toma el grupo', $r2['match'], 'PLATEO');
|
||||||
|
check('no queda nada sin consumir', $r2['sobran'], []);
|
||||||
|
|
||||||
|
// El bug que motivó todo: una finca no debe matchear contra grupos
|
||||||
|
$r3 = $matchear($flows['ciclo_mantenimiento'], ['finca' => 'reposo'], $grupos);
|
||||||
|
check('una finca no se consume como grupo', $r3['match'], null);
|
||||||
|
check('y sobrevive para ask_finca', $r3['sobran'], ['finca' => 'reposo']);
|
||||||
|
|
||||||
|
echo "\nFinca visible en el menú\n";
|
||||||
|
|
||||||
|
/** Espejo de aplicarVarsMenu() */
|
||||||
|
$footer = function (array $menu, string $finca): string {
|
||||||
|
$f = $menu['footer'] ?? '';
|
||||||
|
$f = str_replace('{finca}', $finca, $f);
|
||||||
|
if ($finca !== '' && !str_contains($f, $finca)) $f = '📍 ' . $finca;
|
||||||
|
return $f;
|
||||||
|
};
|
||||||
|
|
||||||
|
$menus = $config['menus'];
|
||||||
|
check('lista muestra la finca activa',
|
||||||
|
$footer($menus['show_menu_cat2'], 'ROSA BLANCA'), '📍 ROSA BLANCA');
|
||||||
|
check('"todas" se lee natural',
|
||||||
|
$footer($menus['show_menu_cat2'], '🌐 Todas las fincas'), '📍 🌐 Todas las fincas');
|
||||||
|
check('sin finca queda el footer de siempre',
|
||||||
|
$footer($menus['show_menu_cat2'], ''), 'Palmas360');
|
||||||
|
check('submenú de botones también la lleva',
|
||||||
|
$footer($menus['submenu_ciclo_mantenimiento'], 'REPOSO'), '📍 REPOSO');
|
||||||
|
check('entra en los 60 caracteres del footer',
|
||||||
|
mb_strlen($footer($menus['show_menu_cat2'], 'ROSA BLANCA')) <= 60, true);
|
||||||
|
|
||||||
|
echo "\nBienvenida diaria\n";
|
||||||
|
|
||||||
|
/** Espejo de saludarUnaVezAlDia(): devuelve el texto o '' si no toca saludar */
|
||||||
|
$saludar = function (array $config, string $cat, array &$meta, string $hoy, string $nombre = 'Usite'): string {
|
||||||
|
$w = $config['welcome'] ?? [];
|
||||||
|
if (empty($w['enabled'])) return '';
|
||||||
|
$texto = (string)($w['text'][$cat] ?? '');
|
||||||
|
if (trim($texto) === '') return '';
|
||||||
|
if (($meta['__saludo'] ?? '') === $hoy) return '';
|
||||||
|
$meta['__saludo'] = $hoy;
|
||||||
|
return strtr($texto, [
|
||||||
|
'{saludo}' => $nombre !== '' ? "Hola *{$nombre}*" : 'Hola',
|
||||||
|
'{empresa}' => 'Rosa Blanca',
|
||||||
|
'{finca}' => '',
|
||||||
|
'{empresas}' => '',
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
$meta = [];
|
||||||
|
$primero = $saludar($config, '3', $meta, '2026-08-01');
|
||||||
|
check('saluda en el primer mensaje del día', $primero !== '', true);
|
||||||
|
check('personaliza con el nombre', str_contains($primero, 'Hola *Usite*'), true);
|
||||||
|
check('nombra la empresa', str_contains($primero, 'Rosa Blanca'), true);
|
||||||
|
check('informa los comandos', str_contains($primero, '*atrás*') && str_contains($primero, '*salir*'), true);
|
||||||
|
check('no deja tokens sin resolver', preg_match('/\{[a-z]+\}/', $primero), 0);
|
||||||
|
|
||||||
|
check('no vuelve a saludar el mismo día', $saludar($config, '3', $meta, '2026-08-01'), '');
|
||||||
|
|
||||||
|
// preservingReset conserva __saludo, así que un informe no dispara otro saludo
|
||||||
|
$trasInforme = array_intersect_key($meta, array_flip(['finca', 'grupo_mant', '__saludo']));
|
||||||
|
check('sobrevive a un informe', $saludar($config, '3', $trasInforme, '2026-08-01'), '');
|
||||||
|
|
||||||
|
check('al día siguiente saluda de nuevo', $saludar($config, '3', $meta, '2026-08-02') !== '', true);
|
||||||
|
|
||||||
|
// Cat 1 no descarga informes: no debe ofrecerle cosas que no puede usar
|
||||||
|
$m1 = [];
|
||||||
|
$cat1 = $saludar($config, '1', $m1, '2026-08-01');
|
||||||
|
check('cat 1 no ofrece descargas', str_contains($cat1, 'faltó hoy'), false);
|
||||||
|
// Cat 1 solo reporta: el saludo debe nombrar los tres flujos de carga
|
||||||
|
foreach (['ausentismo', 'ciclos', 'pluviometría'] as $f) {
|
||||||
|
check("cat 1 ofrece {$f}", str_contains(mb_strtolower($cat1), $f), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sin nombre no queda "Hola **,"
|
||||||
|
$m2 = [];
|
||||||
|
check('sin nombre no rompe el saludo',
|
||||||
|
str_starts_with($saludar($config, '2', $m2, '2026-08-01', ''), 'Hola,'), true);
|
||||||
|
|
||||||
|
echo "\nAusentismo (POST)\n";
|
||||||
|
|
||||||
|
$aus = $flows['registrar_ausentismo'] ?? [];
|
||||||
|
$campos = array_column($aus['fields'] ?? [], null, 'key');
|
||||||
|
|
||||||
|
// Contrato de BotEntradaProcesador::ausentismo()
|
||||||
|
$requeridos = ['empleado_id', 'novedad_id', 'fecha_inicial', 'fecha_final'];
|
||||||
|
check('recolecta los 4 campos que exige el ERP',
|
||||||
|
array_values(array_intersect($requeridos, array_keys($campos))), $requeridos);
|
||||||
|
|
||||||
|
check('el trabajador se busca por nombre',
|
||||||
|
($campos['empleado_id']['lookup_param'] ?? null), 'filtro');
|
||||||
|
check('lee id/nombre como los devuelve /empleados',
|
||||||
|
[$campos['empleado_id']['value_field'] ?? null, $campos['empleado_id']['display_field'] ?? null],
|
||||||
|
['id', 'nombre']);
|
||||||
|
check('el motivo sale del catálogo del ERP',
|
||||||
|
($campos['novedad_id']['source_endpoint_key'] ?? null), 'novedades_ausentismo_dn');
|
||||||
|
check('ambas fechas validan formato',
|
||||||
|
[$campos['fecha_inicial']['other_validate'] ?? null, $campos['fecha_final']['other_validate'] ?? null],
|
||||||
|
['date', 'date']);
|
||||||
|
check('pide confirmación antes de enviar', $aus['confirm'] ?? false, true);
|
||||||
|
|
||||||
|
// Los endpoints que usa deben estar dados de alta
|
||||||
|
preg_match_all("/'key' => '([^']+)'/", substr($seed, $end), $mEp);
|
||||||
|
$eps = array_flip($mEp[1]);
|
||||||
|
foreach (['ausentismos_up', 'empleados_buscar_dn', 'novedades_ausentismo_dn'] as $k) {
|
||||||
|
check("endpoint {$k} registrado", isset($eps[$k]), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accesible para quienes reportan (cat 1 y cat 3), no para cat 2
|
||||||
|
$enMenu = function (array $config, string $cat, string $id): bool {
|
||||||
|
$pt = $config['per_type'][$cat] ?? [];
|
||||||
|
$menus = array_merge($config['menus'] ?? [], $pt['menus'] ?? []);
|
||||||
|
foreach ($menus as $m) {
|
||||||
|
foreach ($m['sections'] ?? [] as $sec) {
|
||||||
|
if (in_array($id, array_column($sec['rows'] ?? [], 'id'), true)) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
check('cat 1 lo ve en su menú', $enMenu($config, '1', 'registrar_ausentismo'), true);
|
||||||
|
check('cat 3 lo ve en enviar información', $enMenu($config, '3', 'registrar_ausentismo'), true);
|
||||||
|
|
||||||
|
echo "\nPluviometría (POST)\n";
|
||||||
|
|
||||||
|
$plu = $flows['registrar_pluviometria'] ?? [];
|
||||||
|
check('pide la fecha antes del bucle', $plu['ask_date'] ?? false, true);
|
||||||
|
check('excluye "Todas las fincas"', $plu['exclude_values'] ?? [], ['0']);
|
||||||
|
check('postea a pluvio_upload', $plu['endpoint_key'] ?? null, 'pluvio_upload');
|
||||||
|
check('endpoint pluvio_upload registrado', isset($eps['pluvio_upload']), true);
|
||||||
|
|
||||||
|
// Espejo de exclude_values: el catálogo antepone id 0 = "🌐 Todas las fincas"
|
||||||
|
$catalogo = [
|
||||||
|
['id' => '0', 'label' => '🌐 Todas las fincas'],
|
||||||
|
['id' => '4', 'label' => 'REPOSO'],
|
||||||
|
['id' => '7', 'label' => 'ROSA BLANCA'],
|
||||||
|
];
|
||||||
|
$reales = array_values(array_filter($catalogo,
|
||||||
|
fn($i) => !in_array((string)$i['id'], array_map('strval', $plu['exclude_values']), true)));
|
||||||
|
check('solo pregunta por fincas reales', array_column($reales, 'label'), ['REPOSO', 'ROSA BLANCA']);
|
||||||
|
|
||||||
|
// Espejo de submitForeach(): forma que exige BotEntradaProcesador::pluviosidad()
|
||||||
|
$armarBody = function (array $fe, string $from, string $nombre): array {
|
||||||
|
$lecturas = [];
|
||||||
|
foreach ($fe['collected'] as $id => $row) {
|
||||||
|
$lecturas[] = ['id' => (string)$id, 'label' => $row['label'], 'valor' => $row['valor']];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
'fecha' => $fe['fecha'] ?? date('Y-m-d'),
|
||||||
|
'telefono' => $from,
|
||||||
|
'nombre' => $nombre,
|
||||||
|
($fe['items_key'] ?? 'fincas') => $lecturas,
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
$body = $armarBody([
|
||||||
|
'fecha' => '2026-08-01',
|
||||||
|
'items_key' => $plu['items_key'] ?? 'fincas',
|
||||||
|
'collected' => [
|
||||||
|
'4' => ['label' => 'REPOSO', 'valor' => '35'],
|
||||||
|
'7' => ['label' => 'ROSA BLANCA', 'valor' => '12'],
|
||||||
|
],
|
||||||
|
], '573001565293', 'Usite');
|
||||||
|
|
||||||
|
// El procesador lee $d['fincas'] y $d['fecha']; el controller, telefono y nombre
|
||||||
|
check('la clave es "fincas", no "lecturas"', array_key_exists('fincas', $body), true);
|
||||||
|
check('manda la fecha elegida', $body['fecha'], '2026-08-01');
|
||||||
|
check('manda trazabilidad', [$body['telefono'], $body['nombre']], ['573001565293', 'Usite']);
|
||||||
|
check('cada lectura lleva id, label y valor',
|
||||||
|
array_keys($body['fincas'][0]), ['id', 'label', 'valor']);
|
||||||
|
check('los ids son los de finca', array_column($body['fincas'], 'id'), ['4', '7']);
|
||||||
|
|
||||||
|
// Sin fecha elegida cae a hoy, no a null
|
||||||
|
check('sin fecha usa hoy',
|
||||||
|
$armarBody(['collected' => []], 'x', 'y')['fecha'], date('Y-m-d'));
|
||||||
|
|
||||||
|
echo "\nRuteo de respuestas interactivas en collect_for_each\n";
|
||||||
|
|
||||||
|
/** Espejo del bloque __foreach de processInteractive() */
|
||||||
|
$rutaFe = function (array $fe, string $input): string {
|
||||||
|
if ($input === '__foreach_confirm') return 'enviar';
|
||||||
|
if ($input === '__foreach_cancel') return 'cancelar';
|
||||||
|
if (!empty($fe['ask_date']) && ($fe['fecha'] ?? null) === null) return 'foreach-input';
|
||||||
|
return 'menus';
|
||||||
|
};
|
||||||
|
|
||||||
|
$esperandoFecha = ['ask_date' => true, 'fecha' => null];
|
||||||
|
$conFecha = ['ask_date' => true, 'fecha' => '2026-08-04'];
|
||||||
|
|
||||||
|
// El bug: elegir la fecha caía en la resolución de menús y moría en silencio
|
||||||
|
check('elegir la fecha llega al flujo', $rutaFe($esperandoFecha, '2026-08-04'), 'foreach-input');
|
||||||
|
check('"otra fecha" también', $rutaFe($esperandoFecha, '__fe_other'), 'foreach-input');
|
||||||
|
check('confirmar sigue enviando', $rutaFe($esperandoFecha, '__foreach_confirm'), 'enviar');
|
||||||
|
check('cancelar sigue cancelando', $rutaFe($esperandoFecha, '__foreach_cancel'), 'cancelar');
|
||||||
|
// Con la fecha resuelta no debe secuestrar la navegación por menús
|
||||||
|
check('con fecha ya elegida no interfiere', $rutaFe($conFecha, 'submenu_ciclos'), 'menus');
|
||||||
|
check('un foreach sin fecha tampoco', $rutaFe(['ask_date' => false], 'submenu_ciclos'), 'menus');
|
||||||
|
|
||||||
|
echo "\nPaginado de listas largas\n";
|
||||||
|
|
||||||
|
/** Espejo del paginado de buildDynamicListResponse() */
|
||||||
|
$paginar = function (array $items, int $pagina, int $max = 10): array {
|
||||||
|
$hayMas = count($items) > $max;
|
||||||
|
$porPagina = $hayMas ? $max - 1 : $max;
|
||||||
|
$filas = array_slice($items, $pagina * $porPagina, $porPagina);
|
||||||
|
$restantes = count($items) - (($pagina + 1) * $porPagina);
|
||||||
|
if ($hayMas && $restantes > 0) $filas[] = '__mas';
|
||||||
|
return $filas;
|
||||||
|
};
|
||||||
|
|
||||||
|
// El caso real: 17 novedades de ausentismo, de las que 7 eran inalcanzables
|
||||||
|
$novedades = array_map(fn($n) => "N{$n}", range(1, 17));
|
||||||
|
|
||||||
|
$vistas = [];
|
||||||
|
$pagina = 0;
|
||||||
|
do {
|
||||||
|
$filas = $paginar($novedades, $pagina);
|
||||||
|
$hayMas = in_array('__mas', $filas, true);
|
||||||
|
foreach ($filas as $f) if ($f !== '__mas') $vistas[] = $f;
|
||||||
|
$pagina++;
|
||||||
|
} while ($hayMas && $pagina < 20);
|
||||||
|
|
||||||
|
check('recorriendo las páginas se llega a todas', count($vistas), 17);
|
||||||
|
check('ninguna se repite', count(array_unique($vistas)), 17);
|
||||||
|
check('ninguna se pierde', array_diff($novedades, $vistas), []);
|
||||||
|
check('termina, no cicla', $pagina <= 3, true);
|
||||||
|
check('la última página no ofrece "ver más"',
|
||||||
|
in_array('__mas', $paginar($novedades, 1), true), false);
|
||||||
|
|
||||||
|
// Con 10 o menos no debe aparecer el botón
|
||||||
|
check('10 opciones entran sin paginar',
|
||||||
|
$paginar(array_slice($novedades, 0, 10), 0), array_slice($novedades, 0, 10));
|
||||||
|
|
||||||
|
echo "\nCiclos — apertura y cierre (POST)\n";
|
||||||
|
|
||||||
|
$cic = $flows['registrar_ciclo'] ?? [];
|
||||||
|
$cf = array_column($cic['fields'] ?? [], null, 'key');
|
||||||
|
|
||||||
|
check('pregunta ciclo, acción, fecha y lotes',
|
||||||
|
array_keys($cf), ['ciclo', 'accion', 'fecha', 'lote_ids']);
|
||||||
|
check('los seis ciclos, sin mantenimiento',
|
||||||
|
array_column($cf['ciclo']['options'], 'id'),
|
||||||
|
['cosecha', 'polinizacion', 'sanidad', 'censo', 'palm', 'tratamiento']);
|
||||||
|
check('apertura y cierre',
|
||||||
|
array_column($cf['accion']['options'], 'id'), ['apertura', 'cierre']);
|
||||||
|
check('los lotes se eligen de a varios', $cf['lote_ids']['type'], 'multi_select');
|
||||||
|
|
||||||
|
// El catálogo de lotes depende de la acción: todos vs solo los abiertos
|
||||||
|
$resolver = function (string $tpl, array $col): string {
|
||||||
|
foreach ($col as $k => $v) $tpl = str_replace('{' . $k . '}', (string)$v, $tpl);
|
||||||
|
return $tpl;
|
||||||
|
};
|
||||||
|
check('apertura ofrece los lotes de la finca',
|
||||||
|
$resolver($cf['lote_ids']['source_endpoint_key'], ['accion' => 'apertura']), 'lotes_apertura_dn');
|
||||||
|
check('cierre ofrece solo los abiertos',
|
||||||
|
$resolver($cf['lote_ids']['source_endpoint_key'], ['accion' => 'cierre']), 'lotes_cierre_dn');
|
||||||
|
|
||||||
|
// Un solo endpoint registrado sirve para los seis tipos
|
||||||
|
foreach (['ciclos_up', 'lotes_apertura_dn', 'lotes_cierre_dn'] as $k) {
|
||||||
|
check("endpoint {$k} registrado", isset($eps[$k]), true);
|
||||||
|
}
|
||||||
|
$urlCiclos = $BASE_TEST = '?peticion=ciclos_{ciclo}_up';
|
||||||
|
check('la URL del POST resuelve por ciclo',
|
||||||
|
$resolver($urlCiclos, ['ciclo' => 'censo']), '?peticion=ciclos_censo_up');
|
||||||
|
check('y coincide con los tipos del ERP',
|
||||||
|
array_map(fn($o) => $resolver($urlCiclos, ['ciclo' => $o['id']]), $cf['ciclo']['options']),
|
||||||
|
['?peticion=ciclos_cosecha_up', '?peticion=ciclos_polinizacion_up', '?peticion=ciclos_sanidad_up',
|
||||||
|
'?peticion=ciclos_censo_up', '?peticion=ciclos_palm_up', '?peticion=ciclos_tratamiento_up']);
|
||||||
|
|
||||||
|
// Espejo del acumulador de multi_select
|
||||||
|
$alternar = function (array $elegidos, string $id, array $catalogo): array {
|
||||||
|
if (isset($elegidos[$id])) unset($elegidos[$id]);
|
||||||
|
else $elegidos[$id] = $catalogo[$id];
|
||||||
|
return $elegidos;
|
||||||
|
};
|
||||||
|
$catalogo = ['4' => 'REPOSO 1A', '7' => 'REPOSO 1B', '9' => 'REPOSO 2A'];
|
||||||
|
$e = [];
|
||||||
|
$e = $alternar($e, '4', $catalogo);
|
||||||
|
$e = $alternar($e, '7', $catalogo);
|
||||||
|
check('acumula varios lotes', array_map('strval', array_keys($e)), ['4', '7']);
|
||||||
|
$e = $alternar($e, '4', $catalogo);
|
||||||
|
check('y desmarca al volver a tocar', array_map('strval', array_keys($e)), ['7']);
|
||||||
|
check('el POST manda un array de ids', array_map('strval', array_keys($alternar($e, '9', $catalogo))), ['7', '9']);
|
||||||
|
|
||||||
|
check('accesible desde enviar información', $enMenu($config, '3', 'registrar_ciclo'), true);
|
||||||
|
|
||||||
|
echo "\nCategorías ruteables por el NLU\n";
|
||||||
|
|
||||||
|
/** Espejo de buildFlowCatalog(): qué ve la IA según la categoría del usuario */
|
||||||
|
$catalogo = function (array $config, int $perm): array {
|
||||||
|
$out = [];
|
||||||
|
foreach (flowsDe($config, (string)$perm) as $k => $f) {
|
||||||
|
$tipo = $f['type'] ?? 'text';
|
||||||
|
$dir = $f['nlu_dir'] ?? '';
|
||||||
|
$sube = in_array($tipo, ['collect_and_post', 'collect_for_each', 'submit_form'], true) || $dir === 'subida';
|
||||||
|
$baja = ($tipo === 'function' && ($f['function'] ?? '') === 'api_report') || $dir === 'descarga';
|
||||||
|
if ($sube && !in_array($perm, [1, 3], true)) continue;
|
||||||
|
if ($baja && !in_array($perm, [2, 3], true)) continue;
|
||||||
|
if (!empty($f['nlu_skip'])) continue;
|
||||||
|
if (($f['nlu_description'] ?? '') === '') continue;
|
||||||
|
$out[] = $k;
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Los tres casos que reporto la clienta: decir la categoria a secas
|
||||||
|
foreach (['submenu_ausentismos', 'submenu_produccion', 'submenu_ciclos_sanidad', 'submenu_ciclos'] as $k) {
|
||||||
|
check("la IA puede rutear a {$k}", in_array($k, $catalogo($config, 2), true), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// El filtro de permisos resuelve la ambiguedad en cat 1 y cat 2
|
||||||
|
$c1 = $catalogo($config, 1);
|
||||||
|
$c2 = $catalogo($config, 2);
|
||||||
|
check('cat 1 solo ve registrar ausentismo',
|
||||||
|
[in_array('registrar_ausentismo', $c1, true), in_array('submenu_ausentismos', $c1, true)], [true, false]);
|
||||||
|
check('cat 2 solo ve consultar ausentismos',
|
||||||
|
[in_array('registrar_ausentismo', $c2, true), in_array('submenu_ausentismos', $c2, true)], [true === false, true]);
|
||||||
|
|
||||||
|
// Cat 3 ve las dos, por eso necesita desambiguar
|
||||||
|
$c3 = $catalogo($config, 3);
|
||||||
|
check('cat 3 ve consultar y registrar',
|
||||||
|
[in_array('registrar_ausentismo', $c3, true), in_array('submenu_ausentismos', $c3, true)], [true, true]);
|
||||||
|
check('y tiene el menu que pregunta cual', in_array('ausentismo', $c3, true), true);
|
||||||
|
|
||||||
|
// La navegacion pura sigue oculta: rutear ahi no aporta nada
|
||||||
|
foreach (['show_main_menu', 'descargar_informes', 'enviar_informacion'] as $k) {
|
||||||
|
check("{$k} sigue oculto al NLU", in_array($k, $catalogo($config, 3), true), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// El orden por uso lo resuelve Palmas360, no el bot
|
||||||
|
check('el bot ya no reordena por su cuenta',
|
||||||
|
isset(flowsDe($config, '3')['registrar_ausentismo']['fields'][1]['frecuentes']), false);
|
||||||
|
|
||||||
|
echo "\nLotes para abrir y cerrar ciclos\n";
|
||||||
|
|
||||||
|
$cfl = array_column($flows['registrar_ciclo']['fields'], null, 'key')['lote_ids'];
|
||||||
|
check('apertura y cierre usan catálogos distintos',
|
||||||
|
$cfl['source_endpoint_key'], 'lotes_{accion}_dn');
|
||||||
|
check('y "Otro lote" trae el listado completo',
|
||||||
|
$cfl['full_endpoint_key'], 'lotes_{accion}_todos_dn');
|
||||||
|
|
||||||
|
$resolver = function (string $t, array $c): string {
|
||||||
|
foreach ($c as $k => $v) $t = str_replace('{' . $k . '}', (string)$v, $t);
|
||||||
|
return $t;
|
||||||
|
};
|
||||||
|
foreach (['apertura', 'cierre'] as $acc) {
|
||||||
|
check("{$acc}: catálogo corto", $resolver($cfl['source_endpoint_key'], ['accion' => $acc]), "lotes_{$acc}_dn");
|
||||||
|
check("{$acc}: catálogo completo", $resolver($cfl['full_endpoint_key'], ['accion' => $acc]), "lotes_{$acc}_todos_dn");
|
||||||
|
foreach ([$acc . '_dn', $acc . '_todos_dn'] as $sufijo) {
|
||||||
|
check("endpoint lotes_{$sufijo} registrado", isset($eps['lotes_' . $sufijo]), true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Con "Otro" visible se reservan tres filas: paginar, ver todos y listo
|
||||||
|
$filas = function (int $total, bool $verTodos): int {
|
||||||
|
$max = 10;
|
||||||
|
$porPagina = $max - ($verTodos ? 3 : 2);
|
||||||
|
$hayMas = $total > $porPagina;
|
||||||
|
$n = min($total, $porPagina);
|
||||||
|
if ($hayMas) $n++;
|
||||||
|
if ($verTodos) $n++;
|
||||||
|
return $n + 1;
|
||||||
|
};
|
||||||
|
check('la lista corta entra en el límite de WhatsApp', $filas(10, true) <= 10, true);
|
||||||
|
check('el listado completo también', $filas(40, false) <= 10, true);
|
||||||
|
|
||||||
|
echo "\nMantenimiento (POST)\n";
|
||||||
|
|
||||||
|
$mto = $flows['registrar_mantenimiento'] ?? [];
|
||||||
|
$mf = array_column($mto['fields'] ?? [], null, 'key');
|
||||||
|
|
||||||
|
// labores_up exige novedad y empleados: sin ellos el ERP rechazaba siempre
|
||||||
|
check('pide fecha, lote, labor, cuadrilla y cantidad',
|
||||||
|
array_keys($mf), ['fecha', 'lote_id', 'novedad_id', 'empleados', 'cantidad']);
|
||||||
|
check('los lotes salen de los que tienen pendiente',
|
||||||
|
$mf['lote_id']['source_endpoint_key'] ?? null, 'lotes_mantenimiento_dn');
|
||||||
|
check('endpoint registrado', isset($eps['lotes_mantenimiento_dn']), true);
|
||||||
|
check('postea como labor diaria', $mto['endpoint_key'] ?? null, 'labores_up');
|
||||||
|
|
||||||
|
// El grupo se pide antes, con la misma intencion pendiente de los informes
|
||||||
|
check('pide el grupo si falta', $mto['requires'] ?? null, ['grupo_mant' => 'grupo_id']);
|
||||||
|
check('y lo resuelve con el selector', $mto['resolver'] ?? null, 'ciclo_mantenimiento');
|
||||||
|
|
||||||
|
// No va en el flujo de ciclos: no tiene apertura ni cierre
|
||||||
|
$cic = array_column($flows['registrar_ciclo']['fields'], null, 'key');
|
||||||
|
check('mantenimiento no está entre los ciclos',
|
||||||
|
in_array('mantenimiento', array_column($cic['ciclo']['options'], 'id'), true), false);
|
||||||
|
|
||||||
|
echo "\nLabores diarias (POST)\n";
|
||||||
|
|
||||||
|
$lab = $flows['registrar_labor'] ?? [];
|
||||||
|
$lf = array_column($lab['fields'] ?? [], null, 'key');
|
||||||
|
|
||||||
|
check('pide fecha, labor, lote, trabajadores y cantidad',
|
||||||
|
array_keys($lf), ['fecha', 'novedad_id', 'lote_id', 'empleados', 'cantidad']);
|
||||||
|
check('el catálogo lo filtra el ERP por fase',
|
||||||
|
$lf['novedad_id']['source_endpoint_key'] ?? null, 'novedades_labor_dn');
|
||||||
|
check('los trabajadores se eligen de a varios',
|
||||||
|
$lf['empleados']['type'] ?? null, 'multi_select');
|
||||||
|
check('y el vinculado se pre-llena',
|
||||||
|
$lf['empleados']['from_phone'] ?? null, 'tercero_id');
|
||||||
|
check('postea a labores_up', $lab['endpoint_key'] ?? null, 'labores_up');
|
||||||
|
foreach (['novedades_labor_dn', 'lotes_finca_dn', 'empleados_labor_dn', 'labores_up'] as $k) {
|
||||||
|
check("endpoint {$k} registrado", isset($eps[$k]), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "\nFase 3 — campos según el tipo\n";
|
||||||
|
|
||||||
|
$f3 = $flows['registrar_labor_validada'] ?? [];
|
||||||
|
$c3 = array_column($f3['fields'] ?? [], null, 'key');
|
||||||
|
|
||||||
|
check('ofrece los tres tipos',
|
||||||
|
array_column($c3['tipo']['options'], 'id'), ['cosecha', 'polinizacion', 'fertilizacion']);
|
||||||
|
check('el producto sale de la fertilización autorizada del lote',
|
||||||
|
$c3['producto_id']['source_endpoint_key'] ?? null, 'productos_fertilizacion_dn');
|
||||||
|
|
||||||
|
/** Espejo de capShouldSkip() */
|
||||||
|
$saltear = function (array $campo, array $col): bool {
|
||||||
|
$si = $campo['skip_if'] ?? null;
|
||||||
|
if (!$si) return false;
|
||||||
|
$v = (string)($col[$si['field']] ?? '');
|
||||||
|
if (isset($si['not_in'])) return !in_array($v, array_map('strval', $si['not_in']), true);
|
||||||
|
if (isset($si['equals'])) return $v !== (string)$si['equals'];
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
$pedidos = function (array $campos, string $tipo) use ($saltear): array {
|
||||||
|
$col = ['tipo' => $tipo];
|
||||||
|
return array_values(array_filter(array_keys($campos),
|
||||||
|
fn($k) => !$saltear($campos[$k], $col)));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Los tres formularios del pedido, sin escribir tres flujos
|
||||||
|
check('cosecha: sin producto ni pase',
|
||||||
|
$pedidos($c3, 'cosecha'), ['tipo', 'novedad_id', 'fecha', 'lote_id', 'empleados', 'cantidad']);
|
||||||
|
check('fertilización: con producto, sin pase',
|
||||||
|
$pedidos($c3, 'fertilizacion'), ['tipo', 'novedad_id', 'fecha', 'lote_id', 'producto_id', 'empleados', 'cantidad']);
|
||||||
|
check('polinización: con producto y pase',
|
||||||
|
$pedidos($c3, 'polinizacion'), ['tipo', 'novedad_id', 'fecha', 'lote_id', 'producto_id', 'empleados', 'cantidad', 'fase_polinizacion']);
|
||||||
|
|
||||||
|
// El producto se pregunta despues del lote: la dosis depende de los dos
|
||||||
|
$orden = array_keys($c3);
|
||||||
|
check('el lote se pregunta antes que el producto',
|
||||||
|
array_search('lote_id', $orden) < array_search('producto_id', $orden), true);
|
||||||
|
|
||||||
|
foreach (['novedades_labor3_dn', 'productos_fertilizacion_dn'] as $k) {
|
||||||
|
check("endpoint {$k} registrado", isset($eps[$k]), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "\nAlcance de fincas por número\n";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Espejo del endpoint fincas: acota al alcance del numero y antepone "Todas"
|
||||||
|
* solo a quien no tiene restriccion. Con dos de cinco asignadas, "todas" no
|
||||||
|
* significa nada claro, asi que no se ofrece.
|
||||||
|
*/
|
||||||
|
$catalogoFincas = function (array $activas, array $alcance): array {
|
||||||
|
if ($alcance) {
|
||||||
|
$activas = array_values(array_filter($activas, fn($f) => in_array($f['id'], $alcance, true)));
|
||||||
|
}
|
||||||
|
$lista = array_column($activas, 'id');
|
||||||
|
if (!$alcance) array_unshift($lista, '0');
|
||||||
|
return $lista;
|
||||||
|
};
|
||||||
|
|
||||||
|
$activas = [['id' => '4'], ['id' => '7'], ['id' => '9']];
|
||||||
|
|
||||||
|
check('sin restricción ve todas y el "Todas"', $catalogoFincas($activas, []), ['0', '4', '7', '9']);
|
||||||
|
check('con dos asignadas ve solo esas', $catalogoFincas($activas, ['4', '9']), ['4', '9']);
|
||||||
|
check('y sin el "Todas"', in_array('0', $catalogoFincas($activas, ['4', '9']), true), false);
|
||||||
|
|
||||||
|
// Una sola finca: un unico item, y handleDynamicList auto-selecciona en ese caso
|
||||||
|
check('con una sola no hay nada que preguntar', $catalogoFincas($activas, ['7']), ['7']);
|
||||||
|
check('el bot ya no filtra por su cuenta', isset($flows['ask_finca']['scope_from']), false);
|
||||||
|
|
||||||
|
echo "\nask_finca\n";
|
||||||
|
$af = $flows['ask_finca'];
|
||||||
|
check('no repregunta si ya hay finca', $af['skip_if_set'] ?? false, true);
|
||||||
|
check('reset_finca la vuelve a pedir', isset($flows['reset_finca']), true);
|
||||||
|
|
||||||
|
echo "\n";
|
||||||
|
if ($fallas) { echo "{$fallas} falla(s)\n"; exit(1); }
|
||||||
|
echo "OK — navegación correcta\n";
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Arnés para ejecutar NormalBot de verdad, sin MySQL ni WhatsApp:
|
||||||
|
*
|
||||||
|
* db() -> falso en PHP puro (sin drivers), con los endpoints
|
||||||
|
* del seed reescritos hacia el servidor de fixtures
|
||||||
|
* ConversationContext -> estado en memoria, un contexto por teléfono
|
||||||
|
* WhatsAppSender -> captura los envíos directos en ::$enviados
|
||||||
|
* AiBot -> respuestas guionadas en ::$respuestas
|
||||||
|
*
|
||||||
|
* El curl es real: pega contra fixtures_api.php servido con `php -S`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const FIXTURE_BASE = 'http://127.0.0.1:8973/fixtures_api.php';
|
||||||
|
|
||||||
|
// ── db(): falso en PHP puro ──────────────────────────────────────────────────
|
||||||
|
// Sin SQLite: los servidores no siempre traen pdo_sqlite. NormalBot solo hace
|
||||||
|
// dos consultas —el endpoint por clave y el perfil por número— así que un par
|
||||||
|
// de mapas en memoria alcanza y no dependemos de ningún driver.
|
||||||
|
|
||||||
|
class FakeStmt
|
||||||
|
{
|
||||||
|
private $row = false;
|
||||||
|
public function __construct(private string $sql) {}
|
||||||
|
|
||||||
|
public function execute(array $p = []): bool
|
||||||
|
{
|
||||||
|
if (str_contains($this->sql, 'company_endpoints')) {
|
||||||
|
$key = $p[1] ?? '';
|
||||||
|
$url = FakeDb::$endpoints[$key] ?? null;
|
||||||
|
$this->row = $url === null ? false
|
||||||
|
: ['url' => $url, 'method' => 'GET', 'params' => null];
|
||||||
|
} elseif (str_contains($this->sql, 'company_phones')) {
|
||||||
|
$this->row = FakeDb::$phones[$p[1] ?? ''] ?? false;
|
||||||
|
} else {
|
||||||
|
$this->row = false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function fetch() { return $this->row; }
|
||||||
|
public function fetchColumn() { return $this->row ? array_values($this->row)[0] : false; }
|
||||||
|
public function fetchAll(): array { return $this->row ? [$this->row] : []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeDb
|
||||||
|
{
|
||||||
|
/** endpoint_key => url absoluta hacia el servidor de fixtures */
|
||||||
|
public static array $endpoints = [];
|
||||||
|
/** wa_number => fila de company_phones */
|
||||||
|
public static array $phones = [];
|
||||||
|
|
||||||
|
public function prepare(string $sql): FakeStmt { return new FakeStmt($sql); }
|
||||||
|
public function query(string $sql): FakeStmt { $s = new FakeStmt($sql); $s->execute(); return $s; }
|
||||||
|
public function exec(string $sql): int { return 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function db(): FakeDb
|
||||||
|
{
|
||||||
|
static $db = null;
|
||||||
|
if ($db !== null) return $db;
|
||||||
|
|
||||||
|
$db = new FakeDb();
|
||||||
|
// Los endpoints salen del seed real: mismas claves y placeholders, otra base.
|
||||||
|
$seed = file_get_contents(dirname(__DIR__) . '/seed_palmas.php');
|
||||||
|
preg_match_all("/\['key' => '([^']+)',\s*'dir' => '([^']+)',\s*'url' => \\\$BASE \. '([^']*)'/", $seed, $m);
|
||||||
|
foreach ($m[1] as $i => $key) {
|
||||||
|
FakeDb::$endpoints[$key] = FIXTURE_BASE . $m[3][$i];
|
||||||
|
}
|
||||||
|
return $db;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Registra un número con perfil para los escenarios de trabajador/supervisor. */
|
||||||
|
function fakePhone(string $wa, ?int $tercero, int $esSupervisor): void
|
||||||
|
{
|
||||||
|
FakeDb::$phones[$wa] = [
|
||||||
|
'tercero_id' => $tercero,
|
||||||
|
'modulos_json' => null,
|
||||||
|
'es_supervisor' => $esSupervisor,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ConversationContext en memoria ───────────────────────────────────────────
|
||||||
|
class ConversationContext
|
||||||
|
{
|
||||||
|
public static array $store = [];
|
||||||
|
|
||||||
|
private static function &ctx(int $id): array
|
||||||
|
{
|
||||||
|
if (!isset(self::$store[$id])) {
|
||||||
|
self::$store[$id] = ['current_node' => null, 'metadata' => []];
|
||||||
|
}
|
||||||
|
return self::$store[$id];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getOrCreate(int $companyId, string $phone, string $botType = 'normal'): array
|
||||||
|
{
|
||||||
|
// Un id estable por teléfono para aislar escenarios
|
||||||
|
$id = crc32($companyId . '|' . $phone) % 100000;
|
||||||
|
$c = self::ctx($id);
|
||||||
|
return ['id' => $id, 'current_node' => $c['current_node'], 'metadata' => json_encode($c['metadata'])];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function updateNode(int $id, ?string $node): void
|
||||||
|
{
|
||||||
|
self::ctx($id)['current_node'] = $node;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getMetadata(int $id): array
|
||||||
|
{
|
||||||
|
return self::ctx($id)['metadata'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function updateMetadata(int $id, array $meta): void
|
||||||
|
{
|
||||||
|
self::ctx($id)['metadata'] = $meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function reset(int $id): void
|
||||||
|
{
|
||||||
|
self::$store[$id] = ['current_node' => null, 'metadata' => []];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── WhatsAppSender: captura ──────────────────────────────────────────────────
|
||||||
|
class WhatsAppSender
|
||||||
|
{
|
||||||
|
public static array $enviados = [];
|
||||||
|
|
||||||
|
public static function sendText(string $to, string $text, string $phoneNumberId): array
|
||||||
|
{
|
||||||
|
self::$enviados[] = ['to' => $to, 'text' => $text];
|
||||||
|
return ['success' => true];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function uploadMedia(string $file, string $mime, string $phoneNumberId): array
|
||||||
|
{
|
||||||
|
return ['success' => true, 'media_id' => 'test-media'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function sendDocument(string $to, string $mediaId, string $phoneNumberId, string $caption, string $name): array
|
||||||
|
{
|
||||||
|
self::$enviados[] = ['to' => $to, 'document' => $name];
|
||||||
|
return ['success' => true];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AiBot guionado ───────────────────────────────────────────────────────────
|
||||||
|
class AiBot
|
||||||
|
{
|
||||||
|
/** Cola de respuestas para routeOrChat; vacía = chat vacío (cae al fallback). */
|
||||||
|
public static array $respuestas = [];
|
||||||
|
|
||||||
|
public static array $modulosRecibidos = [];
|
||||||
|
|
||||||
|
public static function routeOrChat(array $company, array $context, string $input, array $modulosPermitidos = []): array
|
||||||
|
{
|
||||||
|
self::$modulosRecibidos = $modulosPermitidos;
|
||||||
|
return array_shift(self::$respuestas) ?? ['action' => 'chat', 'text' => ''];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
require_once dirname(__DIR__, 2) . '/services/NormalBot.php';
|
||||||
|
|
||||||
|
// ── Utilidades del test ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function empresa(): array
|
||||||
|
{
|
||||||
|
static $config = null;
|
||||||
|
if ($config === null) {
|
||||||
|
$seed = file_get_contents(dirname(__DIR__) . '/seed_palmas.php');
|
||||||
|
$open = strpos($seed, '[', strpos($seed, '$configJson = ['));
|
||||||
|
$d = 0; $end = $open;
|
||||||
|
for ($i = $open, $n = strlen($seed); $i < $n; $i++) {
|
||||||
|
if ($seed[$i] === '[') $d++;
|
||||||
|
elseif ($seed[$i] === ']') { $d--; if (!$d) { $end = $i; break; } }
|
||||||
|
}
|
||||||
|
$config = eval('return ' . substr($seed, $open, $end - $open + 1) . ';');
|
||||||
|
// Sin saludo diario: mete un envío extra en cada primer mensaje
|
||||||
|
$config['welcome']['enabled'] = false;
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
'id' => 1,
|
||||||
|
'name' => 'test',
|
||||||
|
'display_name' => 'Empresa Test',
|
||||||
|
'api_key' => 'k',
|
||||||
|
'config_json' => json_encode($config),
|
||||||
|
'_permission_type' => 3,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function contexto(string $phone): array
|
||||||
|
{
|
||||||
|
return ['from' => $phone, 'name' => 'Tester', 'phone_number_id' => 'pn', 'permission_type' => 3];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Texto plano de la respuesta del bot, venga como texto o interactivo. */
|
||||||
|
function textoDe(?array $resp): string
|
||||||
|
{
|
||||||
|
if (!$resp) return '';
|
||||||
|
$p = json_decode($resp['payload'] ?? '{}', true);
|
||||||
|
if (isset($p['text'])) return $p['text'];
|
||||||
|
$i = $p['interactive'] ?? [];
|
||||||
|
return ($i['header']['text'] ?? '') . ' ' . ($i['body']['text'] ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Filas/botones de una respuesta interactiva, como id => title. */
|
||||||
|
function filasDe(?array $resp): array
|
||||||
|
{
|
||||||
|
$p = json_decode($resp['payload'] ?? '{}', true);
|
||||||
|
$i = $p['interactive'] ?? [];
|
||||||
|
$out = [];
|
||||||
|
foreach ($i['action']['sections'] ?? [] as $sec) {
|
||||||
|
foreach ($sec['rows'] ?? [] as $r) $out[$r['id']] = $r['title'];
|
||||||
|
}
|
||||||
|
foreach ($i['action']['buttons'] ?? [] as $b) {
|
||||||
|
$out[$b['reply']['id']] = $b['reply']['title'];
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Las capturas del servidor de fixtures desde la última llamada. */
|
||||||
|
function capturas(): array
|
||||||
|
{
|
||||||
|
$log = getenv('CAPTURE_LOG');
|
||||||
|
if (!is_file($log)) return [];
|
||||||
|
$out = [];
|
||||||
|
foreach (file($log, FILE_IGNORE_NEW_LINES) as $l) $out[] = json_decode($l, true);
|
||||||
|
file_put_contents($log, '');
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
$GLOBALS['fallas'] = 0;
|
||||||
|
function check(string $nombre, $obtenido, $esperado = true): void
|
||||||
|
{
|
||||||
|
$ok = $obtenido === $esperado;
|
||||||
|
if (!$ok) $GLOBALS['fallas']++;
|
||||||
|
printf("%s %s\n", $ok ? ' ok ' : ' FALLA', $nombre);
|
||||||
|
if (!$ok) {
|
||||||
|
echo " esperaba: " . json_encode($esperado, JSON_UNESCAPED_UNICODE) . "\n";
|
||||||
|
echo " obtuvo: " . json_encode($obtenido, JSON_UNESCAPED_UNICODE) . "\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* ERP falso para los tests unitarios. Se sirve con `php -S` y responde los
|
||||||
|
* catálogos con datos fijos; los POST se capturan en un log JSONL para que el
|
||||||
|
* test verifique el payload exacto que armó el bot.
|
||||||
|
*/
|
||||||
|
|
||||||
|
$peticion = $_GET['peticion'] ?? '';
|
||||||
|
$capture = getenv('CAPTURE_LOG') ?: sys_get_temp_dir() . '/bot_test_capture.jsonl';
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
// ── POST: capturar y responder como el ERP real ──────────────────────────────
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$body = json_decode(file_get_contents('php://input'), true);
|
||||||
|
file_put_contents($capture, json_encode([
|
||||||
|
'peticion' => $peticion,
|
||||||
|
'query' => $_GET,
|
||||||
|
'body' => $body,
|
||||||
|
], JSON_UNESCAPED_UNICODE) . "\n", FILE_APPEND);
|
||||||
|
|
||||||
|
// Mismo shape que BotEntradaProcesador para que el bot repita el detalle
|
||||||
|
echo json_encode([
|
||||||
|
'status' => 'ok',
|
||||||
|
'mensaje' => 'capturado:' . $peticion,
|
||||||
|
], JSON_UNESCAPED_UNICODE);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GET: catálogos fijos ──────────────────────────────────────────────────────
|
||||||
|
$fincas = [
|
||||||
|
['id' => '0', 'label' => '🌐 Todas las fincas'],
|
||||||
|
['id' => '4', 'label' => 'REPOSO'],
|
||||||
|
['id' => '7', 'label' => 'ROSA BLANCA'],
|
||||||
|
];
|
||||||
|
// El ERP acota por número: este wa solo tiene una finca asignada
|
||||||
|
if (($_GET['wa'] ?? '') === '57300RESTRINGIDO') {
|
||||||
|
$fincas = [['id' => '4', 'label' => 'REPOSO']];
|
||||||
|
}
|
||||||
|
|
||||||
|
$novedades17 = [];
|
||||||
|
foreach (range(1, 17) as $i) $novedades17[] = ['id' => (string)(100 + $i), 'nombre' => "NOVEDAD {$i}"];
|
||||||
|
|
||||||
|
$lotes12 = [];
|
||||||
|
foreach (range(1, 12) as $i) $lotes12[] = ['id' => (string)$i, 'label' => "REPOSO {$i}A (" . (50 - $i) . "d)"];
|
||||||
|
// Como el ERP real: sin finca_id valida, el catalogo mezcla fincas
|
||||||
|
if (intval($_GET['finca_id'] ?? 0) !== 4) {
|
||||||
|
array_unshift($lotes12, ['id' => '99', 'label' => 'ROSA BLANCA 9Z (60d)']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$fixtures = [
|
||||||
|
'fincas' => $fincas,
|
||||||
|
'empleados' => [
|
||||||
|
['id' => '412', 'nombre' => 'JULIO PEREZ GOMEZ'],
|
||||||
|
['id' => '415', 'nombre' => 'JULIO CESAR RAMIREZ'],
|
||||||
|
['id' => '418', 'nombre' => 'MARIA GOMEZ RUIZ'],
|
||||||
|
],
|
||||||
|
'novedades_ausentismo_dn' => (function () {
|
||||||
|
// Como el ERP: ?grupos=1 devuelve los grupos, ?grupo_novedad=X los suyos
|
||||||
|
$porGrupo = [
|
||||||
|
'Incapacidad' => [['id'=>'46','nombre'=>'ENFERMEDAD < 3 DIAS','descripcion'=>'INCAPACIDAD ENFERMEDAD < 3 DIAS'],
|
||||||
|
['id'=>'47','nombre'=>'ACCIDENTE DE TRANSITO','descripcion'=>'INCAPACIDAD ACCIDENTE DE TRANSITO']],
|
||||||
|
'Permiso' => [['id'=>'50','nombre'=>'CITA MEDICA','descripcion'=>'PERMISO CITA MEDICA']],
|
||||||
|
'Vacaciones' => [['id'=>'57','nombre'=>'DISFRUTADAS','descripcion'=>'VACACIONES DISFRUTADAS']],
|
||||||
|
];
|
||||||
|
if (!empty($_GET['grupos'])) {
|
||||||
|
$out = [];
|
||||||
|
foreach ($porGrupo as $g => $i) $out[] = ['id'=>$g,'nombre'=>$g,'descripcion'=>count($i).' opcion(es)'];
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
$g = trim((string)($_GET['grupo_novedad'] ?? ''));
|
||||||
|
return $g !== '' ? ($porGrupo[$g] ?? []) : array_merge(...array_values($porGrupo));
|
||||||
|
})(),
|
||||||
|
'novedades_labor_dn' => !empty($_GET['grupo'])
|
||||||
|
? [['id' => '77', 'nombre' => 'PLATEO MANUAL']]
|
||||||
|
: [
|
||||||
|
['id' => '42', 'nombre' => 'TRACTORISTA'],
|
||||||
|
['id' => '43', 'nombre' => 'HORAS EXTRA'],
|
||||||
|
],
|
||||||
|
'lotes_apertura_dn' => $lotes12,
|
||||||
|
'lotes_abiertos_dn' => array_slice($lotes12, 0, 3),
|
||||||
|
'lotes_x_finca' => intval($_GET['finca_id'] ?? 0) === 4
|
||||||
|
? [['id' => '4', 'label' => 'REPOSO 1A'], ['id' => '9', 'label' => 'REPOSO 2A']]
|
||||||
|
: [['id' => '99', 'label' => 'ROSA BLANCA 9Z']],
|
||||||
|
'grupos_mantenimiento' => [
|
||||||
|
['id' => '5', 'label' => 'PLATEO'],
|
||||||
|
['id' => '6', 'label' => 'CORONA'],
|
||||||
|
],
|
||||||
|
'lotes_mantenimiento_dn' => [
|
||||||
|
['id' => '4', 'label' => 'REPOSO 1A (falta 12)', 'faltante' => 12],
|
||||||
|
],
|
||||||
|
'productos_fertilizacion_dn' => [
|
||||||
|
['id' => '30', 'nombre' => 'UREA (falta 120)', 'dosis' => 120],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
// Los informes de texto responden {status, message}
|
||||||
|
if (str_contains($peticion, '_texto_bot')) {
|
||||||
|
file_put_contents($capture, json_encode(['peticion' => $peticion, 'query' => $_GET]) . "\n", FILE_APPEND);
|
||||||
|
echo json_encode(['status' => '1', 'message' => 'informe-de-prueba']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($fixtures[$peticion])) {
|
||||||
|
// La paginación del ERP: los catálogos cortos devuelven 10 salvo &todos=1
|
||||||
|
$datos = $fixtures[$peticion];
|
||||||
|
if (in_array($peticion, ['lotes_apertura_dn', 'lotes_abiertos_dn'], true) && empty($_GET['todos'])) {
|
||||||
|
$datos = array_slice($datos, 0, 10);
|
||||||
|
}
|
||||||
|
echo json_encode(['status' => '1', 'datos' => $datos], JSON_UNESCAPED_UNICODE);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
http_response_code(404);
|
||||||
|
echo json_encode(['status' => 'error', 'mensaje' => "sin fixture: {$peticion}"]);
|
||||||
Executable
+10
@@ -0,0 +1,10 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Levanta el ERP falso, corre los tests de flujo y apaga el servidor.
|
||||||
|
cd "$(dirname "$0")/../.."
|
||||||
|
export CAPTURE_LOG="${TMPDIR:-/tmp}/bot_test_capture.jsonl"
|
||||||
|
: > "$CAPTURE_LOG"
|
||||||
|
CAPTURE_LOG="$CAPTURE_LOG" php -S 127.0.0.1:8973 setup/tests/fixtures_api.php >/dev/null 2>&1 &
|
||||||
|
SERVER=$!
|
||||||
|
trap "kill $SERVER 2>/dev/null" EXIT
|
||||||
|
sleep 0.4
|
||||||
|
php setup/tests/test_flujos.php
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests unitarios de los flujos de carga, ejecutando NormalBot de verdad.
|
||||||
|
*
|
||||||
|
* A diferencia de test_navegacion.php (que espeja la lógica), acá corre
|
||||||
|
* process()/processInteractive() reales: el curl pega contra fixtures_api.php
|
||||||
|
* y los POST capturados se comparan contra el contrato del ERP.
|
||||||
|
*
|
||||||
|
* Uso: php setup/tests/run.sh (levanta el servidor de fixtures y corre esto)
|
||||||
|
*/
|
||||||
|
|
||||||
|
require __DIR__ . '/bootstrap.php';
|
||||||
|
|
||||||
|
$co = empresa();
|
||||||
|
|
||||||
|
// ════ 1. Finca restringida: una sola → entra directo ═════════════════════════
|
||||||
|
echo "\nFinca — alcance por número\n";
|
||||||
|
|
||||||
|
$ctx = contexto('57300RESTRINGIDO');
|
||||||
|
$r = NormalBot::process($co, $ctx, 'hola');
|
||||||
|
check('con una sola finca no pregunta: cae al menú',
|
||||||
|
str_contains(textoDe($r), '¿Qué deseas hacer?'));
|
||||||
|
$meta = ConversationContext::getMetadata(ConversationContext::getOrCreate(1, '57300RESTRINGIDO')['id']);
|
||||||
|
check('la finca quedó elegida sola', $meta['finca']['finca_id'] ?? null, '4');
|
||||||
|
check('con su nombre para el footer', $meta['finca']['finca_label'] ?? null, 'REPOSO');
|
||||||
|
$p = json_decode($r['payload'], true);
|
||||||
|
check('y el footer la muestra', $p['interactive']['footer']['text'] ?? '', '📍 REPOSO');
|
||||||
|
|
||||||
|
$ctx = contexto('57300LIBRE');
|
||||||
|
$r = NormalBot::process($co, $ctx, 'hola');
|
||||||
|
check('sin restricción sí pregunta, con "Todas" primero',
|
||||||
|
(string)array_key_first(filasDe($r)), '0');
|
||||||
|
capturas();
|
||||||
|
|
||||||
|
// ════ 2. Ausentismo completo ═════════════════════════════════════════════════
|
||||||
|
echo "\nAusentismo — de la primera pregunta al POST\n";
|
||||||
|
|
||||||
|
$ctx = contexto('57300AUSEN');
|
||||||
|
$r = NormalBot::processInteractive($co, $ctx, 'registrar_ausentismo');
|
||||||
|
check('arranca pidiendo el trabajador', str_contains(textoDe($r), 'nombre o documento'));
|
||||||
|
|
||||||
|
$r = NormalBot::process($co, $ctx, 'julio');
|
||||||
|
check('varios homónimos: ofrece elegir', str_contains(textoDe($r), 'coincidencias'));
|
||||||
|
check('con los tres del catálogo', count(filasDe($r)), 3);
|
||||||
|
|
||||||
|
$r = NormalBot::processInteractive($co, $ctx, '412');
|
||||||
|
$filas = filasDe($r);
|
||||||
|
check('sigue el tipo, no los 17 motivos de una',
|
||||||
|
array_keys($filas), ['Incapacidad', 'Permiso', 'Vacaciones']);
|
||||||
|
|
||||||
|
$r = NormalBot::processInteractive($co, $ctx, 'Incapacidad');
|
||||||
|
$filas = filasDe($r);
|
||||||
|
check('el motivo se acota al tipo elegido', array_map('strval', array_keys($filas)), ['46', '47']);
|
||||||
|
check('sin el prefijo del grupo, entra en los 24 de WhatsApp',
|
||||||
|
max(array_map('mb_strlen', $filas)) <= 24);
|
||||||
|
$p = json_decode($r['payload'], true);
|
||||||
|
$fila0 = $p['interactive']['action']['sections'][0]['rows'][0];
|
||||||
|
check('y el nombre completo va en la descripción',
|
||||||
|
$fila0['description'] ?? null, 'INCAPACIDAD ENFERMEDAD < 3 DIAS');
|
||||||
|
|
||||||
|
$r = NormalBot::processInteractive($co, $ctx, '46');
|
||||||
|
check('sigue la fecha inicial', str_contains(textoDe($r), 'Desde qué fecha'));
|
||||||
|
check('la fecha inicial sí mira hacia atrás',
|
||||||
|
isset(filasDe($r)[date('Y-m-d', strtotime('-1 day'))]));
|
||||||
|
|
||||||
|
$hoy = date('Y-m-d'); $ayer = date('Y-m-d', strtotime('-1 day'));
|
||||||
|
$manana = date('Y-m-d', strtotime('+1 day'));
|
||||||
|
$r = NormalBot::processInteractive($co, $ctx, $hoy);
|
||||||
|
check('sigue la fecha final', str_contains(textoDe($r), 'Hasta qué fecha'));
|
||||||
|
|
||||||
|
// Una novedad termina hoy o despues: los atajos van hacia adelante
|
||||||
|
$atajos = filasDe($r);
|
||||||
|
check('ofrece hoy, mañana y pasado — no ayer',
|
||||||
|
array_map('strval', array_keys($atajos)),
|
||||||
|
[$hoy, $manana, date('Y-m-d', strtotime('+2 days')), '__cap_other']);
|
||||||
|
check('sin atajos hacia atrás', !isset($atajos[$ayer]));
|
||||||
|
|
||||||
|
WhatsAppSender::$enviados = [];
|
||||||
|
$r = NormalBot::processInteractive($co, $ctx, $ayer);
|
||||||
|
check('rechaza el rango invertido',
|
||||||
|
str_contains(WhatsAppSender::$enviados[0]['text'] ?? '', 'no puede ser anterior'));
|
||||||
|
check('y vuelve a mostrar el selector de fecha', str_contains(textoDe($r), 'Hasta qué fecha'));
|
||||||
|
|
||||||
|
$r = NormalBot::processInteractive($co, $ctx, $hoy);
|
||||||
|
check('resumen con confirmación', isset(filasDe($r)['__cap_confirm']));
|
||||||
|
|
||||||
|
capturas(); // limpia GETs de catálogos
|
||||||
|
NormalBot::processInteractive($co, $ctx, '__cap_confirm');
|
||||||
|
$post = array_values(array_filter(capturas(), fn($c) => ($c['peticion'] ?? '') === 'ausentismos_up'))[0] ?? null;
|
||||||
|
check('el POST llegó al ERP', $post !== null);
|
||||||
|
check('con el contrato exacto del procesador',
|
||||||
|
[$post['body']['empleado_id'] ?? null, $post['body']['novedad_id'] ?? null,
|
||||||
|
$post['body']['fecha_inicial'] ?? null, $post['body']['fecha_final'] ?? null],
|
||||||
|
['412', '46', $hoy, $hoy]);
|
||||||
|
check('y la trazabilidad', $post['body']['telefono'] ?? null, '57300AUSEN');
|
||||||
|
|
||||||
|
// ════ 3. Ausentismo según el perfil del número ═══════════════════════════════
|
||||||
|
echo "\nPerfil — trabajador vinculado y supervisor\n";
|
||||||
|
|
||||||
|
fakePhone('57300TRABAJADOR', 412, 0);
|
||||||
|
fakePhone('57300JEFE', 412, 1);
|
||||||
|
|
||||||
|
$ctx = contexto('57300TRABAJADOR');
|
||||||
|
$r = NormalBot::processInteractive($co, $ctx, 'registrar_ausentismo');
|
||||||
|
check('el trabajador vinculado no se pregunta: arranca por el tipo',
|
||||||
|
str_contains(textoDe($r), 'tipo de ausentismo'));
|
||||||
|
|
||||||
|
$ctx = contexto('57300JEFE');
|
||||||
|
$r = NormalBot::processInteractive($co, $ctx, 'registrar_ausentismo');
|
||||||
|
check('el supervisor elige aunque tenga tercero',
|
||||||
|
str_contains(textoDe($r), 'nombre o documento'));
|
||||||
|
capturas();
|
||||||
|
|
||||||
|
// ════ 4. Ciclos: apertura con selección múltiple ═════════════════════════════
|
||||||
|
echo "\nCiclos — apertura de varios lotes\n";
|
||||||
|
|
||||||
|
$ctx = contexto('57300CICLOS');
|
||||||
|
$id = ConversationContext::getOrCreate(1, '57300CICLOS')['id'];
|
||||||
|
ConversationContext::updateMetadata($id, ['finca' => ['finca_id' => '4', 'finca_label' => 'REPOSO']]);
|
||||||
|
$r = NormalBot::processInteractive($co, $ctx, 'registrar_ciclo');
|
||||||
|
check('pregunta el tipo de ciclo', str_contains(textoDe($r), 'Qué ciclo'));
|
||||||
|
|
||||||
|
$r = NormalBot::processInteractive($co, $ctx, 'cosecha');
|
||||||
|
check('después la acción', isset(filasDe($r)['apertura']));
|
||||||
|
|
||||||
|
$r = NormalBot::processInteractive($co, $ctx, 'apertura');
|
||||||
|
$r = NormalBot::processInteractive($co, $ctx, $hoy);
|
||||||
|
$filas = filasDe($r);
|
||||||
|
check('lista de lotes con "Otro lote" y "Listo"',
|
||||||
|
isset($filas['__todos']) && isset($filas['__listo']));
|
||||||
|
check('solo lotes de la finca elegida, ninguno ajeno',
|
||||||
|
!isset($filas['99']) && !str_contains(implode(' ', $filas), 'ROSA BLANCA'));
|
||||||
|
|
||||||
|
$r = NormalBot::processInteractive($co, $ctx, '1');
|
||||||
|
check('marcar redibuja con el check', str_contains(filasDe($r)['1'] ?? '', '✅'));
|
||||||
|
$r = NormalBot::processInteractive($co, $ctx, '2');
|
||||||
|
check('el contador acumula', str_contains(filasDe($r)['__listo'] ?? '', '(2)'));
|
||||||
|
|
||||||
|
$r = NormalBot::processInteractive($co, $ctx, '__todos');
|
||||||
|
check('"Otro lote" pasa al catálogo completo, paginado', isset(filasDe($r)['__mas']));
|
||||||
|
check('sin perder lo marcado', str_contains(filasDe($r)['1'] ?? '', '✅'));
|
||||||
|
$r = NormalBot::processInteractive($co, $ctx, '__mas');
|
||||||
|
check('y la página 2 alcanza el lote 12', isset(filasDe($r)['12']));
|
||||||
|
$r = NormalBot::processInteractive($co, $ctx, '__mas'); // vuelve: no hay página 3
|
||||||
|
$r = NormalBot::processInteractive($co, $ctx, '__mas');
|
||||||
|
|
||||||
|
// Escribir varios de una: seis toques eran seis mensajes
|
||||||
|
$r = NormalBot::process($co, $ctx, '3a, 4a 5a');
|
||||||
|
$filas = filasDe($r);
|
||||||
|
check('escribir marca varios en un solo mensaje',
|
||||||
|
str_contains($filas['__listo'] ?? '', '(5)'));
|
||||||
|
// El contador no depende de la pagina que se este viendo
|
||||||
|
check('suma a lo tocado en vez de reemplazarlo: 2 + 3 = 5',
|
||||||
|
str_contains($filas['__listo'] ?? '', '(5)'));
|
||||||
|
|
||||||
|
WhatsAppSender::$enviados = [];
|
||||||
|
$r = NormalBot::process($co, $ctx, '6a, 9z9z');
|
||||||
|
check('lo que no reconoce lo avisa, no lo ignora',
|
||||||
|
str_contains(WhatsAppSender::$enviados[0]['text'] ?? '', '"9z9z"'));
|
||||||
|
check('y marca igual lo que sí entendió',
|
||||||
|
str_contains(filasDe($r)['__listo'] ?? '', '(6)'));
|
||||||
|
|
||||||
|
$r = NormalBot::processInteractive($co, $ctx, '__listo');
|
||||||
|
check('resumen final', isset(filasDe($r)['__cap_confirm']));
|
||||||
|
|
||||||
|
capturas();
|
||||||
|
NormalBot::processInteractive($co, $ctx, '__cap_confirm');
|
||||||
|
$post = array_values(array_filter(capturas(), fn($c) => str_contains($c['peticion'] ?? '', 'ciclos_')))[0] ?? null;
|
||||||
|
check('la URL resolvió el tipo elegido', $post['peticion'] ?? null, 'ciclos_cosecha_up');
|
||||||
|
check('lote_ids lleva los tocados y los escritos',
|
||||||
|
$post['body']['lote_ids'] ?? null, ['1', '2', '3', '4', '5', '6']);
|
||||||
|
check('con la acción y la fecha',
|
||||||
|
[$post['body']['accion'] ?? null, $post['body']['fecha'] ?? null], ['apertura', $hoy]);
|
||||||
|
|
||||||
|
// ════ 5. Mantenimiento: requires/resolver + payload completo ═════════════════
|
||||||
|
echo "\nMantenimiento — el grupo se pide primero\n";
|
||||||
|
|
||||||
|
$ctx = contexto('57300MANT');
|
||||||
|
$r = NormalBot::processInteractive($co, $ctx, 'registrar_mantenimiento');
|
||||||
|
check('sin grupo elegido desvía al selector', isset(filasDe($r)['5']));
|
||||||
|
|
||||||
|
$r = NormalBot::processInteractive($co, $ctx, '5');
|
||||||
|
check('y vuelve al flujo: pide la fecha', str_contains(textoDe($r), 'De qué fecha'));
|
||||||
|
|
||||||
|
$r = NormalBot::processInteractive($co, $ctx, $hoy);
|
||||||
|
check('el lote muestra la cantidad faltante', str_contains(filasDe($r)['4'] ?? '', 'falta 12'));
|
||||||
|
|
||||||
|
$r = NormalBot::processInteractive($co, $ctx, '4');
|
||||||
|
check('la labor sale del grupo elegido', filasDe($r), ['77' => 'PLATEO MANUAL']);
|
||||||
|
|
||||||
|
$r = NormalBot::processInteractive($co, $ctx, '77');
|
||||||
|
$r = NormalBot::processInteractive($co, $ctx, '412');
|
||||||
|
$r = NormalBot::processInteractive($co, $ctx, '__listo');
|
||||||
|
$r = NormalBot::process($co, $ctx, '12');
|
||||||
|
check('resumen', isset(filasDe($r)['__cap_confirm']));
|
||||||
|
|
||||||
|
capturas();
|
||||||
|
NormalBot::processInteractive($co, $ctx, '__cap_confirm');
|
||||||
|
$post = array_values(array_filter(capturas(), fn($c) => ($c['peticion'] ?? '') === 'labores_up'))[0] ?? null;
|
||||||
|
check('postea todo lo que labores_up exige',
|
||||||
|
[$post['body']['novedad_id'] ?? null, $post['body']['lote_id'] ?? null,
|
||||||
|
$post['body']['empleados'] ?? null, $post['body']['cantidad'] ?? null],
|
||||||
|
['77', '4', ['412'], '12']);
|
||||||
|
|
||||||
|
// ════ 6. Labores fase 2 ═══════════════════════════════════════════════════════
|
||||||
|
echo "\nLabores diarias — cuadrilla completa\n";
|
||||||
|
|
||||||
|
$ctx = contexto('57300LABOR');
|
||||||
|
$id = ConversationContext::getOrCreate(1, '57300LABOR')['id'];
|
||||||
|
ConversationContext::updateMetadata($id, ['finca' => ['finca_id' => '4', 'finca_label' => 'REPOSO']]);
|
||||||
|
|
||||||
|
$r = NormalBot::processInteractive($co, $ctx, 'registrar_labor');
|
||||||
|
$r = NormalBot::processInteractive($co, $ctx, $hoy);
|
||||||
|
check('el catálogo viene filtrado por fase', filasDe($r), ['42' => 'TRACTORISTA', '43' => 'HORAS EXTRA']);
|
||||||
|
|
||||||
|
$r = NormalBot::processInteractive($co, $ctx, '42');
|
||||||
|
check('los lotes son los de su finca', isset(filasDe($r)['4']) && isset(filasDe($r)['9']));
|
||||||
|
check('sin lotes de otras fincas', !isset(filasDe($r)['99']));
|
||||||
|
|
||||||
|
$r = NormalBot::processInteractive($co, $ctx, '4');
|
||||||
|
$r = NormalBot::processInteractive($co, $ctx, '412');
|
||||||
|
$r = NormalBot::processInteractive($co, $ctx, '415');
|
||||||
|
$r = NormalBot::processInteractive($co, $ctx, '__listo');
|
||||||
|
$r = NormalBot::process($co, $ctx, '1');
|
||||||
|
capturas();
|
||||||
|
NormalBot::processInteractive($co, $ctx, '__cap_confirm');
|
||||||
|
$post = array_values(array_filter(capturas(), fn($c) => ($c['peticion'] ?? '') === 'labores_up'))[0] ?? null;
|
||||||
|
check('la cuadrilla viaja como arreglo', $post['body']['empleados'] ?? null, ['412', '415']);
|
||||||
|
|
||||||
|
// ════ 7. NLU: entity resuelve el grupo y ejecuta el informe ═══════════════════
|
||||||
|
echo "\nNLU — \"informe de mantenimiento de plateo\" en un mensaje\n";
|
||||||
|
|
||||||
|
$ctx = contexto('57300NLU');
|
||||||
|
$id = ConversationContext::getOrCreate(1, '57300NLU')['id'];
|
||||||
|
ConversationContext::updateMetadata($id, ['finca' => ['finca_id' => '4', 'finca_label' => 'REPOSO']]);
|
||||||
|
ConversationContext::updateNode($id, '__greeted');
|
||||||
|
|
||||||
|
AiBot::$respuestas = [[
|
||||||
|
'action' => 'route', 'key' => 'ciclo_mantenimiento_top',
|
||||||
|
'entities' => ['grupo' => 'plateo'],
|
||||||
|
]];
|
||||||
|
capturas();
|
||||||
|
$r = NormalBot::process($co, $ctx, 'quiero informe de mantenimiento de plateo');
|
||||||
|
check('el informe llegó sin menús intermedios', str_contains(textoDe($r), 'informe-de-prueba'));
|
||||||
|
|
||||||
|
$get = array_values(array_filter(capturas(), fn($c) => str_contains($c['peticion'] ?? '', 'mantenimiento_top')))[0] ?? null;
|
||||||
|
check('la entity resolvió el grupo en la URL', $get['query']['grupo'] ?? null, '5');
|
||||||
|
check('y la consulta dice quién pregunta', $get['query']['wa'] ?? null, '57300NLU');
|
||||||
|
|
||||||
|
// ════ 8. NLU coherente con los módulos del perfil ═════════════════════════════
|
||||||
|
echo "\nNLU — respeta los módulos habilitados\n";
|
||||||
|
|
||||||
|
// Este número solo puede cargar pluviometría
|
||||||
|
fakePhone('57300SOLOPLUVIO', null, 0);
|
||||||
|
FakeDb::$phones['57300SOLOPLUVIO']['modulos_json'] = json_encode(['carga' => ['pluviometria']]);
|
||||||
|
|
||||||
|
$ctx = contexto('57300SOLOPLUVIO');
|
||||||
|
$id = ConversationContext::getOrCreate(1, '57300SOLOPLUVIO')['id'];
|
||||||
|
ConversationContext::updateMetadata($id, ['finca' => ['finca_id' => '4', 'finca_label' => 'REPOSO']]);
|
||||||
|
ConversationContext::updateNode($id, '__greeted');
|
||||||
|
|
||||||
|
// "subir pluviometría" → directo a la fecha, un solo mensaje
|
||||||
|
AiBot::$respuestas = [['action' => 'route', 'key' => 'registrar_pluviometria']];
|
||||||
|
$r = NormalBot::process($co, $ctx, 'subir pluviometría');
|
||||||
|
check('"subir pluviometría" va de una a la fecha', str_contains(textoDe($r), 'De qué fecha'));
|
||||||
|
check('el NLU recibió su alcance de módulos', AiBot::$modulosRecibidos, ['pluviometria']);
|
||||||
|
|
||||||
|
// El modelo devuelve una clave fuera del alcance: el guard la corta
|
||||||
|
ConversationContext::updateNode($id, '__greeted');
|
||||||
|
ConversationContext::updateMetadata($id, ['finca' => ['finca_id' => '4', 'finca_label' => 'REPOSO']]);
|
||||||
|
WhatsAppSender::$enviados = [];
|
||||||
|
AiBot::$respuestas = [['action' => 'route', 'key' => 'registrar_ausentismo']];
|
||||||
|
$r = NormalBot::process($co, $ctx, 'registrar ausentismo');
|
||||||
|
check('un módulo vetado no ejecuta el flujo', !str_contains(textoDe($r), 'nombre o documento'));
|
||||||
|
check('avisa que no entendió',
|
||||||
|
str_contains(WhatsAppSender::$enviados[0]['text'] ?? '', 'No estoy seguro'));
|
||||||
|
|
||||||
|
// Sin restricción, "subir labores diarias" también entra directo
|
||||||
|
$ctx = contexto('57300LABORNLU');
|
||||||
|
$id = ConversationContext::getOrCreate(1, '57300LABORNLU')['id'];
|
||||||
|
ConversationContext::updateMetadata($id, ['finca' => ['finca_id' => '4', 'finca_label' => 'REPOSO']]);
|
||||||
|
ConversationContext::updateNode($id, '__greeted');
|
||||||
|
AiBot::$respuestas = [['action' => 'route', 'key' => 'registrar_labor']];
|
||||||
|
$r = NormalBot::process($co, $ctx, 'subir labores diarias');
|
||||||
|
check('"subir labores diarias" arranca el flujo directo', str_contains(textoDe($r), 'De qué fecha es la labor'));
|
||||||
|
capturas();
|
||||||
|
|
||||||
|
// ════ 9. Editar desde el resumen ══════════════════════════════════════════════
|
||||||
|
echo "\nEditar — corregir sin rehacer el formulario\n";
|
||||||
|
|
||||||
|
$ctx = contexto('57300EDITAR');
|
||||||
|
NormalBot::processInteractive($co, $ctx, 'registrar_ausentismo');
|
||||||
|
NormalBot::process($co, $ctx, 'julio');
|
||||||
|
NormalBot::processInteractive($co, $ctx, '412');
|
||||||
|
NormalBot::processInteractive($co, $ctx, 'Incapacidad');
|
||||||
|
NormalBot::processInteractive($co, $ctx, '46');
|
||||||
|
NormalBot::processInteractive($co, $ctx, $hoy);
|
||||||
|
$r = NormalBot::processInteractive($co, $ctx, $manana);
|
||||||
|
check('el resumen ofrece editar', isset(filasDe($r)['__cap_edit']));
|
||||||
|
|
||||||
|
$r = NormalBot::processInteractive($co, $ctx, '__cap_edit');
|
||||||
|
$campos = filasDe($r);
|
||||||
|
check('lista los campos ya cargados',
|
||||||
|
array_keys($campos),
|
||||||
|
['__cap_ed:empleado_id', '__cap_ed:grupo_novedad', '__cap_ed:novedad_id',
|
||||||
|
'__cap_ed:fecha_inicial', '__cap_ed:fecha_final', '__cap_ed_volver']);
|
||||||
|
|
||||||
|
// Un campo hoja: se corrige y vuelve derecho al resumen
|
||||||
|
$r = NormalBot::processInteractive($co, $ctx, '__cap_ed:fecha_final');
|
||||||
|
check('pregunta solo ese campo', str_contains(textoDe($r), 'Hasta qué fecha'));
|
||||||
|
$pasado = date('Y-m-d', strtotime('+2 days'));
|
||||||
|
$r = NormalBot::processInteractive($co, $ctx, $pasado);
|
||||||
|
check('y vuelve al resumen, no al siguiente campo', isset(filasDe($r)['__cap_confirm']));
|
||||||
|
check('con el valor corregido', str_contains(textoDe($r), $pasado));
|
||||||
|
|
||||||
|
// Un campo del que otro depende: se re-pregunta en cadena
|
||||||
|
$r = NormalBot::processInteractive($co, $ctx, '__cap_edit');
|
||||||
|
$r = NormalBot::processInteractive($co, $ctx, '__cap_ed:grupo_novedad');
|
||||||
|
check('editar el tipo vuelve a preguntar el tipo', str_contains(textoDe($r), 'tipo de ausentismo'));
|
||||||
|
$r = NormalBot::processInteractive($co, $ctx, 'Permiso');
|
||||||
|
check('y encadena el motivo, que quedó inválido', str_contains(textoDe($r), 'Cuál es el motivo'));
|
||||||
|
check('acotado al tipo nuevo', array_map('strval', array_keys(filasDe($r))), ['50']);
|
||||||
|
$r = NormalBot::processInteractive($co, $ctx, '50');
|
||||||
|
check('recién ahí vuelve al resumen', isset(filasDe($r)['__cap_confirm']));
|
||||||
|
|
||||||
|
// Salir sin tocar nada
|
||||||
|
$r = NormalBot::processInteractive($co, $ctx, '__cap_edit');
|
||||||
|
$r = NormalBot::processInteractive($co, $ctx, '__cap_ed_volver');
|
||||||
|
check('"Volver" deja todo como estaba', isset(filasDe($r)['__cap_confirm']));
|
||||||
|
|
||||||
|
capturas();
|
||||||
|
NormalBot::processInteractive($co, $ctx, '__cap_confirm');
|
||||||
|
$post = array_values(array_filter(capturas(), fn($c) => ($c['peticion'] ?? '') === 'ausentismos_up'))[0] ?? null;
|
||||||
|
check('el POST lleva lo editado, sin restos del valor viejo',
|
||||||
|
[$post['body']['novedad_id'] ?? null, $post['body']['fecha_final'] ?? null],
|
||||||
|
['50', $pasado]);
|
||||||
|
|
||||||
|
// ════ Resultado ═══════════════════════════════════════════════════════════════
|
||||||
|
echo "\n" . ($GLOBALS['fallas'] ? "{$GLOBALS['fallas']} falla(s)\n" : "Todos los flujos ejecutan de punta a punta\n");
|
||||||
|
exit($GLOBALS['fallas'] ? 1 : 0);
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valida el grafo de navegación de seed_palmas.php sin tocar la base de datos.
|
||||||
|
* Un 'back', 'resolver' o id de botón que apunte a un flow inexistente deja al
|
||||||
|
* usuario en un callejón sin salida, y eso no se nota hasta que alguien lo pisa.
|
||||||
|
*
|
||||||
|
* Uso: php setup/validate_config.php
|
||||||
|
*/
|
||||||
|
|
||||||
|
$seed = file_get_contents(__DIR__ . '/seed_palmas.php');
|
||||||
|
$ini = strpos($seed, '$configJson = [');
|
||||||
|
if ($ini === false) { fwrite(STDERR, "No se encontró \$configJson\n"); exit(1); }
|
||||||
|
|
||||||
|
// Recorte por balance de corchetes desde la apertura del array
|
||||||
|
$open = strpos($seed, '[', $ini);
|
||||||
|
$depth = 0; $end = $open;
|
||||||
|
for ($i = $open, $n = strlen($seed); $i < $n; $i++) {
|
||||||
|
if ($seed[$i] === '[') $depth++;
|
||||||
|
elseif ($seed[$i] === ']') { $depth--; if ($depth === 0) { $end = $i; break; } }
|
||||||
|
}
|
||||||
|
$config = eval('return ' . substr($seed, $open, $end - $open + 1) . ';');
|
||||||
|
|
||||||
|
$errores = [];
|
||||||
|
$avisos = [];
|
||||||
|
|
||||||
|
// Menús raíz: no suben a ningún lado, es correcto que no tengan 'back'
|
||||||
|
$esRaiz = fn(string $k): bool => $k === 'show_main_menu' || str_starts_with($k, 'show_menu_');
|
||||||
|
|
||||||
|
$categorias = ['global' => []] + ($config['per_type'] ?? []);
|
||||||
|
foreach ($categorias as $cat => $pt) {
|
||||||
|
$flows = array_merge($config['flows'] ?? [], $pt['flows'] ?? []);
|
||||||
|
$menus = array_merge($config['menus'] ?? [], $pt['menus'] ?? []);
|
||||||
|
$cmds = array_merge($config['commands'] ?? [], $pt['commands'] ?? []);
|
||||||
|
$donde = "cat {$cat}";
|
||||||
|
|
||||||
|
$existe = fn(string $k): bool => isset($flows[$k]) || isset($menus[$k]);
|
||||||
|
|
||||||
|
foreach ($cmds as $palabra => $destino) {
|
||||||
|
if ($destino === '__back') continue; // se resuelve en runtime
|
||||||
|
if (!$existe($destino)) $errores[] = "{$donde}: comando '{$palabra}' → '{$destino}' no existe";
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($flows as $key => $flow) {
|
||||||
|
foreach (['back', 'resolver', 'next_node'] as $campo) {
|
||||||
|
$destino = $flow[$campo] ?? null;
|
||||||
|
if ($destino !== null && !$existe($destino)) {
|
||||||
|
$errores[] = "{$donde}: flow '{$key}'.{$campo} → '{$destino}' no existe";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!empty($flow['requires']) && empty($flow['resolver'])) {
|
||||||
|
$errores[] = "{$donde}: flow '{$key}' declara requires sin resolver";
|
||||||
|
}
|
||||||
|
if (($flow['type'] ?? '') === 'menu' && !isset($menus[$flow['menu'] ?? ''])) {
|
||||||
|
$errores[] = "{$donde}: flow '{$key}' apunta al menú '{$flow['menu']}' que no existe";
|
||||||
|
}
|
||||||
|
if (($flow['type'] ?? '') === 'menu' && !isset($flow['back']) && !$esRaiz($key)) {
|
||||||
|
$avisos[] = "{$donde}: flow '{$key}' es menú y no define 'back'";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cada id de botón/fila debe resolver a un flow
|
||||||
|
foreach ($menus as $mKey => $menu) {
|
||||||
|
$ids = array_column($menu['buttons'] ?? [], 'id');
|
||||||
|
foreach ($menu['sections'] ?? [] as $sec) {
|
||||||
|
$ids = array_merge($ids, array_column($sec['rows'] ?? [], 'id'));
|
||||||
|
}
|
||||||
|
foreach ($ids as $id) {
|
||||||
|
if (!$existe($id)) $errores[] = "{$donde}: menú '{$mKey}' tiene opción '{$id}' sin flow";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Endpoints referenciados por los flows
|
||||||
|
preg_match_all("/'key'\s*=>\s*'([^']+)'/", substr($seed, $end), $m);
|
||||||
|
$endpoints = array_flip($m[1]);
|
||||||
|
foreach ($config['flows'] ?? [] as $key => $flow) {
|
||||||
|
foreach (['source_endpoint_key', 'source_endpoint_key_all'] as $campo) {
|
||||||
|
$ep = $flow[$campo] ?? null;
|
||||||
|
if ($ep !== null && !isset($endpoints[$ep])) $errores[] = "flow '{$key}'.{$campo} → endpoint '{$ep}' no registrado";
|
||||||
|
}
|
||||||
|
$ep = $flow['params']['endpoint_key'] ?? null;
|
||||||
|
if ($ep !== null && !isset($endpoints[$ep])) $errores[] = "flow '{$key}' → endpoint '{$ep}' no registrado";
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (array_unique($avisos) as $a) echo "aviso: {$a}\n";
|
||||||
|
foreach (array_unique($errores) as $e) echo "ERROR: {$e}\n";
|
||||||
|
|
||||||
|
if ($errores) { echo "\n" . count(array_unique($errores)) . " error(es)\n"; exit(1); }
|
||||||
|
echo "\nOK — grafo de navegación consistente\n";
|
||||||
Reference in New Issue
Block a user