Compare commits
53
Commits
620ae3a1b3
...
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] ?? [];
|
||||||
|
$m = $catMeta[(string)$cat];
|
||||||
|
$greetFlow = self::h($catCfg['greeting_flow'] ?? '');
|
||||||
|
$fallback = $catCfg['fallback_flow'] ?? '';
|
||||||
$selMenu = $catCfg['greeting_menu'] ?? '';
|
$selMenu = $catCfg['greeting_menu'] ?? '';
|
||||||
$opts = '<option value="">— Usa configuración global —</option>';
|
|
||||||
foreach ($menuKeysList as $mk) {
|
// greeting_menu selector (insert selected attr)
|
||||||
$s = $selMenu === $mk ? ' selected' : '';
|
$menuOpts = str_replace(
|
||||||
$opts .= '<option value="' . self::h($mk) . '"' . $s . '>' . self::h($mk) . '</option>';
|
'value="' . self::h($selMenu) . '"',
|
||||||
}
|
'value="' . self::h($selMenu) . '" selected',
|
||||||
$label = $catLabels[(string)$cat];
|
$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>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="{$greetFlowStyle};margin-bottom:12px">
|
||||||
|
<label>Flujo de bienvenida</label>
|
||||||
|
<input type="text" name="per_type_greeting_flow[{$cat}]" value="{$greetFlow}"
|
||||||
|
list="ptFlowsDatalist" placeholder="ej: ask_finca (se auto-selecciona si hay 1 sola opción)"
|
||||||
|
style="width:100%;padding:8px 12px;border:1px solid #e2e5ea;border-radius:8px;font-size:13px">
|
||||||
|
<div style="font-size:11px;color:#7a8291;margin-top:3px">
|
||||||
|
Flow que se ejecuta al primer mensaje. Si el flow retorna 1 sola opción (finca, empresa, etc.) la selecciona automáticamente sin preguntar.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="cat-greet-menu-{$cat}" style="{$greetMenuStyle}" class="form-group" style="margin-bottom:12px">
|
||||||
|
<label>Menú inicial</label>
|
||||||
<select name="per_type_menu[{$cat}]" style="width:100%;padding:9px 12px;border:1px solid #e2e5ea;border-radius:8px;font-size:13px">
|
<select name="per_type_menu[{$cat}]" style="width:100%;padding:9px 12px;border:1px solid #e2e5ea;border-radius:8px;font-size:13px">
|
||||||
{$opts}
|
{$menuOpts}
|
||||||
</select>
|
</select>
|
||||||
<div style="font-size:11px;color:#7a8291;margin-top:4px">Menú que se muestra cuando el usuario escribe por primera vez o sin contexto activo</div>
|
<div style="font-size:11px;color:#7a8291;margin-top:3px">Menú que se muestra cuando no hay flujo de bienvenida configurado</div>
|
||||||
</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>
|
||||||
|
</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; }
|
||||||
|
|||||||
@@ -308,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
|
||||||
|
|||||||
+34
-7
@@ -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';
|
||||||
@@ -662,18 +664,43 @@ $routes = [
|
|||||||
$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;
|
||||||
if ($mk !== '') {
|
$current = $perType[$key] ?? []; // preserve menus/flows/commands from seed
|
||||||
$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']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$config['per_type'] = empty($perType) ? new stdClass() : $perType;
|
|
||||||
|
$fb = trim($ptFallback[$cat] ?? '');
|
||||||
|
if ($fb !== '') {
|
||||||
|
$current['fallback_flow'] = $fb;
|
||||||
|
} else {
|
||||||
|
unset($current['fallback_flow']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$perType[$key] = $current;
|
||||||
|
}
|
||||||
|
|
||||||
|
$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]);
|
||||||
|
|||||||
+63
-8
@@ -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,7 +360,10 @@ 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'])) {
|
||||||
@@ -377,10 +380,32 @@ PROMPT;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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'] ?? [];
|
||||||
@@ -403,8 +428,15 @@ 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
|
||||||
@@ -414,6 +446,13 @@ PROMPT;
|
|||||||
// Flujos marcados como internos — no exponer al NLU
|
// Flujos marcados como internos — no exponer al NLU
|
||||||
if (!empty($flow['nlu_skip'])) continue;
|
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;
|
||||||
@@ -448,7 +487,23 @@ PROMPT;
|
|||||||
. "{\"action\":\"route\",\"key\":\"<key_exacto>\",\"entities\":{\"campo\":\"valor\",...}}\n\n"
|
. "{\"action\":\"route\",\"key\":\"<key_exacto>\",\"entities\":{\"campo\":\"valor\",...}}\n\n"
|
||||||
. "En 'entities' incluye los datos que el usuario ya mencionó: nombre de finca, grupo, filtro, fecha, valor numérico, etc. Usa el nombre tal como lo dijo el usuario.\n"
|
. "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"
|
. "Si no mencionó datos extra, omite entities o usa {}.\n\n"
|
||||||
. "Si no está claro o no hay opción correspondiente → responde SOLO:\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.";
|
||||||
}
|
}
|
||||||
|
|||||||
+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',
|
||||||
],
|
],
|
||||||
|
|||||||
+1070
-93
File diff suppressed because it is too large
Load Diff
+26
-7
@@ -3,6 +3,9 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
class PhoneSync
|
class PhoneSync
|
||||||
{
|
{
|
||||||
|
/** Endpoint registrado en company_endpoints que devuelve los números habilitados */
|
||||||
|
private const ENDPOINT_KEY = 'numeros_dn';
|
||||||
|
|
||||||
public static function syncAll(): array
|
public static function syncAll(): array
|
||||||
{
|
{
|
||||||
$companies = CompanyRepository::findAll();
|
$companies = CompanyRepository::findAll();
|
||||||
@@ -19,14 +22,19 @@ class PhoneSync
|
|||||||
{
|
{
|
||||||
$companyId = (int)$company['id'];
|
$companyId = (int)$company['id'];
|
||||||
$name = $company['name'] ?? "company {$companyId}";
|
$name = $company['name'] ?? "company {$companyId}";
|
||||||
$baseUrl = rtrim($company['api_base_url'] ?? '', '/');
|
|
||||||
$apiKey = $company['api_key'] ?? '';
|
$apiKey = $company['api_key'] ?? '';
|
||||||
|
|
||||||
if ($baseUrl === '') {
|
// La URL sale de company_endpoints, igual que el resto de llamadas al ERP.
|
||||||
return ['company' => $name, 'status' => 'skip', 'reason' => 'sin api_base_url'];
|
// 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() ?: '');
|
||||||
|
|
||||||
$url = $baseUrl . '/php/controller/controller_api_externa.php?peticion=whatsapp_numeros';
|
if ($url === '') {
|
||||||
|
return ['company' => $name, 'status' => 'skip', 'reason' => 'sin endpoint ' . self::ENDPOINT_KEY];
|
||||||
|
}
|
||||||
|
|
||||||
$ch = curl_init($url);
|
$ch = curl_init($url);
|
||||||
curl_setopt_array($ch, [
|
curl_setopt_array($ch, [
|
||||||
@@ -63,11 +71,14 @@ class PhoneSync
|
|||||||
|
|
||||||
$db = db();
|
$db = db();
|
||||||
$upsert = $db->prepare("
|
$upsert = $db->prepare("
|
||||||
INSERT INTO company_phones (company_id, wa_number, label, permission_type, is_active)
|
INSERT INTO company_phones (company_id, wa_number, label, permission_type, tercero_id, modulos_json, es_supervisor, is_active)
|
||||||
VALUES (?, ?, ?, ?, 1)
|
VALUES (?, ?, ?, ?, ?, ?, ?, 1)
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
label = VALUES(label),
|
label = VALUES(label),
|
||||||
permission_type = VALUES(permission_type),
|
permission_type = VALUES(permission_type),
|
||||||
|
tercero_id = VALUES(tercero_id),
|
||||||
|
modulos_json = VALUES(modulos_json),
|
||||||
|
es_supervisor = VALUES(es_supervisor),
|
||||||
is_active = 1
|
is_active = 1
|
||||||
");
|
");
|
||||||
|
|
||||||
@@ -76,11 +87,19 @@ class PhoneSync
|
|||||||
$waNumber = trim($n['wa_number'] ?? '');
|
$waNumber = trim($n['wa_number'] ?? '');
|
||||||
if ($waNumber === '') continue;
|
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([
|
$upsert->execute([
|
||||||
$companyId,
|
$companyId,
|
||||||
$waNumber,
|
$waNumber,
|
||||||
trim($n['nombre'] ?? ''),
|
trim($n['nombre'] ?? ''),
|
||||||
(int)($n['permiso'] ?? 1),
|
(int)($n['permiso'] ?? 1),
|
||||||
|
$tercero,
|
||||||
|
$modulos ? json_encode($modulos) : null,
|
||||||
|
!empty($n['es_supervisor']) ? 1 : 0,
|
||||||
]);
|
]);
|
||||||
$activeNumbers[] = $waNumber;
|
$activeNumbers[] = $waNumber;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
+614
-69
@@ -6,17 +6,80 @@ require_once dirname(__DIR__) . '/config/db.php';
|
|||||||
|
|
||||||
$db = db();
|
$db = db();
|
||||||
|
|
||||||
|
// ─── Columnas que el perfil por número necesita ───────────────────────────────
|
||||||
|
// Se crean acá y no con un ALTER a mano porque el seed ya se corre en cada
|
||||||
|
// despliegue. Es idempotente: si la columna existe, no hace nada.
|
||||||
|
|
||||||
|
foreach ([
|
||||||
|
'tercero_id' => 'INT NULL',
|
||||||
|
'modulos_json' => 'TEXT NULL',
|
||||||
|
'es_supervisor' => 'TINYINT(1) NOT NULL DEFAULT 0',
|
||||||
|
] as $columna => $tipo) {
|
||||||
|
$existe = $db->query("SHOW COLUMNS FROM company_phones LIKE '{$columna}'")->fetch();
|
||||||
|
if (!$existe) {
|
||||||
|
$db->exec("ALTER TABLE company_phones ADD COLUMN {$columna} {$tipo}");
|
||||||
|
echo "✔ company_phones.{$columna} creada\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── CONFIG JSON completo (Company 2 y 3 comparten la misma base) ────────────
|
// ─── CONFIG JSON completo (Company 2 y 3 comparten la misma base) ────────────
|
||||||
|
|
||||||
$configJson = [
|
$configJson = [
|
||||||
'greeting' => '¡Bienvenido a Palmas360! Escribe *menu* para ver las opciones disponibles.',
|
'greeting' => '¡Bienvenido a Palmas360! Escribe *menu* para ver las opciones disponibles.',
|
||||||
'fallback' => 'No entendí. Escribe *menu* para ver las opciones disponibles.',
|
'fallback' => 'No entendí. Escribe *menu* para ver las opciones disponibles.',
|
||||||
|
'nlu_enabled' => true,
|
||||||
|
|
||||||
|
// ── Bienvenida: una vez por día y por empresa ───────────────────────────
|
||||||
|
// Tokens: {saludo} {empresa} {finca} {empresas}
|
||||||
|
// No menciona la finca porque llega antes de elegirla; la finca activa se
|
||||||
|
// ve en el footer de cada menú.
|
||||||
|
'welcome' => [
|
||||||
|
'enabled' => true,
|
||||||
|
'text' => [
|
||||||
|
// Cat 1 — solo reporta: sin ejemplos de descarga, que no puede usar
|
||||||
|
'1' => "{saludo}, bienvenido a {empresa}.\n\n"
|
||||||
|
. "Desde acá enviás los datos de campo:\n"
|
||||||
|
. " • _\"registrar un ausentismo\"_\n"
|
||||||
|
. " • _\"abrir ciclos de cosecha\"_\n"
|
||||||
|
. " • _\"registrar pluviometría\"_\n\n"
|
||||||
|
. "Cuando quieras:\n"
|
||||||
|
. " *menú* ver las opciones · *salir* terminar{empresas}",
|
||||||
|
|
||||||
|
'2' => "{saludo}, bienvenido a {empresa}.\n\n"
|
||||||
|
. "Pedime lo que necesites con tus palabras:\n"
|
||||||
|
. " • _\"informe de mantenimiento de plateo\"_\n"
|
||||||
|
. " • _\"quién faltó hoy\"_\n"
|
||||||
|
. " • _\"producción de este mes\"_\n\n"
|
||||||
|
. "O navegá con los menús. Cuando quieras:\n"
|
||||||
|
. " *menú* volver al inicio · *atrás* subir un nivel · *salir* terminar\n"
|
||||||
|
. " *cambiar finca* para consultar otra finca{empresas}",
|
||||||
|
|
||||||
|
'3' => "{saludo}, bienvenido a {empresa}.\n\n"
|
||||||
|
. "Pedime lo que necesites con tus palabras:\n"
|
||||||
|
. " • _\"informe de mantenimiento de plateo\"_\n"
|
||||||
|
. " • _\"quién faltó hoy\"_\n"
|
||||||
|
. " • _\"registrar un ausentismo\"_\n\n"
|
||||||
|
. "O navegá con los menús. Cuando quieras:\n"
|
||||||
|
. " *menú* volver al inicio · *atrás* subir un nivel · *salir* terminar\n"
|
||||||
|
. " *cambiar finca* para consultar otra finca{empresas}",
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
'commands' => [
|
'commands' => [
|
||||||
|
// Al menú principal
|
||||||
'menu' => 'show_main_menu',
|
'menu' => 'show_main_menu',
|
||||||
|
'inicio' => 'show_main_menu',
|
||||||
|
'salir' => 'show_main_menu',
|
||||||
|
'cancelar' => 'show_main_menu',
|
||||||
|
// Un nivel arriba (__back lo resuelve con el mapa 'back' del nodo actual)
|
||||||
|
'atras' => '__back',
|
||||||
|
'volver' => '__back',
|
||||||
|
'regresar' => '__back',
|
||||||
|
// Atajos
|
||||||
'informes' => 'descargar_informes',
|
'informes' => 'descargar_informes',
|
||||||
'info' => 'enviar_informacion',
|
'info' => 'enviar_informacion',
|
||||||
'inicio' => 'show_main_menu',
|
'finca' => 'reset_finca',
|
||||||
'volver' => 'show_main_menu',
|
'cambiar finca' => 'reset_finca',
|
||||||
],
|
],
|
||||||
// ── Menus globales ──────────────────────────────────────────────────────
|
// ── Menus globales ──────────────────────────────────────────────────────
|
||||||
'menus' => [
|
'menus' => [
|
||||||
@@ -50,9 +113,9 @@ $configJson = [
|
|||||||
[
|
[
|
||||||
'title' => 'Informes',
|
'title' => 'Informes',
|
||||||
'rows' => [
|
'rows' => [
|
||||||
['id' => 'submenu_produccion', 'title' => '🌿 Producción', 'description' => 'Informes de producción'],
|
['id' => 'submenu_produccion', 'modulo' => 'produccion', 'title' => '🌿 Producción', 'description' => 'Informes de producción'],
|
||||||
['id' => 'submenu_ciclos', 'title' => '🔄 Ciclos', 'description' => 'Ciclos de campo'],
|
['id' => 'submenu_ciclos', 'modulo' => 'ciclos', 'title' => '🔄 Ciclos', 'description' => 'Ciclos de campo'],
|
||||||
['id' => 'submenu_ausentismos', 'title' => '👥 Ausentismos', 'description' => 'Faltas y novedades'],
|
['id' => 'submenu_ausentismos', 'modulo' => 'ausentismos', 'title' => '👥 Ausentismos', 'description' => 'Faltas y novedades'],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -117,7 +180,7 @@ $configJson = [
|
|||||||
['id'=>'ciclo_censo_todos', 'title'=>'📋 Todos los lotes'],
|
['id'=>'ciclo_censo_todos', 'title'=>'📋 Todos los lotes'],
|
||||||
['id'=>'ciclo_censo_historico','title'=>'📅 Histórico 30d'],
|
['id'=>'ciclo_censo_historico','title'=>'📅 Histórico 30d'],
|
||||||
]],
|
]],
|
||||||
'submenu_ciclo_palm' => ['type'=>'button','body'=>'Ciclos de Rincofurus — ¿Qué deseas ver?','buttons'=>[
|
'submenu_ciclo_palm' => ['type'=>'button','body'=>'Ciclos de Palmarum — ¿Qué deseas ver?','buttons'=>[
|
||||||
['id'=>'ciclo_palm_top', 'title'=>'🏆 Más largos'],
|
['id'=>'ciclo_palm_top', 'title'=>'🏆 Más largos'],
|
||||||
['id'=>'ciclo_palm_todos', 'title'=>'📋 Todos los lotes'],
|
['id'=>'ciclo_palm_todos', 'title'=>'📋 Todos los lotes'],
|
||||||
['id'=>'ciclo_palm_historico','title'=>'📅 Histórico 30d'],
|
['id'=>'ciclo_palm_historico','title'=>'📅 Histórico 30d'],
|
||||||
@@ -130,7 +193,7 @@ $configJson = [
|
|||||||
'submenu_ciclo_mantenimiento' => ['type'=>'button','body'=>'Ciclos de Mantenimiento — ¿Qué deseas ver?','buttons'=>[
|
'submenu_ciclo_mantenimiento' => ['type'=>'button','body'=>'Ciclos de Mantenimiento — ¿Qué deseas ver?','buttons'=>[
|
||||||
['id'=>'ciclo_mantenimiento_top', 'title'=>'🏆 Más largos'],
|
['id'=>'ciclo_mantenimiento_top', 'title'=>'🏆 Más largos'],
|
||||||
['id'=>'ciclo_mantenimiento_todos', 'title'=>'📋 Todos los lotes'],
|
['id'=>'ciclo_mantenimiento_todos', 'title'=>'📋 Todos los lotes'],
|
||||||
['id'=>'ciclo_mantenimiento_historico', 'title'=>'📅 Histórico 30d'],
|
['id'=>'reset_grupo_mant', 'title'=>'🔄 Otro grupo'],
|
||||||
]],
|
]],
|
||||||
|
|
||||||
// Submenú sanidad vegetal (orden: Censo, Plagas, Rincofurus, Tratamiento)
|
// Submenú sanidad vegetal (orden: Censo, Plagas, Rincofurus, Tratamiento)
|
||||||
@@ -146,7 +209,7 @@ $configJson = [
|
|||||||
'rows' => [
|
'rows' => [
|
||||||
['id' => 'ciclo_censo', 'title' => '📝 Censo', 'description' => 'Ciclos de censo'],
|
['id' => 'ciclo_censo', 'title' => '📝 Censo', 'description' => 'Ciclos de censo'],
|
||||||
['id' => 'ciclo_plagas', 'title' => '🐛 Plagas', 'description' => 'Ciclos de plagas'],
|
['id' => 'ciclo_plagas', 'title' => '🐛 Plagas', 'description' => 'Ciclos de plagas'],
|
||||||
['id' => 'ciclo_palm', 'title' => '🌴 Rincofurus', 'description' => 'Ciclos Rhynchophorus Palmarum'],
|
['id' => 'ciclo_palm', 'title' => '🌴 Palmarum', 'description' => 'Rhynchophorus Palmarum'],
|
||||||
['id' => 'ciclo_tratamiento', 'title' => '💊 Tratamiento', 'description' => 'Ciclos de tratamientos'],
|
['id' => 'ciclo_tratamiento', 'title' => '💊 Tratamiento', 'description' => 'Ciclos de tratamientos'],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -183,7 +246,12 @@ $configJson = [
|
|||||||
[
|
[
|
||||||
'title' => 'Opciones',
|
'title' => 'Opciones',
|
||||||
'rows' => [
|
'rows' => [
|
||||||
['id' => 'subir_ciclo_cosecha', 'title' => '🌾 Ciclo cosecha', 'description' => 'Subir ciclo de cosecha'],
|
['id' => 'registrar_ausentismo', 'modulo' => 'ausentismos', 'title' => '👥 Ausentismo', 'description' => 'Registrar falta, incapacidad o permiso'],
|
||||||
|
['id' => 'registrar_pluviometria','modulo' => 'pluviometria', 'title' => '🌧️ Pluviometría', 'description' => 'Milímetros de lluvia por finca'],
|
||||||
|
['id' => 'registrar_ciclo', 'modulo' => 'ciclos', 'title' => '🔄 Ciclos', 'description' => 'Abrir o cerrar lotes'],
|
||||||
|
['id' => 'registrar_mantenimiento','modulo' => 'mantenimiento', 'title' => '🔧 Mantenimiento', 'description' => 'Avance sobre lotes pendientes'],
|
||||||
|
['id' => 'registrar_labor', 'modulo' => 'labores', 'title' => '💪 Labor diaria', 'description' => 'Tractorista, transporte, horas extra'],
|
||||||
|
['id' => 'registrar_labor_validada','modulo' => 'labores', 'title' => '🌾 Cosecha y otros','description' => 'Cosecha, polinización, fertilización'],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -203,87 +271,109 @@ $configJson = [
|
|||||||
'section_title' => 'Fincas disponibles',
|
'section_title' => 'Fincas disponibles',
|
||||||
'meta_group' => 'finca',
|
'meta_group' => 'finca',
|
||||||
'meta_key' => 'finca_id',
|
'meta_key' => 'finca_id',
|
||||||
|
'entity_key' => 'finca',
|
||||||
'next_node' => 'show_main_menu',
|
'next_node' => 'show_main_menu',
|
||||||
|
'skip_if_set' => true,
|
||||||
|
'nlu_description' => 'Seleccionar o cambiar la finca de trabajo',
|
||||||
|
],
|
||||||
|
|
||||||
|
// Acción NLU: limpiar finca y volver a ask_finca
|
||||||
|
'reset_finca' => [
|
||||||
|
'type' => 'function',
|
||||||
|
'function' => 'reset_finca',
|
||||||
|
'nlu_description' => 'Cambiar de finca o seleccionar una finca diferente',
|
||||||
|
],
|
||||||
|
|
||||||
|
// Vuelve a pedir el grupo de mantenimiento (el grupo persiste entre informes)
|
||||||
|
'reset_grupo_mant' => [
|
||||||
|
'type' => 'function',
|
||||||
|
'function' => 'reset_grupo_mant',
|
||||||
|
'nlu_description' => 'Cambiar el grupo de mantenimiento (plateo, corona, poda, etc.)',
|
||||||
],
|
],
|
||||||
|
|
||||||
// Producción (texto del mes + PDF encadenado)
|
// Producción (texto del mes + PDF encadenado)
|
||||||
'produccion_total' => ['type' => 'function', 'function' => 'api_report', 'params' => [
|
'produccion_total' => ['type' => 'function', 'function' => 'api_report', 'nlu_description' => 'Producción total facturada del mes actual con PDF del año', 'params' => [
|
||||||
'endpoint_key' => 'produccion_total_bot_dn',
|
'endpoint_key' => 'produccion_total_bot_dn',
|
||||||
'date_mode' => 'current_month',
|
'date_mode' => 'current_month',
|
||||||
'next_endpoint_key' => 'produccion_total_pdf_bot_dn',
|
'next_endpoint_key' => 'produccion_total_pdf_bot_dn',
|
||||||
'next_caption' => '📊 Producción del año (PDF)',
|
'next_caption' => '📊 Producción del año (PDF)',
|
||||||
'next_filename' => 'produccion_año.pdf',
|
'next_filename' => 'produccion_año.pdf',
|
||||||
]],
|
]],
|
||||||
'produccion_lotes' => ['type' => 'function', 'function' => 'api_report', 'params' => [
|
'produccion_lotes' => ['type' => 'function', 'function' => 'api_report', 'nlu_description' => 'Producción detallada por lotes en Excel', 'params' => [
|
||||||
'endpoint_key' => 'produccion_lotes_bot_dn',
|
'endpoint_key' => 'produccion_lotes_bot_dn',
|
||||||
'filename' => 'produccion_lotes.xlsx',
|
'filename' => 'produccion_lotes.xlsx',
|
||||||
'caption' => 'Producción por lotes del año',
|
'caption' => 'Producción por lotes del año',
|
||||||
]],
|
]],
|
||||||
|
|
||||||
// Ausentismos
|
// Ausentismos
|
||||||
'ausentismos_hoy' => ['type' => 'function', 'function' => 'api_report', 'params' => [
|
'ausentismos_hoy' => ['type' => 'function', 'function' => 'api_report', 'nlu_description' => 'Ausentismos de hoy, quién faltó hoy', 'params' => [
|
||||||
'endpoint_key' => 'ausentismos_bot_texto_dn',
|
'endpoint_key' => 'ausentismos_bot_texto_dn',
|
||||||
'date_mode' => 'today',
|
'date_mode' => 'today',
|
||||||
]],
|
]],
|
||||||
'ausentismos_semana' => ['type' => 'function', 'function' => 'api_report', 'params' => [
|
'ausentismos_semana' => ['type' => 'function', 'function' => 'api_report', 'nlu_description' => 'Ausentismos de esta semana', 'params' => [
|
||||||
'endpoint_key' => 'ausentismos_bot_texto_dn',
|
'endpoint_key' => 'ausentismos_bot_texto_dn',
|
||||||
'date_mode' => 'last_7',
|
'date_mode' => 'last_7',
|
||||||
]],
|
]],
|
||||||
'ausentismos_mes' => ['type' => 'function', 'function' => 'api_report', 'params' => [
|
'ausentismos_mes' => ['type' => 'function', 'function' => 'api_report', 'nlu_description' => 'Reporte PDF de ausentismos del mes', 'params' => [
|
||||||
'endpoint_key' => 'ausentismos_bot_pdf_dn',
|
'endpoint_key' => 'ausentismos_bot_pdf_dn',
|
||||||
'date_mode' => 'current_month',
|
'date_mode' => 'current_month',
|
||||||
'filename' => 'ausentismos_mes.pdf',
|
'filename' => 'ausentismos_mes.pdf',
|
||||||
'caption' => 'Ausentismos del mes',
|
'caption' => 'Ausentismos del mes',
|
||||||
]],
|
]],
|
||||||
|
|
||||||
// Menus de sección
|
// Menus de sección (nlu_skip: navegación interna, no exponer al NLU)
|
||||||
'submenu_produccion' => ['type' => 'menu', 'menu' => 'submenu_produccion'],
|
// 'back' = a dónde sube el comando "atrás" desde cada nivel
|
||||||
'submenu_ciclos' => ['type' => 'menu', 'menu' => 'submenu_ciclos'],
|
'submenu_produccion' => ['type' => 'menu', 'menu' => 'submenu_produccion', 'back' => 'show_main_menu', 'nlu_dir' => 'descarga', 'nlu_description' => 'Producción en general: totales facturados o detalle por lotes'],
|
||||||
'submenu_ciclos_sanidad' => ['type' => 'menu', 'menu' => 'submenu_ciclos_sanidad'],
|
'submenu_ciclos' => ['type' => 'menu', 'menu' => 'submenu_ciclos', 'back' => 'show_main_menu', 'nlu_dir' => 'descarga', 'nlu_description' => 'Consultar ciclos de campo en general, sin decir cuál'],
|
||||||
'submenu_ausentismos' => ['type' => 'menu', 'menu' => 'submenu_ausentismos'],
|
'submenu_ciclos_sanidad' => ['type' => 'menu', 'menu' => 'submenu_ciclos_sanidad', 'back' => 'submenu_ciclos', 'nlu_dir' => 'descarga', 'nlu_description' => 'Ciclos de sanidad en general: censo, plagas, palmarum, tratamiento'],
|
||||||
|
'submenu_ausentismos' => ['type' => 'menu', 'menu' => 'submenu_ausentismos', 'back' => 'show_main_menu', 'nlu_dir' => 'descarga', 'nlu_description' => 'Consultar ausentismos: quién faltó hoy, esta semana o el mes'],
|
||||||
|
|
||||||
// Ciclos — cada uno abre un sub-menú de 3 opciones
|
// Ciclos — cada uno abre un sub-menú de 3 opciones
|
||||||
'ciclo_cosecha' => ['type' => 'menu', 'menu' => 'submenu_ciclo_cosecha'],
|
'ciclo_cosecha' => ['type' => 'menu', 'menu' => 'submenu_ciclo_cosecha', 'back' => 'submenu_ciclos', 'nlu_description' => 'Ver ciclos de cosecha (lotes más largos o todos)'],
|
||||||
'ciclo_polinizacion' => ['type' => 'menu', 'menu' => 'submenu_ciclo_polinizacion'],
|
'ciclo_polinizacion' => ['type' => 'menu', 'menu' => 'submenu_ciclo_polinizacion', 'back' => 'submenu_ciclos', 'nlu_description' => 'Ver ciclos de polinización'],
|
||||||
'ciclo_plagas' => ['type' => 'menu', 'menu' => 'submenu_ciclo_plagas'],
|
'ciclo_plagas' => ['type' => 'menu', 'menu' => 'submenu_ciclo_plagas', 'back' => 'submenu_ciclos_sanidad', 'nlu_description' => 'Ver ciclos de plagas'],
|
||||||
'ciclo_censo' => ['type' => 'menu', 'menu' => 'submenu_ciclo_censo'],
|
'ciclo_censo' => ['type' => 'menu', 'menu' => 'submenu_ciclo_censo', 'back' => 'submenu_ciclos_sanidad', 'nlu_description' => 'Ver ciclos de censo'],
|
||||||
'ciclo_palm' => ['type' => 'menu', 'menu' => 'submenu_ciclo_palm'],
|
'ciclo_palm' => ['type' => 'menu', 'menu' => 'submenu_ciclo_palm', 'back' => 'submenu_ciclos_sanidad', 'nlu_description' => 'Ver ciclos de Palmarum (Rhynchophorus)'],
|
||||||
'ciclo_tratamiento' => ['type' => 'menu', 'menu' => 'submenu_ciclo_tratamiento'],
|
'ciclo_tratamiento' => ['type' => 'menu', 'menu' => 'submenu_ciclo_tratamiento', 'back' => 'submenu_ciclos_sanidad', 'nlu_description' => 'Ver ciclos de tratamientos'],
|
||||||
|
|
||||||
// Sub-opciones: cosecha
|
// Sub-opciones: cosecha
|
||||||
'ciclo_cosecha_top' => ['type'=>'function','function'=>'api_report','params'=>['endpoint_key'=>'cosecha_top_texto_bot_dn','date_mode'=>'last_30']],
|
'ciclo_cosecha_top' => ['type'=>'function','function'=>'api_report','nlu_description'=>'Lotes de cosecha con ciclos más largos','params'=>['endpoint_key'=>'cosecha_top_texto_bot_dn','date_mode'=>'last_30']],
|
||||||
'ciclo_cosecha_todos' => ['type'=>'function','function'=>'api_report','params'=>['endpoint_key'=>'cosecha_todos_dn','date_mode'=>'today','filename'=>'ciclos_cosecha.pdf','caption'=>'Ciclos de Cosecha — todos los lotes']],
|
'ciclo_cosecha_todos' => ['type'=>'function','function'=>'api_report','nlu_description'=>'PDF de ciclos de cosecha todos los lotes','params'=>['endpoint_key'=>'cosecha_todos_dn','date_mode'=>'today','filename'=>'ciclos_cosecha.pdf','caption'=>'Ciclos de Cosecha — todos los lotes']],
|
||||||
'ciclo_cosecha_historico'=> ['type'=>'function','function'=>'api_report','params'=>['endpoint_key'=>'cosecha_historico_bot_dn','date_mode'=>'last_30','filename'=>'historico_cosecha.pdf','caption'=>'Histórico Cosecha — últimos 30 días']],
|
'ciclo_cosecha_historico'=> ['type'=>'function','function'=>'api_report','nlu_description'=>'Histórico de cosecha últimos 30 días','params'=>['endpoint_key'=>'cosecha_historico_bot_dn','date_mode'=>'last_30','filename'=>'historico_cosecha.pdf','caption'=>'Histórico Cosecha — últimos 30 días']],
|
||||||
|
|
||||||
// Sub-opciones: polinizacion
|
// Sub-opciones: polinizacion
|
||||||
'ciclo_polinizacion_top' => ['type'=>'function','function'=>'api_report','params'=>['endpoint_key'=>'polinizacion_top_texto_bot_dn','date_mode'=>'last_30']],
|
'ciclo_polinizacion_top' => ['type'=>'function','function'=>'api_report','nlu_description'=>'Lotes de polinización con ciclos más largos','params'=>['endpoint_key'=>'polinizacion_top_texto_bot_dn','date_mode'=>'last_30']],
|
||||||
'ciclo_polinizacion_todos' => ['type'=>'function','function'=>'api_report','params'=>['endpoint_key'=>'polinizacion_todos_dn','date_mode'=>'today','filename'=>'ciclos_polinizacion.pdf','caption'=>'Ciclos de Polinización — todos los lotes']],
|
'ciclo_polinizacion_todos' => ['type'=>'function','function'=>'api_report','nlu_description'=>'PDF de ciclos de polinización todos los lotes','params'=>['endpoint_key'=>'polinizacion_todos_dn','date_mode'=>'today','filename'=>'ciclos_polinizacion.pdf','caption'=>'Ciclos de Polinización — todos los lotes']],
|
||||||
'ciclo_polinizacion_historico'=> ['type'=>'function','function'=>'api_report','params'=>['endpoint_key'=>'polinizacion_historico_bot_dn','date_mode'=>'last_30','filename'=>'historico_polinizacion.pdf','caption'=>'Histórico Polinización — últimos 30 días']],
|
'ciclo_polinizacion_historico'=> ['type'=>'function','function'=>'api_report','nlu_description'=>'Histórico de polinización últimos 30 días','params'=>['endpoint_key'=>'polinizacion_historico_bot_dn','date_mode'=>'last_30','filename'=>'historico_polinizacion.pdf','caption'=>'Histórico Polinización — últimos 30 días']],
|
||||||
|
|
||||||
// Sub-opciones: plagas
|
// Sub-opciones: plagas
|
||||||
'ciclo_plagas_top' => ['type'=>'function','function'=>'api_report','params'=>['endpoint_key'=>'plagas_top_texto_bot_dn','date_mode'=>'last_30']],
|
'ciclo_plagas_top' => ['type'=>'function','function'=>'api_report','nlu_description'=>'Lotes de plagas con ciclos más largos','params'=>['endpoint_key'=>'plagas_top_texto_bot_dn','date_mode'=>'last_30']],
|
||||||
'ciclo_plagas_todos' => ['type'=>'function','function'=>'api_report','params'=>['endpoint_key'=>'plagas_todos_dn','date_mode'=>'today','filename'=>'ciclos_plagas.pdf','caption'=>'Ciclos de Plagas — todos los lotes']],
|
'ciclo_plagas_todos' => ['type'=>'function','function'=>'api_report','nlu_description'=>'PDF de ciclos de plagas todos los lotes','params'=>['endpoint_key'=>'plagas_todos_dn','date_mode'=>'today','filename'=>'ciclos_plagas.pdf','caption'=>'Ciclos de Plagas — todos los lotes']],
|
||||||
'ciclo_plagas_historico'=> ['type'=>'function','function'=>'api_report','params'=>['endpoint_key'=>'plagas_historico_bot_dn','date_mode'=>'last_30','filename'=>'historico_plagas.pdf','caption'=>'Histórico Plagas — últimos 30 días']],
|
'ciclo_plagas_historico'=> ['type'=>'function','function'=>'api_report','nlu_description'=>'Histórico de plagas últimos 30 días','params'=>['endpoint_key'=>'plagas_historico_bot_dn','date_mode'=>'last_30','filename'=>'historico_plagas.pdf','caption'=>'Histórico Plagas — últimos 30 días']],
|
||||||
|
|
||||||
// Sub-opciones: censo
|
// Sub-opciones: censo
|
||||||
'ciclo_censo_top' => ['type'=>'function','function'=>'api_report','params'=>['endpoint_key'=>'censo_top_texto_bot_dn','date_mode'=>'last_30']],
|
'ciclo_censo_top' => ['type'=>'function','function'=>'api_report','nlu_description'=>'Lotes de censo con ciclos más largos','params'=>['endpoint_key'=>'censo_top_texto_bot_dn','date_mode'=>'last_30']],
|
||||||
'ciclo_censo_todos' => ['type'=>'function','function'=>'api_report','params'=>['endpoint_key'=>'censo_todos_dn','date_mode'=>'today','filename'=>'ciclos_censo.pdf','caption'=>'Ciclos de Censo — todos los lotes']],
|
'ciclo_censo_todos' => ['type'=>'function','function'=>'api_report','nlu_description'=>'PDF de ciclos de censo todos los lotes','params'=>['endpoint_key'=>'censo_todos_dn','date_mode'=>'today','filename'=>'ciclos_censo.pdf','caption'=>'Ciclos de Censo — todos los lotes']],
|
||||||
'ciclo_censo_historico'=> ['type'=>'function','function'=>'api_report','params'=>['endpoint_key'=>'censo_historico_bot_dn','date_mode'=>'last_30','filename'=>'historico_censo.pdf','caption'=>'Histórico Censo — últimos 30 días']],
|
'ciclo_censo_historico'=> ['type'=>'function','function'=>'api_report','nlu_description'=>'Histórico de censo últimos 30 días','params'=>['endpoint_key'=>'censo_historico_bot_dn','date_mode'=>'last_30','filename'=>'historico_censo.pdf','caption'=>'Histórico Censo — últimos 30 días']],
|
||||||
|
|
||||||
// Sub-opciones: palm
|
// Sub-opciones: palm
|
||||||
'ciclo_palm_top' => ['type'=>'function','function'=>'api_report','params'=>['endpoint_key'=>'palm_top_texto_bot_dn','date_mode'=>'last_30']],
|
'ciclo_palm_top' => ['type'=>'function','function'=>'api_report','nlu_description'=>'Lotes de Palmarum con ciclos más largos','params'=>['endpoint_key'=>'palm_top_texto_bot_dn','date_mode'=>'last_30']],
|
||||||
'ciclo_palm_todos' => ['type'=>'function','function'=>'api_report','params'=>['endpoint_key'=>'palm_todos_dn','date_mode'=>'today','filename'=>'ciclos_palmarum.pdf','caption'=>'Ciclos de Palmarum — todos los lotes']],
|
'ciclo_palm_todos' => ['type'=>'function','function'=>'api_report','nlu_description'=>'PDF de ciclos de Palmarum todos los lotes','params'=>['endpoint_key'=>'palm_todos_dn','date_mode'=>'today','filename'=>'ciclos_palmarum.pdf','caption'=>'Ciclos de Palmarum — todos los lotes']],
|
||||||
'ciclo_palm_historico'=> ['type'=>'function','function'=>'api_report','params'=>['endpoint_key'=>'palm_historico_bot_dn','date_mode'=>'last_30','filename'=>'historico_palmarum.pdf','caption'=>'Histórico Palmarum — últimos 30 días']],
|
'ciclo_palm_historico'=> ['type'=>'function','function'=>'api_report','nlu_description'=>'Histórico de Palmarum últimos 30 días','params'=>['endpoint_key'=>'palm_historico_bot_dn','date_mode'=>'last_30','filename'=>'historico_palmarum.pdf','caption'=>'Histórico Palmarum — últimos 30 días']],
|
||||||
|
|
||||||
// Sub-opciones: tratamiento
|
// Sub-opciones: tratamiento
|
||||||
'ciclo_tratamiento_top' => ['type'=>'function','function'=>'api_report','params'=>['endpoint_key'=>'tratamiento_top_texto_bot_dn','date_mode'=>'last_30']],
|
'ciclo_tratamiento_top' => ['type'=>'function','function'=>'api_report','nlu_description'=>'Lotes de tratamiento con ciclos más largos','params'=>['endpoint_key'=>'tratamiento_top_texto_bot_dn','date_mode'=>'last_30']],
|
||||||
'ciclo_tratamiento_todos' => ['type'=>'function','function'=>'api_report','params'=>['endpoint_key'=>'tratamiento_todos_dn','date_mode'=>'today','filename'=>'ciclos_tratamientos.pdf','caption'=>'Ciclos de Tratamientos — todos los lotes']],
|
'ciclo_tratamiento_todos' => ['type'=>'function','function'=>'api_report','nlu_description'=>'PDF de ciclos de tratamientos todos los lotes','params'=>['endpoint_key'=>'tratamiento_todos_dn','date_mode'=>'today','filename'=>'ciclos_tratamientos.pdf','caption'=>'Ciclos de Tratamientos — todos los lotes']],
|
||||||
'ciclo_tratamiento_historico'=> ['type'=>'function','function'=>'api_report','params'=>['endpoint_key'=>'tratamiento_historico_bot_dn','date_mode'=>'last_30','filename'=>'historico_tratamientos.pdf','caption'=>'Histórico Tratamientos — últimos 30 días']],
|
'ciclo_tratamiento_historico'=> ['type'=>'function','function'=>'api_report','nlu_description'=>'Histórico de tratamientos últimos 30 días','params'=>['endpoint_key'=>'tratamiento_historico_bot_dn','date_mode'=>'last_30','filename'=>'historico_tratamientos.pdf','caption'=>'Histórico Tratamientos — últimos 30 días']],
|
||||||
|
|
||||||
// ciclo_mantenimiento → selección dinámica de grupo (top3 + "Otro")
|
// ciclo_mantenimiento → selección dinámica de grupo (top3 + "Otro")
|
||||||
'ciclo_mantenimiento' => [
|
'ciclo_mantenimiento' => [
|
||||||
'type' => 'dynamic_list',
|
'type' => 'dynamic_list',
|
||||||
'source_endpoint_key' => 'mantenimiento_grupos_top3_dn',
|
'source_endpoint_key' => 'mantenimiento_grupos_top3_dn',
|
||||||
|
// Con entities del NLU se busca en el catálogo completo: "plateo" puede
|
||||||
|
// no estar entre los 3 grupos más frecuentes.
|
||||||
|
'source_endpoint_key_all' => 'mantenimiento_grupos_all_dn',
|
||||||
|
'back' => 'submenu_ciclos',
|
||||||
'value_field' => 'id',
|
'value_field' => 'id',
|
||||||
'label_field' => 'label',
|
'label_field' => 'label',
|
||||||
'header' => '🔧 Mantenimiento',
|
'header' => '🔧 Mantenimiento',
|
||||||
@@ -291,8 +381,10 @@ $configJson = [
|
|||||||
'section_title' => 'Grupos frecuentes',
|
'section_title' => 'Grupos frecuentes',
|
||||||
'meta_group' => 'grupo_mant',
|
'meta_group' => 'grupo_mant',
|
||||||
'meta_key' => 'grupo_id',
|
'meta_key' => 'grupo_id',
|
||||||
|
'entity_key' => 'grupo',
|
||||||
'next_node' => 'submenu_ciclo_mantenimiento',
|
'next_node' => 'submenu_ciclo_mantenimiento',
|
||||||
'append_options' => [['id' => 'select_grupo_mant_otros', 'title' => '➕ Otro grupo']],
|
'append_options' => [['id' => 'select_grupo_mant_otros', 'title' => '➕ Otro grupo']],
|
||||||
|
'nlu_description' => 'Informes de mantenimiento de un grupo (plateo, corona, poda...) — usar cuando NO dice qué informe quiere',
|
||||||
],
|
],
|
||||||
'select_grupo_mant_otros' => [
|
'select_grupo_mant_otros' => [
|
||||||
'type' => 'dynamic_list',
|
'type' => 'dynamic_list',
|
||||||
@@ -305,20 +397,403 @@ $configJson = [
|
|||||||
'meta_group' => 'grupo_mant',
|
'meta_group' => 'grupo_mant',
|
||||||
'meta_key' => 'grupo_id',
|
'meta_key' => 'grupo_id',
|
||||||
'next_node' => 'submenu_ciclo_mantenimiento',
|
'next_node' => 'submenu_ciclo_mantenimiento',
|
||||||
|
'nlu_skip' => true,
|
||||||
],
|
],
|
||||||
|
|
||||||
// Sub-opciones: mantenimiento
|
// Sub-opciones: mantenimiento.
|
||||||
'ciclo_mantenimiento_top' => ['type'=>'function','function'=>'api_report','params'=>['endpoint_key'=>'mantenimiento_top_bot_dn','date_mode'=>'today','filename'=>'mantenimiento_top.pdf','caption'=>'Mantenimiento — lotes con ciclos más largos']],
|
// requires/resolver: si el grupo aún no está elegido, el bot lo pide primero
|
||||||
'ciclo_mantenimiento_todos' => ['type'=>'function','function'=>'api_report','params'=>['endpoint_key'=>'mantenimiento_todos_bot_dn','date_mode'=>'today','filename'=>'mantenimiento_todos.pdf','caption'=>'Mantenimiento — todos los lotes']],
|
// y al resolverlo vuelve a este informe (no al submenú).
|
||||||
'ciclo_mantenimiento_historico'=> ['type'=>'function','function'=>'api_report','params'=>['endpoint_key'=>'mantenimiento_todos_bot_dn','date_mode'=>'last_30','filename'=>'mantenimiento_historico.pdf','caption'=>'Mantenimiento — histórico 30 días']],
|
'ciclo_mantenimiento_top' => [
|
||||||
|
'type'=>'function','function'=>'api_report',
|
||||||
|
'requires'=>['grupo_mant'=>'grupo_id'], 'resolver'=>'ciclo_mantenimiento',
|
||||||
|
'back'=>'submenu_ciclo_mantenimiento',
|
||||||
|
'nlu_description'=>'Lotes MÁS ATRASADOS de un grupo de mantenimiento — solo si pide los más largos o atrasados',
|
||||||
|
'params'=>['endpoint_key'=>'mantenimiento_top_texto_bot_dn','date_mode'=>'today'],
|
||||||
|
],
|
||||||
|
'ciclo_mantenimiento_todos' => [
|
||||||
|
'type'=>'function','function'=>'api_report',
|
||||||
|
'requires'=>['grupo_mant'=>'grupo_id'], 'resolver'=>'ciclo_mantenimiento',
|
||||||
|
'back'=>'submenu_ciclo_mantenimiento',
|
||||||
|
'nlu_description'=>'PDF con TODOS los lotes de un grupo de mantenimiento — solo si pide explícitamente todos',
|
||||||
|
'params'=>['endpoint_key'=>'mantenimiento_todos_bot_dn','date_mode'=>'today','filename'=>'mantenimiento_todos.pdf','caption'=>'Mantenimiento — todos los lotes'],
|
||||||
|
],
|
||||||
|
|
||||||
|
// ── Carga de datos (POST) — Fase 1 ──────────────────────────────────
|
||||||
|
// Palmas360 decide si auto-aprueba o encola para revisión; el bot solo
|
||||||
|
// envía. Campos opcionales del contrato (eps, diagnostico_id) quedan
|
||||||
|
// fuera hasta confirmar qué novedades son incapacidad.
|
||||||
|
'registrar_ausentismo' => [
|
||||||
|
'type' => 'collect_and_post',
|
||||||
|
'endpoint_key' => 'ausentismos_up',
|
||||||
|
'header' => '👥 Registrar ausentismo',
|
||||||
|
'confirm' => true,
|
||||||
|
'success_text' => '✅ Ausentismo enviado a Palmas360.',
|
||||||
|
'back' => 'enviar_informacion',
|
||||||
|
'nlu_description' => 'Registrar un ausentismo, incapacidad o permiso de un trabajador',
|
||||||
|
'fields' => [
|
||||||
|
[
|
||||||
|
'key' => 'empleado_id',
|
||||||
|
'label' => 'Trabajador',
|
||||||
|
'type' => 'lookup',
|
||||||
|
// Numero vinculado a un trabajador: no se le pregunta
|
||||||
|
'from_phone' => 'tercero_id',
|
||||||
|
'prompt' => '👤 Escribe el nombre o documento del trabajador:',
|
||||||
|
'lookup_endpoint_key' => 'empleados_buscar_dn',
|
||||||
|
'lookup_param' => 'filtro',
|
||||||
|
'value_field' => 'id',
|
||||||
|
'display_field' => 'nombre',
|
||||||
|
'not_found_text' => '⚠️ No encontré ese trabajador. Escribe el nombre de nuevo:',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
// Primero el grupo general: 17 motivos en una sola lista
|
||||||
|
// llegaban con el nombre cortado y costaban de entender
|
||||||
|
'key' => 'grupo_novedad',
|
||||||
|
// Al cambiarlo se vuelve a preguntar: el motivo pertenece al tipo elegido
|
||||||
|
'invalida' => ['novedad_id'],
|
||||||
|
'label' => 'Tipo',
|
||||||
|
'type' => 'select',
|
||||||
|
'prompt' => '¿Qué tipo de ausentismo?',
|
||||||
|
'source_endpoint_key' => 'novedades_grupos_dn',
|
||||||
|
'value_field' => 'id',
|
||||||
|
'label_field' => 'nombre',
|
||||||
|
'desc_field' => 'descripcion',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'novedad_id',
|
||||||
|
'label' => 'Motivo',
|
||||||
|
'type' => 'select',
|
||||||
|
'prompt' => '¿Cuál es el motivo?',
|
||||||
|
'source_endpoint_key' => 'novedades_ausentismo_dn',
|
||||||
|
'desc_field' => 'descripcion',
|
||||||
|
'value_field' => 'id',
|
||||||
|
'label_field' => 'nombre',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'fecha_inicial',
|
||||||
|
'label' => 'Desde',
|
||||||
|
'type' => 'date_quick',
|
||||||
|
'prompt' => '📅 ¿Desde qué fecha?',
|
||||||
|
'other_validate' => 'date',
|
||||||
|
'other_prompt' => 'Escribe la fecha de inicio (YYYY-MM-DD):',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'fecha_final',
|
||||||
|
'label' => 'Hasta',
|
||||||
|
'type' => 'date_quick',
|
||||||
|
// Una novedad termina hoy o mas adelante, nunca anteayer
|
||||||
|
'quick' => 'futuro',
|
||||||
|
'prompt' => '📅 ¿Hasta qué fecha? (si es un solo día, elige la misma)',
|
||||||
|
'other_validate' => 'date',
|
||||||
|
'other_prompt' => 'Escribe la fecha final (YYYY-MM-DD):',
|
||||||
|
'min_field' => 'fecha_inicial',
|
||||||
|
'min_error' => '⚠️ La fecha final no puede ser anterior a la inicial. Elige otra:',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
// Pluviometría: un valor de mm por finca, todas contra la misma fecha.
|
||||||
|
// exclude_values saca el "🌐 Todas las fincas" (id 0) que el catálogo
|
||||||
|
// antepone para los informes y que acá no es una finca real.
|
||||||
|
'registrar_pluviometria' => [
|
||||||
|
'type' => 'collect_for_each',
|
||||||
|
'source_endpoint_key' => 'fincas_dn',
|
||||||
|
'endpoint_key' => 'pluvio_upload',
|
||||||
|
'value_field' => 'id',
|
||||||
|
'label_field' => 'label',
|
||||||
|
'exclude_values' => ['0'],
|
||||||
|
'ask_date' => true,
|
||||||
|
'date_question' => '📅 ¿De qué fecha es la lectura?',
|
||||||
|
'question' => '🌧️ ¿Cuántos milímetros?',
|
||||||
|
'header' => '🌧️ Pluviometría',
|
||||||
|
'items_key' => 'fincas',
|
||||||
|
'success_text' => '✅ Pluviometría enviada a Palmas360.',
|
||||||
|
'back' => 'enviar_informacion',
|
||||||
|
'nlu_description' => 'Registrar pluviometría, lluvia o milímetros por finca',
|
||||||
|
],
|
||||||
|
|
||||||
|
// Apertura y cierre de lotes, para los seis tipos de ciclo.
|
||||||
|
// Mantenimiento no va acá: no tiene tabla de ciclos ni apertura/cierre,
|
||||||
|
// es una labor. El orden sigue el de la pantalla web.
|
||||||
|
'registrar_ciclo' => [
|
||||||
|
'type' => 'collect_and_post',
|
||||||
|
'endpoint_key' => 'ciclos_up',
|
||||||
|
'header' => '🔄 Ciclos',
|
||||||
|
'confirm' => true,
|
||||||
|
'success_text' => '✅ Enviado a Palmas360.',
|
||||||
|
'back' => 'enviar_informacion',
|
||||||
|
'nlu_description' => 'Abrir o cerrar ciclos de lotes (cosecha, sanidad, censo, palmarum, tratamiento, polinización)',
|
||||||
|
'fields' => [
|
||||||
|
[
|
||||||
|
'key' => 'ciclo',
|
||||||
|
'label' => 'Ciclo',
|
||||||
|
'type' => 'static_select',
|
||||||
|
'prompt' => '¿Qué ciclo vas a registrar?',
|
||||||
|
'options' => [
|
||||||
|
['id' => 'cosecha', 'label' => '🌾 Cosecha'],
|
||||||
|
['id' => 'polinizacion', 'label' => '🌸 Polinización'],
|
||||||
|
['id' => 'sanidad', 'label' => '🐛 Plagas'],
|
||||||
|
['id' => 'censo', 'label' => '📝 Censo'],
|
||||||
|
['id' => 'palm', 'label' => '🌴 Palmarum'],
|
||||||
|
['id' => 'tratamiento', 'label' => '💊 Tratamiento'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'accion',
|
||||||
|
'label' => 'Acción',
|
||||||
|
'type' => 'static_select',
|
||||||
|
'prompt' => '¿Qué vas a hacer?',
|
||||||
|
'options' => [
|
||||||
|
['id' => 'apertura', 'label' => '📂 Abrir lotes'],
|
||||||
|
['id' => 'cierre', 'label' => '📁 Cerrar lotes'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'fecha',
|
||||||
|
'label' => 'Fecha',
|
||||||
|
'type' => 'date_quick',
|
||||||
|
'prompt' => '📅 ¿De qué fecha?',
|
||||||
|
'other_validate' => 'date',
|
||||||
|
'other_prompt' => 'Escribe la fecha (YYYY-MM-DD):',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'lote_ids',
|
||||||
|
'label' => 'Lotes',
|
||||||
|
'type' => 'multi_select',
|
||||||
|
'prompt' => 'Elige los lotes (puedes marcar varios):',
|
||||||
|
// Apertura ofrece los lotes de la finca; cierre, solo los abiertos
|
||||||
|
'source_endpoint_key' => 'lotes_{accion}_dn',
|
||||||
|
// "Otro lote" cambia al listado completo
|
||||||
|
'full_endpoint_key' => 'lotes_{accion}_todos_dn',
|
||||||
|
'value_field' => 'id',
|
||||||
|
'label_field' => 'label',
|
||||||
|
'empty_text' => '⚠️ No hay lotes disponibles para esta combinación.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
// Mantenimiento no abre ni cierra ciclos —no tiene tabla de ciclos—:
|
||||||
|
// se registra avance sobre lotes que ya tienen cantidad pendiente.
|
||||||
|
'registrar_mantenimiento' => [
|
||||||
|
'type' => 'collect_and_post',
|
||||||
|
'endpoint_key' => 'labores_up',
|
||||||
|
'header' => '🔧 Mantenimiento',
|
||||||
|
'confirm' => true,
|
||||||
|
'success_text' => '✅ Enviado a Palmas360.',
|
||||||
|
'back' => 'enviar_informacion',
|
||||||
|
'requires' => ['grupo_mant' => 'grupo_id'],
|
||||||
|
'resolver' => 'ciclo_mantenimiento',
|
||||||
|
'nlu_description' => 'Registrar avance de mantenimiento: plateo, corona, poda',
|
||||||
|
'fields' => [
|
||||||
|
[
|
||||||
|
'key' => 'fecha',
|
||||||
|
'label' => 'Fecha',
|
||||||
|
'type' => 'date_quick',
|
||||||
|
'prompt' => '📅 ¿De qué fecha?',
|
||||||
|
'other_validate' => 'date',
|
||||||
|
'other_prompt' => 'Escribe la fecha (YYYY-MM-DD):',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'lote_id',
|
||||||
|
|
||||||
|
'label' => 'Lote',
|
||||||
|
'type' => 'select',
|
||||||
|
'prompt' => 'Elige el lote (entre paréntesis va lo que falta):',
|
||||||
|
'source_endpoint_key' => 'lotes_mantenimiento_dn',
|
||||||
|
'value_field' => 'id',
|
||||||
|
'label_field' => 'label',
|
||||||
|
'empty_text' => '⚠️ No hay lotes con cantidad pendiente para ese grupo.',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
// labores_up exige la novedad concreta, no el grupo: se
|
||||||
|
// ofrecen solo las del grupo ya elegido
|
||||||
|
'key' => 'novedad_id',
|
||||||
|
'label' => 'Labor',
|
||||||
|
'type' => 'select',
|
||||||
|
'prompt' => '¿Qué labor de mantenimiento?',
|
||||||
|
'source_endpoint_key' => 'novedades_mant_dn',
|
||||||
|
'value_field' => 'id',
|
||||||
|
'label_field' => 'nombre',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'empleados',
|
||||||
|
'label' => 'Trabajadores',
|
||||||
|
'type' => 'multi_select',
|
||||||
|
'prompt' => '¿Quiénes lo hicieron? (puedes marcar varios)',
|
||||||
|
'source_endpoint_key' => 'empleados_labor_dn',
|
||||||
|
'value_field' => 'id',
|
||||||
|
'label_field' => 'nombre',
|
||||||
|
'from_phone' => 'tercero_id',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'cantidad',
|
||||||
|
'label' => 'Cantidad',
|
||||||
|
'type' => 'text',
|
||||||
|
'prompt' => '¿Qué cantidad avanzaste?',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
// Labores diarias sin validaciones: tractorista, transporte, horas
|
||||||
|
// extra. El ERP filtra el catalogo de novedades, el bot no decide cual
|
||||||
|
// corresponde. A quien ya tiene jornal ese dia se le registra con
|
||||||
|
// jornal 0 y el ERP lo avisa en la respuesta.
|
||||||
|
'registrar_labor' => [
|
||||||
|
'type' => 'collect_and_post',
|
||||||
|
'endpoint_key' => 'labores_up',
|
||||||
|
'header' => '💪 Labor diaria',
|
||||||
|
'confirm' => true,
|
||||||
|
'success_text' => '✅ Enviado a Palmas360.',
|
||||||
|
'back' => 'enviar_informacion',
|
||||||
|
'nlu_description' => 'Registrar una labor diaria: tractorista, transporte, horas extra, labores varias',
|
||||||
|
'fields' => [
|
||||||
|
[
|
||||||
|
'key' => 'fecha',
|
||||||
|
'label' => 'Fecha',
|
||||||
|
'type' => 'date_quick',
|
||||||
|
'prompt' => '📅 ¿De qué fecha es la labor?',
|
||||||
|
'other_validate' => 'date',
|
||||||
|
'other_prompt' => 'Escribe la fecha (YYYY-MM-DD):',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'novedad_id',
|
||||||
|
'label' => 'Labor',
|
||||||
|
'type' => 'select',
|
||||||
|
'prompt' => '¿Qué labor se hizo?',
|
||||||
|
'source_endpoint_key' => 'novedades_labor_dn',
|
||||||
|
'value_field' => 'id',
|
||||||
|
'label_field' => 'nombre',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'lote_id',
|
||||||
|
|
||||||
|
'label' => 'Lote',
|
||||||
|
'type' => 'select',
|
||||||
|
'prompt' => '¿En qué lote?',
|
||||||
|
'source_endpoint_key' => 'lotes_finca_dn',
|
||||||
|
'value_field' => 'id',
|
||||||
|
'label_field' => 'label',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'empleados',
|
||||||
|
'label' => 'Trabajadores',
|
||||||
|
'type' => 'multi_select',
|
||||||
|
'prompt' => '¿Quiénes la hicieron? (puedes marcar varios)',
|
||||||
|
'source_endpoint_key' => 'empleados_labor_dn',
|
||||||
|
'value_field' => 'id',
|
||||||
|
'label_field' => 'nombre',
|
||||||
|
// El trabajador vinculado se pre-llena; el supervisor elige
|
||||||
|
'from_phone' => 'tercero_id',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'cantidad',
|
||||||
|
'label' => 'Cantidad',
|
||||||
|
'type' => 'text',
|
||||||
|
'prompt' => '¿Qué cantidad? (jornales, horas o unidades según la labor)',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
// Fase 3: labores con validacion previa. Un solo flujo con campos
|
||||||
|
// condicionales en vez de tres: cosecha no lleva producto, y el pase
|
||||||
|
// es solo de polinizacion. El ERP valida inventario y autorizaciones,
|
||||||
|
// y si rechaza algo lo dice en la respuesta.
|
||||||
|
'registrar_labor_validada' => [
|
||||||
|
'type' => 'collect_and_post',
|
||||||
|
'endpoint_key' => 'labores_up',
|
||||||
|
'header' => '🌾 Cosecha, polinización y fertilización',
|
||||||
|
'confirm' => true,
|
||||||
|
'success_text' => '✅ Enviado a Palmas360.',
|
||||||
|
'back' => 'enviar_informacion',
|
||||||
|
'nlu_description' => 'Registrar labor de cosecha, polinización o fertilización',
|
||||||
|
'fields' => [
|
||||||
|
[
|
||||||
|
'key' => 'tipo',
|
||||||
|
// Al cambiarlo se vuelve a preguntar: el catálogo de labores
|
||||||
|
// y el producto dependen del tipo elegido
|
||||||
|
'invalida' => ['novedad_id', 'producto_id'],
|
||||||
|
'label' => 'Tipo',
|
||||||
|
'type' => 'static_select',
|
||||||
|
'prompt' => '¿Qué vas a registrar?',
|
||||||
|
'options' => [
|
||||||
|
['id' => 'cosecha', 'label' => '🌾 Cosecha'],
|
||||||
|
['id' => 'polinizacion', 'label' => '🌸 Polinización'],
|
||||||
|
['id' => 'fertilizacion','label' => '🧪 Fertilización'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'novedad_id',
|
||||||
|
'label' => 'Labor',
|
||||||
|
'type' => 'select',
|
||||||
|
'prompt' => '¿Qué labor se hizo?',
|
||||||
|
'source_endpoint_key' => 'novedades_labor3_dn',
|
||||||
|
'value_field' => 'id',
|
||||||
|
'label_field' => 'nombre',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'fecha',
|
||||||
|
'label' => 'Fecha',
|
||||||
|
'type' => 'date_quick',
|
||||||
|
'prompt' => '📅 ¿De qué fecha?',
|
||||||
|
'other_validate' => 'date',
|
||||||
|
'other_prompt' => 'Escribe la fecha (YYYY-MM-DD):',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'lote_id',
|
||||||
|
// Al cambiarlo se vuelve a preguntar: la dosis sale de la fertilizacion de ese lote
|
||||||
|
'invalida' => ['producto_id'],
|
||||||
|
'label' => 'Lote',
|
||||||
|
'type' => 'select',
|
||||||
|
'prompt' => '¿En qué lote?',
|
||||||
|
'source_endpoint_key' => 'lotes_finca_dn',
|
||||||
|
'value_field' => 'id',
|
||||||
|
'label_field' => 'label',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
// Cosecha no lleva producto. La dosis sale de la
|
||||||
|
// fertilizacion autorizada para ese lote, por eso se
|
||||||
|
// pregunta despues del lote y no antes.
|
||||||
|
'key' => 'producto_id',
|
||||||
|
'label' => 'Producto',
|
||||||
|
'type' => 'select',
|
||||||
|
'prompt' => '¿Qué producto se aplicó?',
|
||||||
|
'source_endpoint_key' => 'productos_fertilizacion_dn',
|
||||||
|
'value_field' => 'id',
|
||||||
|
'label_field' => 'nombre',
|
||||||
|
'skip_if' => ['field' => 'tipo', 'not_in' => ['polinizacion', 'fertilizacion']],
|
||||||
|
'empty_text' => '⚠️ Ese lote no tiene fertilización autorizada pendiente.',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'empleados',
|
||||||
|
'label' => 'Trabajadores',
|
||||||
|
'type' => 'multi_select',
|
||||||
|
'prompt' => '¿Quiénes la hicieron? (puedes marcar varios)',
|
||||||
|
'source_endpoint_key' => 'empleados_labor_dn',
|
||||||
|
'value_field' => 'id',
|
||||||
|
'label_field' => 'nombre',
|
||||||
|
'from_phone' => 'tercero_id',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'cantidad',
|
||||||
|
'label' => 'Cantidad',
|
||||||
|
'type' => 'text',
|
||||||
|
'prompt' => '¿Qué cantidad?',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'fase_polinizacion',
|
||||||
|
'label' => 'Pase',
|
||||||
|
'type' => 'text',
|
||||||
|
'prompt' => '¿Qué pase de polinización?',
|
||||||
|
'skip_if' => ['field' => 'tipo', 'equals' => 'polinizacion'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
// Upload
|
// Upload
|
||||||
'enviar_informacion' => ['type' => 'menu', 'menu' => 'submenu_subir_ciclos'],
|
'enviar_informacion' => ['type' => 'menu', 'menu' => 'submenu_subir_ciclos', 'nlu_skip' => true, 'back' => 'show_main_menu'],
|
||||||
'subir_ciclo_cosecha' => ['type' => 'function', 'function' => 'upload_ciclo', 'params' => ['ciclo' => 'cosecha']],
|
|
||||||
|
|
||||||
// Alias globales
|
// Alias globales (nlu_skip: son alias de navegación)
|
||||||
'show_main_menu' => ['type' => 'menu', 'menu' => 'show_menu_cat2'],
|
'show_main_menu' => ['type' => 'menu', 'menu' => 'show_menu_cat2', 'nlu_skip' => true],
|
||||||
'descargar_informes' => ['type' => 'menu', 'menu' => 'show_menu_cat2'],
|
'descargar_informes' => ['type' => 'menu', 'menu' => 'show_menu_cat2', 'nlu_skip' => true, 'back' => 'show_main_menu'],
|
||||||
],
|
],
|
||||||
|
|
||||||
// ── Per-type config ─────────────────────────────────────────────────────
|
// ── Per-type config ─────────────────────────────────────────────────────
|
||||||
@@ -342,7 +817,12 @@ $configJson = [
|
|||||||
[
|
[
|
||||||
'title' => 'Opciones',
|
'title' => 'Opciones',
|
||||||
'rows' => [
|
'rows' => [
|
||||||
['id' => 'subir_ciclo_cosecha', 'title' => '🌾 Subir cosecha', 'description' => 'Enviar ciclo de cosecha'],
|
['id' => 'registrar_ausentismo', 'modulo' => 'ausentismos', 'title' => '👥 Ausentismo', 'description' => 'Registrar falta, incapacidad o permiso'],
|
||||||
|
['id' => 'registrar_pluviometria','modulo' => 'pluviometria', 'title' => '🌧️ Pluviometría', 'description' => 'Milímetros de lluvia por finca'],
|
||||||
|
['id' => 'registrar_ciclo', 'modulo' => 'ciclos', 'title' => '🔄 Ciclos', 'description' => 'Abrir o cerrar lotes'],
|
||||||
|
['id' => 'registrar_mantenimiento','modulo' => 'mantenimiento', 'title' => '🔧 Mantenimiento', 'description' => 'Avance sobre lotes pendientes'],
|
||||||
|
['id' => 'registrar_labor', 'modulo' => 'labores', 'title' => '💪 Labor diaria', 'description' => 'Tractorista, transporte, horas extra'],
|
||||||
|
['id' => 'registrar_labor_validada','modulo' => 'labores', 'title' => '🌾 Cosecha y otros','description' => 'Cosecha, polinización, fertilización'],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -351,7 +831,6 @@ $configJson = [
|
|||||||
'flows' => [
|
'flows' => [
|
||||||
'show_menu_cat1' => ['type' => 'menu', 'menu' => 'show_menu_cat1'],
|
'show_menu_cat1' => ['type' => 'menu', 'menu' => 'show_menu_cat1'],
|
||||||
'show_main_menu' => ['type' => 'menu', 'menu' => 'show_menu_cat1'],
|
'show_main_menu' => ['type' => 'menu', 'menu' => 'show_menu_cat1'],
|
||||||
'subir_ciclo_cosecha' => ['type' => 'function', 'function' => 'upload_ciclo', 'params' => ['ciclo' => 'cosecha']],
|
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
@@ -359,7 +838,7 @@ $configJson = [
|
|||||||
'2' => [
|
'2' => [
|
||||||
'greeting_flow' => 'ask_finca',
|
'greeting_flow' => 'ask_finca',
|
||||||
'fallback_flow' => 'show_menu_cat2',
|
'fallback_flow' => 'show_menu_cat2',
|
||||||
'greeting' => null,
|
'greeting' => '',
|
||||||
'fallback' => null,
|
'fallback' => null,
|
||||||
'commands' => ['menu' => 'show_menu_cat2'],
|
'commands' => ['menu' => 'show_menu_cat2'],
|
||||||
'menus' => [
|
'menus' => [
|
||||||
@@ -373,9 +852,9 @@ $configJson = [
|
|||||||
[
|
[
|
||||||
'title' => 'Informes',
|
'title' => 'Informes',
|
||||||
'rows' => [
|
'rows' => [
|
||||||
['id' => 'submenu_produccion', 'title' => '🌿 Producción', 'description' => 'Informes de producción'],
|
['id' => 'submenu_produccion', 'modulo' => 'produccion', 'title' => '🌿 Producción', 'description' => 'Informes de producción'],
|
||||||
['id' => 'submenu_ciclos', 'title' => '🔄 Ciclos', 'description' => 'Ciclos de campo'],
|
['id' => 'submenu_ciclos', 'modulo' => 'ciclos', 'title' => '🔄 Ciclos', 'description' => 'Ciclos de campo'],
|
||||||
['id' => 'submenu_ausentismos', 'title' => '👥 Ausentismos', 'description' => 'Faltas y novedades'],
|
['id' => 'submenu_ausentismos', 'modulo' => 'ausentismos', 'title' => '👥 Ausentismos', 'description' => 'Faltas y novedades'],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -384,7 +863,7 @@ $configJson = [
|
|||||||
'flows' => [
|
'flows' => [
|
||||||
'show_menu_cat2' => ['type' => 'menu', 'menu' => 'show_menu_cat2'],
|
'show_menu_cat2' => ['type' => 'menu', 'menu' => 'show_menu_cat2'],
|
||||||
'show_main_menu' => ['type' => 'menu', 'menu' => 'show_menu_cat2'],
|
'show_main_menu' => ['type' => 'menu', 'menu' => 'show_menu_cat2'],
|
||||||
'descargar_informes' => ['type' => 'menu', 'menu' => 'show_menu_cat2'],
|
'descargar_informes' => ['type' => 'menu', 'menu' => 'show_menu_cat2', 'back' => 'show_main_menu'],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
@@ -392,14 +871,28 @@ $configJson = [
|
|||||||
'3' => [
|
'3' => [
|
||||||
'greeting_flow' => 'ask_finca',
|
'greeting_flow' => 'ask_finca',
|
||||||
'fallback_flow' => 'show_main_menu',
|
'fallback_flow' => 'show_main_menu',
|
||||||
'greeting' => null,
|
'greeting' => '',
|
||||||
'fallback' => null,
|
'fallback' => null,
|
||||||
'commands' => ['menu' => 'show_main_menu'],
|
'commands' => ['menu' => 'show_main_menu'],
|
||||||
'menus' => [],
|
'menus' => [
|
||||||
|
// Solo cat 3 puede consultar Y registrar, asi que "ausentismo" a
|
||||||
|
// secas es ambiguo unicamente para el. Cat 1 y cat 2 lo resuelve
|
||||||
|
// el filtro de permisos: cada uno ve una sola opcion.
|
||||||
|
'menu_ausentismo_que' => ['type' => 'button', 'body' => '¿Qué necesitas con los ausentismos?', 'buttons' => [
|
||||||
|
['id' => 'submenu_ausentismos', 'title' => '📊 Consultar'],
|
||||||
|
['id' => 'registrar_ausentismo', 'title' => '📝 Registrar'],
|
||||||
|
]],
|
||||||
|
],
|
||||||
'flows' => [
|
'flows' => [
|
||||||
|
'ausentismo' => [
|
||||||
|
'type' => 'menu',
|
||||||
|
'menu' => 'menu_ausentismo_que',
|
||||||
|
'back' => 'show_main_menu',
|
||||||
|
'nlu_description' => 'Ausentismos cuando no dice si quiere consultarlos o registrar uno',
|
||||||
|
],
|
||||||
'show_main_menu' => ['type' => 'menu', 'menu' => 'menu_modo_trabajo'],
|
'show_main_menu' => ['type' => 'menu', 'menu' => 'menu_modo_trabajo'],
|
||||||
'descargar_informes' => ['type' => 'menu', 'menu' => 'show_menu_cat2'],
|
'descargar_informes' => ['type' => 'menu', 'menu' => 'show_menu_cat2', 'back' => 'show_main_menu'],
|
||||||
'enviar_informacion' => ['type' => 'menu', 'menu' => 'submenu_subir_ciclos'],
|
'enviar_informacion' => ['type' => 'menu', 'menu' => 'submenu_subir_ciclos', 'back' => 'show_main_menu'],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -429,6 +922,36 @@ $endpoints = [
|
|||||||
['key' => 'tratamiento_historico_bot_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=tratamiento_historico_bot&finca_id={finca_id}'],
|
['key' => 'tratamiento_historico_bot_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=tratamiento_historico_bot&finca_id={finca_id}'],
|
||||||
['key' => 'fincas_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=fincas'],
|
['key' => 'fincas_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=fincas'],
|
||||||
['key' => 'numeros_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=whatsapp_numeros&estado=1'],
|
['key' => 'numeros_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=whatsapp_numeros&estado=1'],
|
||||||
|
|
||||||
|
// ── Carga de datos (POST) — Fase 1: Ausentismo ──────────────────────────
|
||||||
|
// El lookup agrega &filtro=<texto> a esta URL; estado=1 restringe a activos.
|
||||||
|
['key' => 'empleados_buscar_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=empleados&estado=1&items=10'],
|
||||||
|
['key' => 'novedades_grupos_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=novedades_ausentismo_dn&grupos=1'],
|
||||||
|
['key' => 'novedades_ausentismo_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=novedades_ausentismo_dn&grupo_novedad={grupo_novedad}'],
|
||||||
|
['key' => 'ausentismos_up', 'dir' => 'upload', 'url' => $BASE . '?peticion=ausentismos_up'],
|
||||||
|
['key' => 'pluvio_upload', 'dir' => 'upload', 'url' => $BASE . '?peticion=pluvio_upload'],
|
||||||
|
|
||||||
|
// ── Carga de ciclos ─────────────────────────────────────────────────────
|
||||||
|
// {ciclo} y {accion} se resuelven con lo que el usuario ya eligió, así los
|
||||||
|
// seis tipos y las dos acciones comparten estas tres entradas.
|
||||||
|
// Los 10 mas atrasados; con todos=1, el listado completo para "Otro lote"
|
||||||
|
['key' => 'lotes_apertura_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=lotes_apertura_dn&ciclo={ciclo}&finca_id={finca_id}'],
|
||||||
|
['key' => 'lotes_cierre_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=lotes_abiertos_dn&ciclo={ciclo}&finca_id={finca_id}'],
|
||||||
|
['key' => 'lotes_apertura_todos_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=lotes_apertura_dn&ciclo={ciclo}&finca_id={finca_id}&todos=1'],
|
||||||
|
['key' => 'lotes_cierre_todos_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=lotes_abiertos_dn&ciclo={ciclo}&finca_id={finca_id}&todos=1'],
|
||||||
|
['key' => 'ciclos_up', 'dir' => 'upload', 'url' => $BASE . '?peticion=ciclos_{ciclo}_up'],
|
||||||
|
// Mantenimiento: lotes con cantidad pendiente de asignar, no apertura/cierre
|
||||||
|
// Labores diarias — fase 2: las que no arrastran inventario ni autorizacion
|
||||||
|
['key' => 'novedades_labor_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=novedades_labor_dn&fase=2'],
|
||||||
|
['key' => 'lotes_finca_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=lotes_x_finca&finca_id={finca_id}'],
|
||||||
|
['key' => 'empleados_labor_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=empleados&estado=1&items=200'],
|
||||||
|
['key' => 'labores_up', 'dir' => 'upload', 'url' => $BASE . '?peticion=labores_up'],
|
||||||
|
// Fase 3: las que arrastran inventario o autorizacion previa
|
||||||
|
['key' => 'novedades_labor3_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=novedades_labor_dn&fase=3'],
|
||||||
|
['key' => 'productos_fertilizacion_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=productos_fertilizacion_dn&lote_id={lote_id}'],
|
||||||
|
['key' => 'novedades_mant_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=novedades_labor_dn&fase=3&grupo={grupo_id}'],
|
||||||
|
['key' => 'lotes_mantenimiento_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=lotes_mantenimiento_dn&grupo={grupo_id}&finca_id={finca_id}'],
|
||||||
|
['key' => 'lotes_mantenimiento_todos_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=lotes_mantenimiento_dn&grupo={grupo_id}&finca_id={finca_id}&todos=1'],
|
||||||
// Todos los lotes (PDF) por ciclo
|
// Todos los lotes (PDF) por ciclo
|
||||||
['key' => 'cosecha_todos_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=ciclos_cosecha_dn&finca_id={finca_id}'],
|
['key' => 'cosecha_todos_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=ciclos_cosecha_dn&finca_id={finca_id}'],
|
||||||
['key' => 'polinizacion_todos_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=ciclos_polinizacion_dn&finca_id={finca_id}'],
|
['key' => 'polinizacion_todos_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=ciclos_polinizacion_dn&finca_id={finca_id}'],
|
||||||
@@ -438,7 +961,9 @@ $endpoints = [
|
|||||||
['key' => 'tratamiento_todos_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=ciclos_tratamientos_dn&finca_id={finca_id}'],
|
['key' => 'tratamiento_todos_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=ciclos_tratamientos_dn&finca_id={finca_id}'],
|
||||||
['key' => 'mantenimiento_grupos_top3_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=grupos_mantenimiento&limit=3'],
|
['key' => 'mantenimiento_grupos_top3_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=grupos_mantenimiento&limit=3'],
|
||||||
['key' => 'mantenimiento_grupos_otros_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=grupos_mantenimiento&limit=10&offset=3'],
|
['key' => 'mantenimiento_grupos_otros_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=grupos_mantenimiento&limit=10&offset=3'],
|
||||||
|
['key' => 'mantenimiento_grupos_all_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=grupos_mantenimiento&limit=200'],
|
||||||
['key' => 'mantenimiento_top_bot_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=mantenimiento_dn&top_lotes=10&grupo={grupo_id}&finca_id={finca_id}'],
|
['key' => 'mantenimiento_top_bot_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=mantenimiento_dn&top_lotes=10&grupo={grupo_id}&finca_id={finca_id}'],
|
||||||
|
['key' => 'mantenimiento_top_texto_bot_dn','dir' => 'download', 'url' => $BASE . '?peticion=mantenimiento_top_texto_bot&grupo={grupo_id}&finca_id={finca_id}'],
|
||||||
['key' => 'mantenimiento_todos_bot_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=mantenimiento_dn&grupo={grupo_id}&finca_id={finca_id}'],
|
['key' => 'mantenimiento_todos_bot_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=mantenimiento_dn&grupo={grupo_id}&finca_id={finca_id}'],
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -451,7 +976,7 @@ $companies = [
|
|||||||
'display_name' => 'Rosa Blanca',
|
'display_name' => 'Rosa Blanca',
|
||||||
'phone_number_id' => '1168511569675820',
|
'phone_number_id' => '1168511569675820',
|
||||||
'display_phone' => '',
|
'display_phone' => '',
|
||||||
'api_base_url' => 'https://graph.facebook.com/v22.0',
|
'api_base_url' => 'https://app.palmas360.com/rosablanca', // ERP, no el Graph API (WhatsAppSender lo tiene hardcodeado)
|
||||||
'api_key' => 'EAAqbNQQekwUBRm8ZAAfCvXZAsxcM1BTkiHjVJYeOfXYCg5t4qYtBL7ap2IdHGa1iXclZBO47gZBshYZBAlQRuUvgSib2Owh7gPnAWfNlHXZAT61ccElXoihuXuEuy0qLiB95tDhLMWPqoKYuDwAHCU78RQxoi1nzz1OsOY40EK7U2tKZBVuZABQZCJctZBrLK7rRiC7AZDZD',
|
'api_key' => 'EAAqbNQQekwUBRm8ZAAfCvXZAsxcM1BTkiHjVJYeOfXYCg5t4qYtBL7ap2IdHGa1iXclZBO47gZBshYZBAlQRuUvgSib2Owh7gPnAWfNlHXZAT61ccElXoihuXuEuy0qLiB95tDhLMWPqoKYuDwAHCU78RQxoi1nzz1OsOY40EK7U2tKZBVuZABQZCJctZBrLK7rRiC7AZDZD',
|
||||||
'bot_type' => 'normal',
|
'bot_type' => 'normal',
|
||||||
'requires_approval' => 0,
|
'requires_approval' => 0,
|
||||||
@@ -463,7 +988,7 @@ $companies = [
|
|||||||
'display_name' => 'Palmas360 Test',
|
'display_name' => 'Palmas360 Test',
|
||||||
'phone_number_id' => '1168511569675820',
|
'phone_number_id' => '1168511569675820',
|
||||||
'display_phone' => '',
|
'display_phone' => '',
|
||||||
'api_base_url' => 'https://graph.facebook.com/v22.0',
|
'api_base_url' => 'https://app.palmas360.com/rosablanca', // ERP, no el Graph API (WhatsAppSender lo tiene hardcodeado)
|
||||||
'api_key' => 'EAAqbNQQekwUBRm8ZAAfCvXZAsxcM1BTkiHjVJYeOfXYCg5t4qYtBL7ap2IdHGa1iXclZBO47gZBshYZBAlQRuUvgSib2Owh7gPnAWfNlHXZAT61ccElXoihuXuEuy0qLiB95tDhLMWPqoKYuDwAHCU78RQxoi1nzz1OsOY40EK7U2tKZBVuZABQZCJctZBrLK7rRiC7AZDZD',
|
'api_key' => 'EAAqbNQQekwUBRm8ZAAfCvXZAsxcM1BTkiHjVJYeOfXYCg5t4qYtBL7ap2IdHGa1iXclZBO47gZBshYZBAlQRuUvgSib2Owh7gPnAWfNlHXZAT61ccElXoihuXuEuy0qLiB95tDhLMWPqoKYuDwAHCU78RQxoi1nzz1OsOY40EK7U2tKZBVuZABQZCJctZBrLK7rRiC7AZDZD',
|
||||||
'bot_type' => 'normal',
|
'bot_type' => 'normal',
|
||||||
'requires_approval' => 0,
|
'requires_approval' => 0,
|
||||||
@@ -471,10 +996,29 @@ $companies = [
|
|||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
$configJsonStr = json_encode($configJson, JSON_UNESCAPED_UNICODE);
|
|
||||||
|
|
||||||
// ─── Insert / update companies ────────────────────────────────────────────────
|
// ─── Insert / update companies ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* El seed solo es dueño de la estructura del bot (menús, flujos, comandos...).
|
||||||
|
* Todo lo demás que viva en config_json —modelos de IA, temperatura, prompt,
|
||||||
|
* whisper— lo configura el admin y debe sobrevivir a cada corrida.
|
||||||
|
*/
|
||||||
|
function configPreservando(PDO $db, int $companyId, array $configSeed): string
|
||||||
|
{
|
||||||
|
$stmt = $db->prepare("SELECT config_json FROM companies WHERE id = ? LIMIT 1");
|
||||||
|
$stmt->execute([$companyId]);
|
||||||
|
$actual = json_decode((string)$stmt->fetchColumn(), true);
|
||||||
|
|
||||||
|
if (is_array($actual)) {
|
||||||
|
$conservadas = array_diff_key($actual, $configSeed);
|
||||||
|
if ($conservadas) {
|
||||||
|
$configSeed = array_merge($configSeed, $conservadas);
|
||||||
|
echo " ↳ conservadas: " . implode(', ', array_keys($conservadas)) . "\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return json_encode($configSeed, JSON_UNESCAPED_UNICODE);
|
||||||
|
}
|
||||||
|
|
||||||
$stmtCo = $db->prepare("
|
$stmtCo = $db->prepare("
|
||||||
INSERT INTO companies (id, name, display_name, phone_number_id, display_phone, api_base_url, api_key, bot_type, requires_approval, is_active, config_json)
|
INSERT INTO companies (id, name, display_name, phone_number_id, display_phone, api_base_url, api_key, bot_type, requires_approval, is_active, config_json)
|
||||||
VALUES (:id, :name, :display_name, :phone_number_id, :display_phone, :api_base_url, :api_key, :bot_type, :requires_approval, :is_active, :config_json)
|
VALUES (:id, :name, :display_name, :phone_number_id, :display_phone, :api_base_url, :api_key, :bot_type, :requires_approval, :is_active, :config_json)
|
||||||
@@ -488,6 +1032,8 @@ $stmtCo = $db->prepare("
|
|||||||
");
|
");
|
||||||
|
|
||||||
foreach ($companies as $co) {
|
foreach ($companies as $co) {
|
||||||
|
echo "✔ Company {$co['id']} ({$co['display_name']})\n";
|
||||||
|
$cfg = configPreservando($db, (int)$co['id'], $configJson);
|
||||||
$stmtCo->execute([
|
$stmtCo->execute([
|
||||||
':id' => $co['id'],
|
':id' => $co['id'],
|
||||||
':name' => $co['name'],
|
':name' => $co['name'],
|
||||||
@@ -499,9 +1045,8 @@ foreach ($companies as $co) {
|
|||||||
':bot_type' => $co['bot_type'],
|
':bot_type' => $co['bot_type'],
|
||||||
':requires_approval'=> $co['requires_approval'],
|
':requires_approval'=> $co['requires_approval'],
|
||||||
':is_active' => $co['is_active'],
|
':is_active' => $co['is_active'],
|
||||||
':config_json' => $configJsonStr,
|
':config_json' => $cfg,
|
||||||
]);
|
]);
|
||||||
echo "✔ Company {$co['id']} ({$co['display_name']}) OK\n";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Insert / update endpoints ────────────────────────────────────────────────
|
// ─── Insert / update endpoints ────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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