Compare commits
90
Commits
497743ceb1
...
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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+116
-25
@@ -3170,6 +3170,9 @@ HTML;
|
||||
$aiModel35 = $aiModelVal === 'gpt-3.5-turbo'? ' selected' : '';
|
||||
$geminiApiKeyVal = self::h($config['gemini_api_key'] ?? '');
|
||||
$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' : '';
|
||||
$geminiModelF15 = $geminiModelVal === 'gemini-1.5-flash' ? ' selected' : '';
|
||||
@@ -3190,28 +3193,81 @@ HTML;
|
||||
// Per-category config HTML
|
||||
$perTypeConfig = $config['per_type'] ?? [];
|
||||
$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 = '';
|
||||
// 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) {
|
||||
$catCfg = $perTypeConfig[(string)$cat] ?? [];
|
||||
$selMenu = $catCfg['greeting_menu'] ?? '';
|
||||
$opts = '<option value="">— Usa configuración global —</option>';
|
||||
foreach ($menuKeysList as $mk) {
|
||||
$s = $selMenu === $mk ? ' selected' : '';
|
||||
$opts .= '<option value="' . self::h($mk) . '"' . $s . '>' . self::h($mk) . '</option>';
|
||||
}
|
||||
$label = $catLabels[(string)$cat];
|
||||
$catCfg = $perTypeConfig[(string)$cat] ?? [];
|
||||
$m = $catMeta[(string)$cat];
|
||||
$greetFlow = self::h($catCfg['greeting_flow'] ?? '');
|
||||
$fallback = $catCfg['fallback_flow'] ?? '';
|
||||
$selMenu = $catCfg['greeting_menu'] ?? '';
|
||||
|
||||
// greeting_menu selector (insert selected attr)
|
||||
$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
|
||||
<div style="background:#f8f9fd;border:1px solid #eef1f5;border-radius:10px;padding:18px;margin-bottom:16px">
|
||||
<div style="font-weight:700;font-size:13px;color:#0b3d91;margin-bottom:12px">{$label}</div>
|
||||
<div class="form-group" style="margin-bottom:0">
|
||||
<label>Menú de bienvenida (greeting menu)</label>
|
||||
<select name="per_type_menu[{$cat}]" style="width:100%;padding:9px 12px;border:1px solid #e2e5ea;border-radius:8px;font-size:13px">
|
||||
{$opts}
|
||||
</select>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div style="background:{$bg};border:1px solid {$bd};border-radius:10px;padding:18px;margin-bottom:16px">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:14px">
|
||||
<span style="font-size:20px">{$icon}</span>
|
||||
<span style="font-weight:700;font-size:13px;color:{$clr}">{$label}</span>
|
||||
</div>
|
||||
<div class="form-group" style="{$greetFlowStyle};margin-bottom:12px">
|
||||
<label>Flujo de bienvenida</label>
|
||||
<input type="text" name="per_type_greeting_flow[{$cat}]" value="{$greetFlow}"
|
||||
list="ptFlowsDatalist" placeholder="ej: ask_finca (se auto-selecciona si hay 1 sola opción)"
|
||||
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;
|
||||
}
|
||||
$aiModelMini = $aiModelVal === 'gpt-4o-mini' ? ' selected' : '';
|
||||
@@ -3640,7 +3696,13 @@ HTML;
|
||||
<!-- ─── POR CATEGORÍA ────────────────────────────────────────────────── -->
|
||||
<div class="tab-content" id="tab-categories">
|
||||
<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}
|
||||
<button type="button" id="savePerTypeBtn" onclick="savePerType()" class="btn-primary" style="margin-top:8px">💾 Guardar categorías</button>
|
||||
</div>
|
||||
@@ -3717,6 +3779,26 @@ HTML;
|
||||
</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-group">
|
||||
<label>Temperatura ({$aiTempVal})</label>
|
||||
@@ -3828,8 +3910,12 @@ function savePerType() {
|
||||
const fd = new FormData();
|
||||
fd.set('company_id', cid);
|
||||
[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 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 (fb) fd.append('per_type_fallback[' + cat + ']', fb.value);
|
||||
});
|
||||
var btn = document.getElementById('savePerTypeBtn');
|
||||
if (btn) { btn.textContent = 'Guardando...'; btn.disabled = true; }
|
||||
@@ -3862,6 +3948,10 @@ function saveAiConfig() {
|
||||
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');
|
||||
|
||||
@@ -5729,7 +5819,7 @@ HTML;
|
||||
}
|
||||
|
||||
$providerOptions = '';
|
||||
foreach (['', 'openai', 'gemini', 'claude'] as $p) {
|
||||
foreach (['', 'openai', 'gemini', 'claude', 'whisper-own'] as $p) {
|
||||
$label = $p === '' ? 'Todos' : ucfirst($p);
|
||||
$sel = $provider === $p ? ' selected' : '';
|
||||
$providerOptions .= "<option value=\"{$p}\"{$sel}>{$label}</option>";
|
||||
@@ -5740,10 +5830,11 @@ HTML;
|
||||
$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',
|
||||
default => 'background:#6b7280;color:#fff',
|
||||
'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'])
|
||||
|
||||
+11
-4
@@ -187,7 +187,7 @@ class WpWebhook
|
||||
}
|
||||
|
||||
// Pre-menú multi-empresa: retorna true si la empresa quedó resuelta, false si hay que esperar al usuario
|
||||
private static function handleMultiCompany(string $from, string $name, array $companies, string $rawText, string $phoneNumberId): bool
|
||||
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_')) {
|
||||
@@ -233,7 +233,8 @@ class WpWebhook
|
||||
}
|
||||
|
||||
// 2. Keyword de reinicio → borrar sesión y mostrar pre-menú
|
||||
if (self::isResetKeyword($rawText)) {
|
||||
// Solo aplica para texto libre; las respuestas interactivas no son keywords
|
||||
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;
|
||||
@@ -307,11 +308,17 @@ class WpWebhook
|
||||
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, ['salir','inicio','menu','reiniciar','volver','reset','0','atras','regresar'], true);
|
||||
return in_array($n, ['cambiar empresa', 'cambiar de empresa', 'empresas', 'reiniciar', 'reset'], true);
|
||||
}
|
||||
|
||||
private static function getPermissionType(array $companies, int $companyId): int
|
||||
@@ -418,7 +425,7 @@ class WpWebhook
|
||||
'button' => $msg['button']['payload'] ?? $msg['button']['text'] ?? '',
|
||||
default => '',
|
||||
};
|
||||
if (!self::handleMultiCompany($from, $name, $matchedCompanies, $rawText, $phoneNumberId)) {
|
||||
if (!self::handleMultiCompany($from, $name, $matchedCompanies, $rawText, $phoneNumberId, $type)) {
|
||||
continue;
|
||||
}
|
||||
self::log('INFO', "Número {$from} → empresa (multi-sel): " . (self::$currentCompany['name'] ?? '?'));
|
||||
|
||||
@@ -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";
|
||||
+43
-9
@@ -1,6 +1,8 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
date_default_timezone_set('America/Bogota');
|
||||
|
||||
require_once __DIR__ . '/../config/env.php';
|
||||
require_once __DIR__ . '/../config/db.php';
|
||||
require_once __DIR__ . '/../services/Settings.php';
|
||||
@@ -634,6 +636,13 @@ $routes = [
|
||||
$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;
|
||||
@@ -651,22 +660,47 @@ $routes = [
|
||||
|
||||
$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)
|
||||
? $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) {
|
||||
$mk = trim($perTypeMenus[$cat] ?? '');
|
||||
$key = (string)$cat;
|
||||
if ($mk !== '') {
|
||||
$perType[$key] = array_merge($perType[$key] ?? [], ['greeting_menu' => $mk]);
|
||||
$key = (string)$cat;
|
||||
$current = $perType[$key] ?? []; // preserve menus/flows/commands from seed
|
||||
|
||||
$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 {
|
||||
unset($perType[$key]['greeting_menu']);
|
||||
if (empty($perType[$key])) unset($perType[$key]);
|
||||
// Sin greeting_flow → usa greeting_menu (cat 1)
|
||||
$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)]);
|
||||
echo json_encode(['ok' => true]);
|
||||
|
||||
+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);
|
||||
$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'];
|
||||
foreach ($preserveKeys as $key) {
|
||||
if (array_key_exists($key, $existing)) {
|
||||
$menuConfig[$key] = $existing[$key];
|
||||
// Partir del config existente para preservar TODO lo custom (nlu, whisper_*, gemini_*, etc.)
|
||||
$merged = $existing;
|
||||
|
||||
// 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'])) {
|
||||
$menuConfig['greeting'] = '¡Bienvenido! Escribe *menu* para ver las opciones disponibles.';
|
||||
// Aplicar defaults solo si no existen
|
||||
if (!isset($merged['greeting'])) {
|
||||
$merged['greeting'] = '¡Bienvenido! Escribe *menu* para ver las opciones disponibles.';
|
||||
}
|
||||
if (!isset($menuConfig['fallback'])) {
|
||||
$menuConfig['fallback'] = 'No entendí. Escribe *menu* para ver las opciones disponibles.';
|
||||
if (!isset($merged['fallback'])) {
|
||||
$merged['fallback'] = 'No entendí. Escribe *menu* para ver las opciones disponibles.';
|
||||
}
|
||||
|
||||
CompanyRepository::save([
|
||||
'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'];
|
||||
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++;
|
||||
}
|
||||
|
||||
|
||||
+74
-11
@@ -334,11 +334,11 @@ PROMPT;
|
||||
* 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
|
||||
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);
|
||||
$catalog = self::buildFlowCatalog($config, $permType, $modulosPermitidos);
|
||||
|
||||
if (empty($catalog)) {
|
||||
return ['action' => 'chat', 'text' => ''];
|
||||
@@ -360,24 +360,52 @@ PROMPT;
|
||||
$json = json_decode($clean, true);
|
||||
|
||||
if (!is_array($json) || !isset($json['action'])) {
|
||||
return ['action' => 'chat', 'text' => $clean];
|
||||
// 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'])) {
|
||||
// Validate key exists in flows
|
||||
$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']];
|
||||
return [
|
||||
'action' => 'route',
|
||||
'key' => $json['key'],
|
||||
'entities' => is_array($json['entities'] ?? null) ? $json['entities'] : [],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return ['action' => 'chat', 'text' => $json['text'] ?? $clean];
|
||||
return ['action' => 'chat', 'text' => self::rescatarTexto((string)($json['text'] ?? $clean))];
|
||||
}
|
||||
|
||||
private static function buildFlowCatalog(array $config, int $permType): array
|
||||
/**
|
||||
* 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'] ?? [];
|
||||
@@ -400,14 +428,31 @@ PROMPT;
|
||||
$type = $flow['type'] ?? 'text';
|
||||
$fn = $flow['function'] ?? '';
|
||||
|
||||
$isUpload = in_array($type, ['collect_and_post', 'collect_for_each', 'submit_form'], true);
|
||||
$isDownload = ($type === 'function' && $fn === 'api_report');
|
||||
// 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;
|
||||
@@ -439,8 +484,26 @@ PROMPT;
|
||||
. "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>\"}\n\n"
|
||||
. "Si no está claro o no hay opción correspondiente → responde SOLO:\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.";
|
||||
}
|
||||
|
||||
+27
-6
@@ -125,13 +125,20 @@ class BotRouter
|
||||
|
||||
if ($text !== null && trim($text) !== '') {
|
||||
self::log("NLU media [{$inputType}]: texto extraído → \"{$text}\"");
|
||||
// Confirmar al usuario lo que entendió
|
||||
$prefix = $inputType === 'audio' ? '🎤 Transcripción' : '🖼️ Imagen analizada';
|
||||
$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);
|
||||
}
|
||||
}
|
||||
@@ -155,16 +162,30 @@ class BotRouter
|
||||
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');
|
||||
ConversationContext::updateNode((int)$botCtx['id'], $key);
|
||||
$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}]");
|
||||
// Ejecutar con input vacío — NormalBot retoma current_node
|
||||
return NormalBot::process($company, $context, '');
|
||||
}
|
||||
|
||||
|
||||
+19
-3
@@ -17,9 +17,24 @@ class ErpMonitor
|
||||
|
||||
public static function check(array $company): array
|
||||
{
|
||||
$baseUrl = rtrim($company['api_base_url'] ?? '', '/');
|
||||
$healthUrl = $baseUrl !== '' ? $baseUrl . '/health' : '';
|
||||
// El ERP no expone /health sino ?peticion=ping, y la URL sale de
|
||||
// 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'] ?? '';
|
||||
$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 === '') {
|
||||
return [
|
||||
@@ -27,7 +42,7 @@ class ErpMonitor
|
||||
'company_name' => $company['name'] ?? '',
|
||||
'status' => 'unknown',
|
||||
'latency_ms' => null,
|
||||
'error' => 'Sin URL configurada',
|
||||
'error' => 'Sin endpoint registrado para esta empresa',
|
||||
'last_check' => date('Y-m-d H:i:s'),
|
||||
];
|
||||
}
|
||||
@@ -40,6 +55,7 @@ class ErpMonitor
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
CURLOPT_CONNECTTIMEOUT => 5,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Authorization: Bearer ' . $apiKey,
|
||||
'X-API-Key: ' . $apiKey,
|
||||
'User-Agent: bot-palmas360-monitor/1.0',
|
||||
],
|
||||
|
||||
@@ -60,6 +60,11 @@ class MediaTranscriber
|
||||
$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),
|
||||
@@ -69,13 +74,51 @@ class MediaTranscriber
|
||||
|
||||
private static function whisper(string $bytes, string $mime, array $cfg): ?string
|
||||
{
|
||||
$apiKey = $cfg['openai_api_key'] ?? env('OPENAI_API_KEY', '');
|
||||
if ($apiKey === '') return null;
|
||||
|
||||
$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, [
|
||||
|
||||
+1322
-71
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);
|
||||
@@ -234,8 +234,11 @@ $db->exec("
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
company_id INT NOT NULL,
|
||||
endpoint_key VARCHAR(50) NOT NULL,
|
||||
name VARCHAR(100) DEFAULT '',
|
||||
params TEXT DEFAULT NULL,
|
||||
direction ENUM('upload','download') NOT NULL,
|
||||
url VARCHAR(500) DEFAULT '',
|
||||
body_fields TEXT DEFAULT NULL,
|
||||
method VARCHAR(10) DEFAULT 'GET',
|
||||
last_response TEXT,
|
||||
last_called_at DATETIME,
|
||||
@@ -246,6 +249,15 @@ $db->exec("
|
||||
) 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,
|
||||
|
||||
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