Compare commits
88
Commits
fc54ec992b
...
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}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -3193,28 +3193,81 @@ HTML;
|
|||||||
// Per-category config HTML
|
// Per-category config HTML
|
||||||
$perTypeConfig = $config['per_type'] ?? [];
|
$perTypeConfig = $config['per_type'] ?? [];
|
||||||
$menuKeysList = array_keys($menus);
|
$menuKeysList = array_keys($menus);
|
||||||
$catLabels = ['1' => 'Categoría 1 — Solo reporta', '2' => 'Categoría 2 — Solo recibe informes', '3' => 'Categoría 3 — Reporta y recibe'];
|
$flowKeysList = array_keys($config['flows'] ?? []);
|
||||||
|
// Include per_type menus/flows in the option lists
|
||||||
|
foreach ($config['per_type'] ?? [] as $pt) {
|
||||||
|
foreach (array_keys($pt['menus'] ?? []) as $k) if (!in_array($k, $menuKeysList)) $menuKeysList[] = $k;
|
||||||
|
foreach (array_keys($pt['flows'] ?? []) as $k) if (!in_array($k, $flowKeysList)) $flowKeysList[] = $k;
|
||||||
|
}
|
||||||
|
$catMeta = [
|
||||||
|
'1' => ['label' => 'Categoría 1 — Solo reporta', 'icon' => '📤', 'bg' => '#f0fdf4', 'bd' => '#86efac', 'clr' => '#166534'],
|
||||||
|
'2' => ['label' => 'Categoría 2 — Solo recibe informes', 'icon' => '📥', 'bg' => '#eff6ff', 'bd' => '#93c5fd', 'clr' => '#1e40af'],
|
||||||
|
'3' => ['label' => 'Categoría 3 — Reporta y recibe', 'icon' => '🔄', 'bg' => '#fdf4ff', 'bd' => '#e879f9', 'clr' => '#701a75'],
|
||||||
|
];
|
||||||
$categoryTabHtml = '';
|
$categoryTabHtml = '';
|
||||||
|
// Build shared option lists
|
||||||
|
$menuOptsBase = '<option value="">— Sin menú —</option>';
|
||||||
|
foreach ($menuKeysList as $mk) $menuOptsBase .= '<option value="' . self::h($mk) . '">' . self::h($mk) . '</option>';
|
||||||
|
$flowOptsBase = '<option value="">— Ninguno —</option>';
|
||||||
|
foreach ($menuKeysList as $mk) $flowOptsBase .= '<option value="' . self::h($mk) . '">📑 ' . self::h($mk) . '</option>';
|
||||||
|
foreach ($flowKeysList as $fk) $flowOptsBase .= '<option value="' . self::h($fk) . '">🔀 ' . self::h($fk) . '</option>';
|
||||||
|
|
||||||
foreach ([1, 2, 3] as $cat) {
|
foreach ([1, 2, 3] as $cat) {
|
||||||
$catCfg = $perTypeConfig[(string)$cat] ?? [];
|
$catCfg = $perTypeConfig[(string)$cat] ?? [];
|
||||||
$selMenu = $catCfg['greeting_menu'] ?? '';
|
$m = $catMeta[(string)$cat];
|
||||||
$opts = '<option value="">— Usa configuración global —</option>';
|
$greetFlow = self::h($catCfg['greeting_flow'] ?? '');
|
||||||
foreach ($menuKeysList as $mk) {
|
$fallback = $catCfg['fallback_flow'] ?? '';
|
||||||
$s = $selMenu === $mk ? ' selected' : '';
|
$selMenu = $catCfg['greeting_menu'] ?? '';
|
||||||
$opts .= '<option value="' . self::h($mk) . '"' . $s . '>' . self::h($mk) . '</option>';
|
|
||||||
}
|
// greeting_menu selector (insert selected attr)
|
||||||
$label = $catLabels[(string)$cat];
|
$menuOpts = str_replace(
|
||||||
|
'value="' . self::h($selMenu) . '"',
|
||||||
|
'value="' . self::h($selMenu) . '" selected',
|
||||||
|
$menuOptsBase
|
||||||
|
);
|
||||||
|
// fallback selector (insert selected attr)
|
||||||
|
$fbOpts = str_replace(
|
||||||
|
'value="' . self::h($fallback) . '"',
|
||||||
|
'value="' . self::h($fallback) . '" selected',
|
||||||
|
$flowOptsBase
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mostrar greeting_menu solo cuando NO hay greeting_flow (cat 1)
|
||||||
|
$hasGreetFlow = $greetFlow !== '';
|
||||||
|
$greetFlowStyle = '';
|
||||||
|
$greetMenuStyle = $hasGreetFlow ? 'display:none' : '';
|
||||||
|
|
||||||
|
$label = $m['label']; $icon = $m['icon']; $bg = $m['bg']; $bd = $m['bd']; $clr = $m['clr'];
|
||||||
$categoryTabHtml .= <<<CATHTML
|
$categoryTabHtml .= <<<CATHTML
|
||||||
<div style="background:#f8f9fd;border:1px solid #eef1f5;border-radius:10px;padding:18px;margin-bottom:16px">
|
<div style="background:{$bg};border:1px solid {$bd};border-radius:10px;padding:18px;margin-bottom:16px">
|
||||||
<div style="font-weight:700;font-size:13px;color:#0b3d91;margin-bottom:12px">{$label}</div>
|
<div style="display:flex;align-items:center;gap:8px;margin-bottom:14px">
|
||||||
<div class="form-group" style="margin-bottom:0">
|
<span style="font-size:20px">{$icon}</span>
|
||||||
<label>Menú de bienvenida (greeting menu)</label>
|
<span style="font-weight:700;font-size:13px;color:{$clr}">{$label}</span>
|
||||||
<select name="per_type_menu[{$cat}]" style="width:100%;padding:9px 12px;border:1px solid #e2e5ea;border-radius:8px;font-size:13px">
|
</div>
|
||||||
{$opts}
|
<div class="form-group" style="{$greetFlowStyle};margin-bottom:12px">
|
||||||
</select>
|
<label>Flujo de bienvenida</label>
|
||||||
<div style="font-size:11px;color:#7a8291;margin-top:4px">Menú que se muestra cuando el usuario escribe por primera vez o sin contexto activo</div>
|
<input type="text" name="per_type_greeting_flow[{$cat}]" value="{$greetFlow}"
|
||||||
</div>
|
list="ptFlowsDatalist" placeholder="ej: ask_finca (se auto-selecciona si hay 1 sola opción)"
|
||||||
</div>
|
style="width:100%;padding:8px 12px;border:1px solid #e2e5ea;border-radius:8px;font-size:13px">
|
||||||
|
<div style="font-size:11px;color:#7a8291;margin-top:3px">
|
||||||
|
Flow que se ejecuta al primer mensaje. Si el flow retorna 1 sola opción (finca, empresa, etc.) la selecciona automáticamente sin preguntar.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="cat-greet-menu-{$cat}" style="{$greetMenuStyle}" class="form-group" style="margin-bottom:12px">
|
||||||
|
<label>Menú inicial</label>
|
||||||
|
<select name="per_type_menu[{$cat}]" style="width:100%;padding:9px 12px;border:1px solid #e2e5ea;border-radius:8px;font-size:13px">
|
||||||
|
{$menuOpts}
|
||||||
|
</select>
|
||||||
|
<div style="font-size:11px;color:#7a8291;margin-top:3px">Menú que se muestra cuando no hay flujo de bienvenida configurado</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin-bottom:0">
|
||||||
|
<label>Flujo de respaldo (fallback)</label>
|
||||||
|
<select name="per_type_fallback[{$cat}]" style="width:100%;padding:9px 12px;border:1px solid #e2e5ea;border-radius:8px;font-size:13px">
|
||||||
|
{$fbOpts}
|
||||||
|
</select>
|
||||||
|
<div style="font-size:11px;color:#7a8291;margin-top:3px">Se muestra cuando el usuario envía algo que no corresponde a ninguna opción</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
CATHTML;
|
CATHTML;
|
||||||
}
|
}
|
||||||
$aiModelMini = $aiModelVal === 'gpt-4o-mini' ? ' selected' : '';
|
$aiModelMini = $aiModelVal === 'gpt-4o-mini' ? ' selected' : '';
|
||||||
@@ -3643,7 +3696,13 @@ HTML;
|
|||||||
<!-- ─── POR CATEGORÍA ────────────────────────────────────────────────── -->
|
<!-- ─── POR CATEGORÍA ────────────────────────────────────────────────── -->
|
||||||
<div class="tab-content" id="tab-categories">
|
<div class="tab-content" id="tab-categories">
|
||||||
<div class="card-h">👥 Configuración por Categoría</div>
|
<div class="card-h">👥 Configuración por Categoría</div>
|
||||||
<p style="font-size:12px;color:#7a8291;margin-bottom:16px">Define qué menú de bienvenida ve cada categoría de usuario cuando escribe por primera vez o sin contexto activo.</p>
|
<p style="font-size:12px;color:#7a8291;margin-bottom:16px">Define el comportamiento inicial de cada categoría de usuario: si pide finca, qué menú muestra y qué hacer ante texto no reconocido.</p>
|
||||||
|
<datalist id="ptFlowsDatalist">
|
||||||
|
HTML;
|
||||||
|
foreach ($menuKeysList as $mk) echo '<option value="' . self::h($mk) . '">📑 Menú</option>' . "\n";
|
||||||
|
foreach ($flowKeysList as $fk) echo '<option value="' . self::h($fk) . '">🔀 Flow</option>' . "\n";
|
||||||
|
echo <<<HTML
|
||||||
|
</datalist>
|
||||||
{$categoryTabHtml}
|
{$categoryTabHtml}
|
||||||
<button type="button" id="savePerTypeBtn" onclick="savePerType()" class="btn-primary" style="margin-top:8px">💾 Guardar categorías</button>
|
<button type="button" id="savePerTypeBtn" onclick="savePerType()" class="btn-primary" style="margin-top:8px">💾 Guardar categorías</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -3851,8 +3910,12 @@ function savePerType() {
|
|||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.set('company_id', cid);
|
fd.set('company_id', cid);
|
||||||
[1, 2, 3].forEach(function(cat) {
|
[1, 2, 3].forEach(function(cat) {
|
||||||
|
var gf = document.querySelector('[name="per_type_greeting_flow[' + cat + ']"]');
|
||||||
var sel = document.querySelector('[name="per_type_menu[' + cat + ']"]');
|
var sel = document.querySelector('[name="per_type_menu[' + cat + ']"]');
|
||||||
|
var fb = document.querySelector('[name="per_type_fallback[' + cat + ']"]');
|
||||||
|
if (gf) fd.append('per_type_greeting_flow[' + cat + ']', gf.value);
|
||||||
if (sel) fd.append('per_type_menu[' + cat + ']', sel.value);
|
if (sel) fd.append('per_type_menu[' + cat + ']', sel.value);
|
||||||
|
if (fb) fd.append('per_type_fallback[' + cat + ']', fb.value);
|
||||||
});
|
});
|
||||||
var btn = document.getElementById('savePerTypeBtn');
|
var btn = document.getElementById('savePerTypeBtn');
|
||||||
if (btn) { btn.textContent = 'Guardando...'; btn.disabled = true; }
|
if (btn) { btn.textContent = 'Guardando...'; btn.disabled = true; }
|
||||||
@@ -5756,7 +5819,7 @@ HTML;
|
|||||||
}
|
}
|
||||||
|
|
||||||
$providerOptions = '';
|
$providerOptions = '';
|
||||||
foreach (['', 'openai', 'gemini', 'claude'] as $p) {
|
foreach (['', 'openai', 'gemini', 'claude', 'whisper-own'] as $p) {
|
||||||
$label = $p === '' ? 'Todos' : ucfirst($p);
|
$label = $p === '' ? 'Todos' : ucfirst($p);
|
||||||
$sel = $provider === $p ? ' selected' : '';
|
$sel = $provider === $p ? ' selected' : '';
|
||||||
$providerOptions .= "<option value=\"{$p}\"{$sel}>{$label}</option>";
|
$providerOptions .= "<option value=\"{$p}\"{$sel}>{$label}</option>";
|
||||||
@@ -5767,10 +5830,11 @@ HTML;
|
|||||||
$cid = (int)($r['company_id'] ?? 0);
|
$cid = (int)($r['company_id'] ?? 0);
|
||||||
$cname = $companyMap[$cid] ?? '<span style="color:#aaa">—</span>';
|
$cname = $companyMap[$cid] ?? '<span style="color:#aaa">—</span>';
|
||||||
$badge = match($r['provider'] ?? '') {
|
$badge = match($r['provider'] ?? '') {
|
||||||
'openai' => 'background:#10a37f;color:#fff',
|
'openai' => 'background:#10a37f;color:#fff',
|
||||||
'gemini' => 'background:#4285f4;color:#fff',
|
'gemini' => 'background:#4285f4;color:#fff',
|
||||||
'claude' => 'background:#d97706;color:#fff',
|
'claude' => 'background:#d97706;color:#fff',
|
||||||
default => 'background:#6b7280;color:#fff',
|
'whisper-own' => 'background:#7c3aed;color:#fff',
|
||||||
|
default => 'background:#6b7280;color:#fff',
|
||||||
};
|
};
|
||||||
$tok = ($r['tokens_in'] ?? null) !== null
|
$tok = ($r['tokens_in'] ?? null) !== null
|
||||||
? self::h($r['tokens_in']) . '+' . self::h($r['tokens_out'])
|
? 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
|
// 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
|
// 1. El usuario tocó un botón de selección de empresa
|
||||||
if (str_starts_with($rawText, '__co_')) {
|
if (str_starts_with($rawText, '__co_')) {
|
||||||
@@ -233,7 +233,8 @@ class WpWebhook
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. Keyword de reinicio → borrar sesión y mostrar pre-menú
|
// 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) {}
|
try { db()->prepare("DELETE FROM multi_company_sessions WHERE wa_number = ?")->execute([$from]); } catch (\PDOException $e) {}
|
||||||
self::sendCompanyPreMenu($from, $phoneNumberId, $name, $companies);
|
self::sendCompanyPreMenu($from, $phoneNumberId, $name, $companies);
|
||||||
return false;
|
return false;
|
||||||
@@ -307,11 +308,17 @@ class WpWebhook
|
|||||||
self::log('INFO', "Pre-menú de empresa enviado a {$to} (" . count($items) . " opciones)");
|
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
|
private static function isResetKeyword(string $text): bool
|
||||||
{
|
{
|
||||||
$n = mb_strtolower(trim($text));
|
$n = mb_strtolower(trim($text));
|
||||||
$n = str_replace(['á','é','í','ó','ú','ü','ñ'], ['a','e','i','o','u','u','n'], $n);
|
$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
|
private static function getPermissionType(array $companies, int $companyId): int
|
||||||
@@ -418,7 +425,7 @@ class WpWebhook
|
|||||||
'button' => $msg['button']['payload'] ?? $msg['button']['text'] ?? '',
|
'button' => $msg['button']['payload'] ?? $msg['button']['text'] ?? '',
|
||||||
default => '',
|
default => '',
|
||||||
};
|
};
|
||||||
if (!self::handleMultiCompany($from, $name, $matchedCompanies, $rawText, $phoneNumberId)) {
|
if (!self::handleMultiCompany($from, $name, $matchedCompanies, $rawText, $phoneNumberId, $type)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
self::log('INFO', "Número {$from} → empresa (multi-sel): " . (self::$currentCompany['name'] ?? '?'));
|
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";
|
||||||
+36
-9
@@ -1,6 +1,8 @@
|
|||||||
<?php
|
<?php
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
date_default_timezone_set('America/Bogota');
|
||||||
|
|
||||||
require_once __DIR__ . '/../config/env.php';
|
require_once __DIR__ . '/../config/env.php';
|
||||||
require_once __DIR__ . '/../config/db.php';
|
require_once __DIR__ . '/../config/db.php';
|
||||||
require_once __DIR__ . '/../services/Settings.php';
|
require_once __DIR__ . '/../services/Settings.php';
|
||||||
@@ -658,22 +660,47 @@ $routes = [
|
|||||||
|
|
||||||
$config = json_decode($company['config_json'] ?? '{}', true) ?: [];
|
$config = json_decode($company['config_json'] ?? '{}', true) ?: [];
|
||||||
|
|
||||||
$rawPt = $config['per_type'] ?? [];
|
$rawPt = $config['per_type'] ?? [];
|
||||||
$perType = (is_array($rawPt) && count(array_filter(array_keys($rawPt), 'is_string')) > 0)
|
$perType = (is_array($rawPt) && count(array_filter(array_keys($rawPt), 'is_string')) > 0)
|
||||||
? $rawPt : [];
|
? $rawPt : [];
|
||||||
|
|
||||||
$perTypeMenus = $_POST['per_type_menu'] ?? [];
|
$ptGreetFlow = $_POST['per_type_greeting_flow'] ?? [];
|
||||||
|
$ptMenus = $_POST['per_type_menu'] ?? [];
|
||||||
|
$ptFallback = $_POST['per_type_fallback'] ?? [];
|
||||||
|
|
||||||
foreach ([1, 2, 3] as $cat) {
|
foreach ([1, 2, 3] as $cat) {
|
||||||
$mk = trim($perTypeMenus[$cat] ?? '');
|
$key = (string)$cat;
|
||||||
$key = (string)$cat;
|
$current = $perType[$key] ?? []; // preserve menus/flows/commands from seed
|
||||||
if ($mk !== '') {
|
|
||||||
$perType[$key] = array_merge($perType[$key] ?? [], ['greeting_menu' => $mk]);
|
$gf = trim($ptGreetFlow[$cat] ?? '');
|
||||||
|
if ($gf !== '') {
|
||||||
|
// Si tiene greeting_flow → greeting='' para que el flow se dispare al primer mensaje
|
||||||
|
$current['greeting'] = '';
|
||||||
|
$current['greeting_flow'] = $gf;
|
||||||
|
unset($current['greeting_menu']);
|
||||||
} else {
|
} else {
|
||||||
unset($perType[$key]['greeting_menu']);
|
// Sin greeting_flow → usa greeting_menu (cat 1)
|
||||||
if (empty($perType[$key])) unset($perType[$key]);
|
$current['greeting'] = null;
|
||||||
|
unset($current['greeting_flow']);
|
||||||
|
$mk = trim($ptMenus[$cat] ?? '');
|
||||||
|
if ($mk !== '') {
|
||||||
|
$current['greeting_menu'] = $mk;
|
||||||
|
} else {
|
||||||
|
unset($current['greeting_menu']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$fb = trim($ptFallback[$cat] ?? '');
|
||||||
|
if ($fb !== '') {
|
||||||
|
$current['fallback_flow'] = $fb;
|
||||||
|
} else {
|
||||||
|
unset($current['fallback_flow']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$perType[$key] = $current;
|
||||||
}
|
}
|
||||||
$config['per_type'] = empty($perType) ? new stdClass() : $perType;
|
|
||||||
|
$config['per_type'] = $perType;
|
||||||
|
|
||||||
CompanyRepository::save(['id' => $companyId, 'config_json' => json_encode($config, JSON_UNESCAPED_UNICODE)]);
|
CompanyRepository::save(['id' => $companyId, 'config_json' => json_encode($config, JSON_UNESCAPED_UNICODE)]);
|
||||||
echo json_encode(['ok' => true]);
|
echo json_encode(['ok' => true]);
|
||||||
|
|||||||
+30
-10
@@ -167,6 +167,10 @@ $menuConfig = [
|
|||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Claves de flows y menus definidas en este script (las únicas que se actualizan)
|
||||||
|
$baseFlowKeys = array_keys($menuConfig['flows']);
|
||||||
|
$baseMenuKeys = array_keys($menuConfig['menus']);
|
||||||
|
|
||||||
$companies = CompanyRepository::findAll(true);
|
$companies = CompanyRepository::findAll(true);
|
||||||
$count = 0;
|
$count = 0;
|
||||||
|
|
||||||
@@ -179,27 +183,43 @@ foreach ($companies as $company) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$preserveKeys = ['greeting', 'fallback', 'ai_prompt', 'ai_model', 'ai_temperature', 'ai_provider', 'approval_webhook', 'ignore_prefixes', 'ai_max_tokens'];
|
// Partir del config existente para preservar TODO lo custom (nlu, whisper_*, gemini_*, etc.)
|
||||||
foreach ($preserveKeys as $key) {
|
$merged = $existing;
|
||||||
if (array_key_exists($key, $existing)) {
|
|
||||||
$menuConfig[$key] = $existing[$key];
|
// Actualizar SOLO los flows base definidos aquí; los flows custom se preservan
|
||||||
|
foreach ($menuConfig['flows'] as $key => $flow) {
|
||||||
|
$merged['flows'][$key] = $flow;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actualizar SOLO los menus base; los menus custom se preservan
|
||||||
|
foreach ($menuConfig['menus'] as $key => $menu) {
|
||||||
|
$merged['menus'][$key] = $menu;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actualizar commands, per_type y otros campos estructurales del template
|
||||||
|
foreach (['commands', 'per_type'] as $structKey) {
|
||||||
|
if (isset($menuConfig[$structKey])) {
|
||||||
|
$merged[$structKey] = $menuConfig[$structKey];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isset($menuConfig['greeting'])) {
|
// Aplicar defaults solo si no existen
|
||||||
$menuConfig['greeting'] = '¡Bienvenido! Escribe *menu* para ver las opciones disponibles.';
|
if (!isset($merged['greeting'])) {
|
||||||
|
$merged['greeting'] = '¡Bienvenido! Escribe *menu* para ver las opciones disponibles.';
|
||||||
}
|
}
|
||||||
if (!isset($menuConfig['fallback'])) {
|
if (!isset($merged['fallback'])) {
|
||||||
$menuConfig['fallback'] = 'No entendí. Escribe *menu* para ver las opciones disponibles.';
|
$merged['fallback'] = 'No entendí. Escribe *menu* para ver las opciones disponibles.';
|
||||||
}
|
}
|
||||||
|
|
||||||
CompanyRepository::save([
|
CompanyRepository::save([
|
||||||
'id' => (int)$company['id'],
|
'id' => (int)$company['id'],
|
||||||
'config_json' => json_encode($menuConfig, JSON_UNESCAPED_UNICODE),
|
'config_json' => json_encode($merged, JSON_UNESCAPED_UNICODE),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$name = $company['display_name'] ?: $company['name'];
|
$name = $company['display_name'] ?: $company['name'];
|
||||||
echo "✔ {$name} actualizada\n";
|
$customFlows = count(array_diff(array_keys($merged['flows'] ?? []), $baseFlowKeys));
|
||||||
|
$customMenus = count(array_diff(array_keys($merged['menus'] ?? []), $baseMenuKeys));
|
||||||
|
echo "✔ {$name} actualizada (preservados: {$customFlows} flows custom, {$customMenus} menus custom)\n";
|
||||||
$count++;
|
$count++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+74
-11
@@ -334,11 +334,11 @@ PROMPT;
|
|||||||
* Dado un mensaje de texto, decide si enrutar a un flow existente o responder en chat libre.
|
* 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'=>'...']
|
* 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);
|
$config = self::getConfig($company);
|
||||||
$permType = (int)($context['permission_type'] ?? 1);
|
$permType = (int)($context['permission_type'] ?? 1);
|
||||||
$catalog = self::buildFlowCatalog($config, $permType);
|
$catalog = self::buildFlowCatalog($config, $permType, $modulosPermitidos);
|
||||||
|
|
||||||
if (empty($catalog)) {
|
if (empty($catalog)) {
|
||||||
return ['action' => 'chat', 'text' => ''];
|
return ['action' => 'chat', 'text' => ''];
|
||||||
@@ -360,24 +360,52 @@ PROMPT;
|
|||||||
$json = json_decode($clean, true);
|
$json = json_decode($clean, true);
|
||||||
|
|
||||||
if (!is_array($json) || !isset($json['action'])) {
|
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'])) {
|
if ($json['action'] === 'route' && isset($json['key'])) {
|
||||||
// Validate key exists in flows
|
|
||||||
$allFlows = $config['flows'] ?? [];
|
$allFlows = $config['flows'] ?? [];
|
||||||
foreach ($config['per_type'] ?? [] as $pt) {
|
foreach ($config['per_type'] ?? [] as $pt) {
|
||||||
$allFlows = array_merge($allFlows, $pt['flows'] ?? []);
|
$allFlows = array_merge($allFlows, $pt['flows'] ?? []);
|
||||||
}
|
}
|
||||||
if (isset($allFlows[$json['key']])) {
|
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'] ?? [];
|
$flows = $config['flows'] ?? [];
|
||||||
$menus = $config['menus'] ?? [];
|
$menus = $config['menus'] ?? [];
|
||||||
@@ -400,14 +428,31 @@ PROMPT;
|
|||||||
$type = $flow['type'] ?? 'text';
|
$type = $flow['type'] ?? 'text';
|
||||||
$fn = $flow['function'] ?? '';
|
$fn = $flow['function'] ?? '';
|
||||||
|
|
||||||
$isUpload = in_array($type, ['collect_and_post', 'collect_for_each', 'submit_form'], true);
|
// nlu_dir deja que un menú de categoría declare su dirección: por tipo
|
||||||
$isDownload = ($type === 'function' && $fn === 'api_report');
|
// 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:
|
// Permission filter:
|
||||||
// type 1 = solo reporta (upload), type 2 = solo recibe (download), type 3 = ambos
|
// type 1 = solo reporta (upload), type 2 = solo recibe (download), type 3 = ambos
|
||||||
if ($isUpload && !in_array($permType, [1, 3], true)) continue;
|
if ($isUpload && !in_array($permType, [1, 3], true)) continue;
|
||||||
if ($isDownload && !in_array($permType, [2, 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
|
// Skip internal/navigation flows unless they have an explicit NLU description
|
||||||
$hasNluDesc = isset($flow['nlu_description']) && $flow['nlu_description'] !== '';
|
$hasNluDesc = isset($flow['nlu_description']) && $flow['nlu_description'] !== '';
|
||||||
if (in_array($type, ['text', 'image'], true) && !$isUpload && !$isDownload && !$hasNluDesc) continue;
|
if (in_array($type, ['text', 'image'], true) && !$isUpload && !$isDownload && !$hasNluDesc) continue;
|
||||||
@@ -439,8 +484,26 @@ PROMPT;
|
|||||||
. "Opciones disponibles:\n" . implode("\n", $lines) . "\n\n"
|
. "Opciones disponibles:\n" . implode("\n", $lines) . "\n\n"
|
||||||
. "Analiza el mensaje y determina si se refiere claramente a una opción.\n\n"
|
. "Analiza el mensaje y determina si se refiere claramente a una opción.\n\n"
|
||||||
. "Si sí → responde SOLO este JSON (sin markdown):\n"
|
. "Si sí → responde SOLO este JSON (sin markdown):\n"
|
||||||
. "{\"action\":\"route\",\"key\":\"<key_exacto>\"}\n\n"
|
. "{\"action\":\"route\",\"key\":\"<key_exacto>\",\"entities\":{\"campo\":\"valor\",...}}\n\n"
|
||||||
. "Si no está claro o no hay opción correspondiente → responde SOLO:\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"
|
. "{\"action\":\"chat\",\"text\":\"<respuesta corta en español, máx 2 oraciones>\"}\n\n"
|
||||||
. "No inventes keys. Usa exactamente los keys de la lista.";
|
. "No inventes keys. Usa exactamente los keys de la lista.";
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-6
@@ -125,13 +125,20 @@ class BotRouter
|
|||||||
|
|
||||||
if ($text !== null && trim($text) !== '') {
|
if ($text !== null && trim($text) !== '') {
|
||||||
self::log("NLU media [{$inputType}]: texto extraído → \"{$text}\"");
|
self::log("NLU media [{$inputType}]: texto extraído → \"{$text}\"");
|
||||||
// Confirmar al usuario lo que entendió
|
$prefix = $inputType === 'audio' ? '🎤 Escuché' : '🖼️ Imagen analizada';
|
||||||
$prefix = $inputType === 'audio' ? '🎤 Transcripción' : '🖼️ Imagen analizada';
|
|
||||||
WhatsAppSender::sendText(
|
WhatsAppSender::sendText(
|
||||||
$context['from'],
|
$context['from'],
|
||||||
"{$prefix}: _{$text}_",
|
"{$prefix}: _{$text}_",
|
||||||
$context['phone_number_id'] ?? ''
|
$context['phone_number_id'] ?? ''
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Si el usuario está en medio de un flujo activo, el texto va directo
|
||||||
|
// a NormalBot como respuesta al paso actual — no al NLU
|
||||||
|
if (self::hasActiveFlow((int)$company['id'], $context['from'])) {
|
||||||
|
self::log("NLU media: flujo activo detectado → NormalBot recibe \"{$text}\"");
|
||||||
|
return self::runNormalBot($company, $context, $text, 'text', false);
|
||||||
|
}
|
||||||
|
|
||||||
return self::runNluOnText($company, $context, $text, $config);
|
return self::runNluOnText($company, $context, $text, $config);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -155,16 +162,30 @@ class BotRouter
|
|||||||
return self::categoryMenuFallback($company, $context, $config);
|
return self::categoryMenuFallback($company, $context, $config);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function hasActiveFlow(int $companyId, string $phone): bool
|
||||||
|
{
|
||||||
|
$botCtx = ConversationContext::getOrCreate($companyId, $phone, 'normal');
|
||||||
|
$meta = ConversationContext::getMetadata((int)$botCtx['id']);
|
||||||
|
return !empty($meta['__cap']) || !empty($meta['__foreach']) || !empty($meta['collecting']) || !empty($meta['__api_vars']);
|
||||||
|
}
|
||||||
|
|
||||||
private static function runNluOnText(array $company, array $context, string $input, array $config): ?array
|
private static function runNluOnText(array $company, array $context, string $input, array $config): ?array
|
||||||
{
|
{
|
||||||
$result = AiBot::routeOrChat($company, $context, $input);
|
$result = AiBot::routeOrChat($company, $context, $input);
|
||||||
|
|
||||||
if ($result['action'] === 'route') {
|
if ($result['action'] === 'route') {
|
||||||
$key = $result['key'];
|
$key = $result['key'];
|
||||||
$botCtx = ConversationContext::getOrCreate((int)$company['id'], $context['from'], 'normal');
|
$botCtx = ConversationContext::getOrCreate((int)$company['id'], $context['from'], 'normal');
|
||||||
ConversationContext::updateNode((int)$botCtx['id'], $key);
|
$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}]");
|
self::log("NLU: {$context['from']} → flow [{$key}]");
|
||||||
// Ejecutar con input vacío — NormalBot retoma current_node
|
|
||||||
return NormalBot::process($company, $context, '');
|
return NormalBot::process($company, $context, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+19
-3
@@ -17,9 +17,24 @@ class ErpMonitor
|
|||||||
|
|
||||||
public static function check(array $company): array
|
public static function check(array $company): array
|
||||||
{
|
{
|
||||||
$baseUrl = rtrim($company['api_base_url'] ?? '', '/');
|
// El ERP no expone /health sino ?peticion=ping, y la URL sale de
|
||||||
$healthUrl = $baseUrl !== '' ? $baseUrl . '/health' : '';
|
// company_endpoints como el resto de las llamadas: api_base_url no
|
||||||
|
// sirve para esto. Sin esto el panel mostraba el ERP siempre caído.
|
||||||
$apiKey = $company['api_key'] ?? '';
|
$apiKey = $company['api_key'] ?? '';
|
||||||
|
$healthUrl = '';
|
||||||
|
try {
|
||||||
|
$stmt = db()->prepare(
|
||||||
|
"SELECT url FROM company_endpoints WHERE company_id = ? AND endpoint_key = ? AND is_active = 1 LIMIT 1"
|
||||||
|
);
|
||||||
|
$stmt->execute([(int)$company['id'], 'numeros_dn']);
|
||||||
|
$url = (string)($stmt->fetchColumn() ?: '');
|
||||||
|
if ($url !== '') {
|
||||||
|
// Cualquier endpoint sirve de referencia; se reemplaza por ping
|
||||||
|
$healthUrl = preg_replace('/peticion=[^&]*/', 'peticion=ping', $url);
|
||||||
|
}
|
||||||
|
} catch (\PDOException $e) {
|
||||||
|
$healthUrl = '';
|
||||||
|
}
|
||||||
|
|
||||||
if ($healthUrl === '') {
|
if ($healthUrl === '') {
|
||||||
return [
|
return [
|
||||||
@@ -27,7 +42,7 @@ class ErpMonitor
|
|||||||
'company_name' => $company['name'] ?? '',
|
'company_name' => $company['name'] ?? '',
|
||||||
'status' => 'unknown',
|
'status' => 'unknown',
|
||||||
'latency_ms' => null,
|
'latency_ms' => null,
|
||||||
'error' => 'Sin URL configurada',
|
'error' => 'Sin endpoint registrado para esta empresa',
|
||||||
'last_check' => date('Y-m-d H:i:s'),
|
'last_check' => date('Y-m-d H:i:s'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -40,6 +55,7 @@ class ErpMonitor
|
|||||||
CURLOPT_TIMEOUT => 10,
|
CURLOPT_TIMEOUT => 10,
|
||||||
CURLOPT_CONNECTTIMEOUT => 5,
|
CURLOPT_CONNECTTIMEOUT => 5,
|
||||||
CURLOPT_HTTPHEADER => [
|
CURLOPT_HTTPHEADER => [
|
||||||
|
'Authorization: Bearer ' . $apiKey,
|
||||||
'X-API-Key: ' . $apiKey,
|
'X-API-Key: ' . $apiKey,
|
||||||
'User-Agent: bot-palmas360-monitor/1.0',
|
'User-Agent: bot-palmas360-monitor/1.0',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -60,6 +60,11 @@ class MediaTranscriber
|
|||||||
$media = self::downloadMedia($mediaId);
|
$media = self::downloadMedia($mediaId);
|
||||||
if ($media === null) return null;
|
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) {
|
return match ($provider) {
|
||||||
'openai' => self::whisper($media['bytes'], $media['mime'], $cfg),
|
'openai' => self::whisper($media['bytes'], $media['mime'], $cfg),
|
||||||
'gemini' => self::geminiAudio($media['bytes'], $media['mime'], $cfg),
|
'gemini' => self::geminiAudio($media['bytes'], $media['mime'], $cfg),
|
||||||
@@ -83,7 +88,10 @@ class MediaTranscriber
|
|||||||
$ch = curl_init($whisperUrl);
|
$ch = curl_init($whisperUrl);
|
||||||
$opts = [
|
$opts = [
|
||||||
CURLOPT_POST => true,
|
CURLOPT_POST => true,
|
||||||
CURLOPT_POSTFIELDS => ['audio_file' => new CURLFile($tmpFile, $mime, 'audio.' . $ext)],
|
CURLOPT_POSTFIELDS => [
|
||||||
|
'audio_file' => new CURLFile($tmpFile, $mime, 'audio.' . $ext),
|
||||||
|
'response_format' => 'json',
|
||||||
|
],
|
||||||
CURLOPT_RETURNTRANSFER => true,
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
CURLOPT_TIMEOUT => 120,
|
CURLOPT_TIMEOUT => 120,
|
||||||
];
|
];
|
||||||
|
|||||||
+1308
-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,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
company_id INT NOT NULL,
|
company_id INT NOT NULL,
|
||||||
endpoint_key VARCHAR(50) NOT NULL,
|
endpoint_key VARCHAR(50) NOT NULL,
|
||||||
|
name VARCHAR(100) DEFAULT '',
|
||||||
|
params TEXT DEFAULT NULL,
|
||||||
direction ENUM('upload','download') NOT NULL,
|
direction ENUM('upload','download') NOT NULL,
|
||||||
url VARCHAR(500) DEFAULT '',
|
url VARCHAR(500) DEFAULT '',
|
||||||
|
body_fields TEXT DEFAULT NULL,
|
||||||
method VARCHAR(10) DEFAULT 'GET',
|
method VARCHAR(10) DEFAULT 'GET',
|
||||||
last_response TEXT,
|
last_response TEXT,
|
||||||
last_called_at DATETIME,
|
last_called_at DATETIME,
|
||||||
@@ -246,6 +249,15 @@ $db->exec("
|
|||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
");
|
");
|
||||||
|
|
||||||
|
$db->exec("
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
id VARCHAR(128) NOT NULL PRIMARY KEY,
|
||||||
|
data LONGTEXT NOT NULL,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_sessions_updated (updated_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
");
|
||||||
|
|
||||||
$db->exec("
|
$db->exec("
|
||||||
CREATE TABLE IF NOT EXISTS multi_company_sessions (
|
CREATE TABLE IF NOT EXISTS multi_company_sessions (
|
||||||
wa_number VARCHAR(20) NOT NULL PRIMARY KEY,
|
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