feat: module toggles in POS config and Instance Manager
- Add GET/PUT /pos/api/config/modules endpoints in POS config_bp.py - Update sidebar.js to filter nav items based on enabled modules - Add Modules section to POS config.html with toggles for WhatsApp, Marketplace, MercadoLibre - Add module load/save logic to POS config.js - Preload modules in app-init.js for sidebar caching - Add tenant module management to Instance Manager - get_tenant_modules / update_tenant_modules in tenant_service.py - GET/PUT /api/tenants/<id>/modules endpoints in tenants_bp.py - Add modules modal to manager index.html - Add module editing UI and logic to manager.js - Add toggle-switch CSS to manager.css
This commit is contained in:
@@ -11,6 +11,9 @@ if not DB_URL:
|
|||||||
"Example: postgresql://user:pass@localhost/nexus_autoparts"
|
"Example: postgresql://user:pass@localhost/nexus_autoparts"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
MASTER_DB_URL = os.environ.get("MASTER_DB_URL") or DB_URL
|
||||||
|
TENANT_DB_URL_TEMPLATE = os.environ.get("TENANT_DB_URL_TEMPLATE") or DB_URL.replace("nexus_autoparts", "{db_name}")
|
||||||
|
|
||||||
# Legacy SQLite path (used only by migration script)
|
# Legacy SQLite path (used only by migration script)
|
||||||
SQLITE_PATH = os.path.join(
|
SQLITE_PATH = os.path.join(
|
||||||
os.path.dirname(os.path.abspath(__file__)),
|
os.path.dirname(os.path.abspath(__file__)),
|
||||||
|
|||||||
@@ -92,6 +92,14 @@
|
|||||||
<span class="badge" id="pendingUsersBadge" style="display:none; background:var(--warning); color:#000; font-size:0.7rem; padding:2px 6px; border-radius:10px; margin-left:auto;"></span>
|
<span class="badge" id="pendingUsersBadge" style="display:none; background:var(--warning); color:#000; font-size:0.7rem; padding:2px 6px; border-radius:10px; margin-left:auto;"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar-section">
|
||||||
|
<h3>Tenants</h3>
|
||||||
|
<div class="sidebar-item" data-section="tenants">
|
||||||
|
<span class="icon">🏢</span>
|
||||||
|
<span>Módulos</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<!-- Main Content -->
|
<!-- Main Content -->
|
||||||
@@ -660,6 +668,35 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Tenants / Modules Section -->
|
||||||
|
<section id="section-tenants" class="admin-section">
|
||||||
|
<div class="page-header">
|
||||||
|
<h1 class="page-title">Configuración de Módulos por Tenant</h1>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="card-title">Tenants Activos</h2>
|
||||||
|
</div>
|
||||||
|
<div style="overflow-x:auto;">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Nombre</th>
|
||||||
|
<th>WhatsApp</th>
|
||||||
|
<th>Marketplace</th>
|
||||||
|
<th>MercadoLibre</th>
|
||||||
|
<th>Acciones</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="tenantsTable">
|
||||||
|
<tr><td colspan="6" class="loading"><div class="spinner"></div></td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -121,6 +121,9 @@ function showSection(sectionId) {
|
|||||||
case 'users':
|
case 'users':
|
||||||
loadUsers();
|
loadUsers();
|
||||||
break;
|
break;
|
||||||
|
case 'tenants':
|
||||||
|
loadTenants();
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2074,3 +2077,99 @@ async function toggleUserActive(userId, currentActive) {
|
|||||||
showAlert(e.message, 'error');
|
showAlert(e.message, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Tenants / Modules ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function loadTenants() {
|
||||||
|
var token = localStorage.getItem('access_token');
|
||||||
|
var tbody = document.getElementById('tenantsTable');
|
||||||
|
tbody.innerHTML = '<tr><td colspan="6" class="loading"><div class="spinner"></div></td></tr>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
var res = await fetch('/api/admin/tenants', {
|
||||||
|
headers: { 'Authorization': 'Bearer ' + token }
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Error al cargar tenants (' + res.status + ')');
|
||||||
|
var data = await res.json();
|
||||||
|
var tenants = data.tenants || [];
|
||||||
|
|
||||||
|
if (tenants.length === 0) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center; color:var(--text-secondary); padding:2rem;">No hay tenants activos</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load modules for each tenant
|
||||||
|
var modulesMap = {};
|
||||||
|
await Promise.all(tenants.map(async function(t) {
|
||||||
|
try {
|
||||||
|
var mres = await fetch('/api/admin/tenants/' + t.id + '/modules', {
|
||||||
|
headers: { 'Authorization': 'Bearer ' + token }
|
||||||
|
});
|
||||||
|
if (mres.ok) {
|
||||||
|
modulesMap[t.id] = await mres.json();
|
||||||
|
} else {
|
||||||
|
modulesMap[t.id] = {};
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
modulesMap[t.id] = {};
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
renderTenantsTable(tenants, modulesMap);
|
||||||
|
} catch (e) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center; color:#ef4444; padding:2rem;">' + e.message + '</td></tr>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTenantsTable(tenants, modulesMap) {
|
||||||
|
var tbody = document.getElementById('tenantsTable');
|
||||||
|
tbody.innerHTML = tenants.map(function(t) {
|
||||||
|
var mods = modulesMap[t.id] || {};
|
||||||
|
function toggleBtn(tenantId, key, enabled) {
|
||||||
|
var label = enabled ? 'Activado' : 'Desactivado';
|
||||||
|
var cls = enabled ? 'btn-primary' : 'btn-secondary';
|
||||||
|
return '<button class="btn ' + cls + '" style="font-size:0.75rem; padding:3px 10px;" ' +
|
||||||
|
'onclick="toggleTenantModule(' + tenantId + ', \'' + key + '\', ' + enabled + ')">' + label + '</button>';
|
||||||
|
}
|
||||||
|
return '<tr>' +
|
||||||
|
'<td>' + t.id + '</td>' +
|
||||||
|
'<td>' + (t.name || '-') + '</td>' +
|
||||||
|
'<td>' + toggleBtn(t.id, 'whatsapp_enabled', !!mods.whatsapp_enabled) + '</td>' +
|
||||||
|
'<td>' + toggleBtn(t.id, 'marketplace_enabled', !!mods.marketplace_enabled) + '</td>' +
|
||||||
|
'<td>' + toggleBtn(t.id, 'meli_enabled', !!mods.meli_enabled) + '</td>' +
|
||||||
|
'<td><button class="btn btn-primary" style="font-size:0.75rem; padding:3px 10px;" onclick="loadTenants()">🔄 Recargar</button></td>' +
|
||||||
|
'</tr>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleTenantModule(tenantId, key, currentValue) {
|
||||||
|
var token = localStorage.getItem('access_token');
|
||||||
|
var moduleNames = {
|
||||||
|
'whatsapp_enabled': 'WhatsApp',
|
||||||
|
'marketplace_enabled': 'Marketplace',
|
||||||
|
'meli_enabled': 'MercadoLibre'
|
||||||
|
};
|
||||||
|
var action = currentValue ? 'desactivar' : 'activar';
|
||||||
|
if (!confirm('¿Seguro que deseas ' + action + ' ' + moduleNames[key] + ' para el tenant #' + tenantId + '?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
var payload = {};
|
||||||
|
payload[key] = !currentValue;
|
||||||
|
var res = await fetch('/api/admin/tenants/' + tenantId + '/modules', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': 'Bearer ' + token
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
var err = await res.json();
|
||||||
|
throw new Error(err.error || 'Error al actualizar módulo');
|
||||||
|
}
|
||||||
|
showAlert(moduleNames[key] + ' ' + (currentValue ? 'desactivado' : 'activado') + ' para tenant #' + tenantId);
|
||||||
|
loadTenants();
|
||||||
|
} catch (e) {
|
||||||
|
showAlert(e.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ sys.path.insert(0, os.path.join(_base, '..', 'pos')) # pos/ for auth, services
|
|||||||
sys.path.insert(0, os.path.join(_base, '..')) # root config.py (has DB_URL)
|
sys.path.insert(0, os.path.join(_base, '..')) # root config.py (has DB_URL)
|
||||||
from config import DB_URL
|
from config import DB_URL
|
||||||
from auth import hash_password, check_password, create_access_token, create_refresh_token, decode_token, require_auth
|
from auth import hash_password, check_password, create_access_token, create_refresh_token, decode_token, require_auth
|
||||||
|
from tenant_db import get_tenant_conn
|
||||||
from services.translations import translate_part_name, translate_category
|
from services.translations import translate_part_name, translate_category
|
||||||
|
|
||||||
sys.path.insert(0, os.path.join(_base, '..', 'pos'))
|
sys.path.insert(0, os.path.join(_base, '..', 'pos'))
|
||||||
@@ -4628,6 +4629,76 @@ def part_aftermarket(part_id):
|
|||||||
session.close()
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Tenant Module Config Endpoints
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
MODULE_CONFIG_KEYS = [
|
||||||
|
'whatsapp_enabled',
|
||||||
|
'marketplace_enabled',
|
||||||
|
'meli_enabled',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/admin/tenants')
|
||||||
|
def api_admin_tenants():
|
||||||
|
session = Session()
|
||||||
|
try:
|
||||||
|
rows = session.execute(text(
|
||||||
|
"SELECT id, name, db_name, is_active, is_seller FROM tenants WHERE is_active = true ORDER BY id"
|
||||||
|
)).mappings().all()
|
||||||
|
return jsonify({'tenants': [dict(r) for r in rows]})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/admin/tenants/<int:tenant_id>/modules')
|
||||||
|
def api_admin_tenant_modules(tenant_id):
|
||||||
|
try:
|
||||||
|
conn = get_tenant_conn(tenant_id)
|
||||||
|
cur = conn.cursor()
|
||||||
|
result = {}
|
||||||
|
for key in MODULE_CONFIG_KEYS:
|
||||||
|
cur.execute("SELECT value FROM tenant_config WHERE key = %s", (key,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
result[key] = (row[0] or '').lower() == 'true' if row else False
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
return jsonify(result)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/admin/tenants/<int:tenant_id>/modules', methods=['PUT'])
|
||||||
|
def api_admin_tenant_modules_update(tenant_id):
|
||||||
|
data = request.get_json() or {}
|
||||||
|
if not data:
|
||||||
|
return jsonify({'error': 'No data provided'}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
conn = get_tenant_conn(tenant_id)
|
||||||
|
cur = conn.cursor()
|
||||||
|
for key, value in data.items():
|
||||||
|
if key not in MODULE_CONFIG_KEYS:
|
||||||
|
continue
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO tenant_config (key, value, updated_at)
|
||||||
|
VALUES (%s, %s, NOW())
|
||||||
|
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
|
||||||
|
""",
|
||||||
|
(key, 'true' if value else 'false'),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
return jsonify({'ok': True})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Static files from dashboard root (CSS/JS/HTML)
|
# Static files from dashboard root (CSS/JS/HTML)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -58,3 +58,24 @@ def delete_tenant(tenant_id):
|
|||||||
return jsonify(result)
|
return jsonify(result)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@tenants_bp.route("/<int:tenant_id>/modules", methods=["GET"])
|
||||||
|
@require_manager_auth
|
||||||
|
def get_tenant_modules(tenant_id):
|
||||||
|
try:
|
||||||
|
result = tenant_service.get_tenant_modules(tenant_id)
|
||||||
|
return jsonify({"data": result})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@tenants_bp.route("/<int:tenant_id>/modules", methods=["PUT"])
|
||||||
|
@require_manager_auth
|
||||||
|
def update_tenant_modules(tenant_id):
|
||||||
|
data = request.get_json() or {}
|
||||||
|
try:
|
||||||
|
result = tenant_service.update_tenant_modules(tenant_id, data)
|
||||||
|
return jsonify(result)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|||||||
@@ -311,6 +311,55 @@ def get_tenant_login_url(subdomain):
|
|||||||
return f"https://{subdomain}.{domain}/pos/login"
|
return f"https://{subdomain}.{domain}/pos/login"
|
||||||
|
|
||||||
|
|
||||||
|
def get_tenant_modules(tenant_id):
|
||||||
|
"""Get enabled modules for a tenant from tenant_config."""
|
||||||
|
tenant = get_tenant(tenant_id)
|
||||||
|
if not tenant:
|
||||||
|
raise ValueError("Tenant not found")
|
||||||
|
db_name = tenant["db_name"]
|
||||||
|
dsn = TENANT_DB_URL_TEMPLATE.format(db_name=db_name)
|
||||||
|
conn = psycopg2.connect(dsn)
|
||||||
|
cur = conn.cursor()
|
||||||
|
try:
|
||||||
|
modules = {}
|
||||||
|
for key in ["module_whatsapp", "module_marketplace", "module_meli"]:
|
||||||
|
cur.execute("SELECT value FROM tenant_config WHERE key = %s", (key,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
modules[key.replace("module_", "")] = (row[0] or "").lower() == "true" if row else True
|
||||||
|
return modules
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def update_tenant_modules(tenant_id, modules):
|
||||||
|
"""Update enabled modules for a tenant in tenant_config."""
|
||||||
|
tenant = get_tenant(tenant_id)
|
||||||
|
if not tenant:
|
||||||
|
raise ValueError("Tenant not found")
|
||||||
|
db_name = tenant["db_name"]
|
||||||
|
dsn = TENANT_DB_URL_TEMPLATE.format(db_name=db_name)
|
||||||
|
conn = psycopg2.connect(dsn)
|
||||||
|
cur = conn.cursor()
|
||||||
|
try:
|
||||||
|
key_map = {
|
||||||
|
"whatsapp": "module_whatsapp",
|
||||||
|
"marketplace": "module_marketplace",
|
||||||
|
"meli": "module_meli",
|
||||||
|
}
|
||||||
|
for field, key in key_map.items():
|
||||||
|
value = "true" if modules.get(field) else "false"
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO tenant_config (key, value) VALUES (%s, %s)
|
||||||
|
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
||||||
|
""", (key, value))
|
||||||
|
conn.commit()
|
||||||
|
return {"success": True, "tenant_id": tenant_id, "modules": modules}
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
def get_dashboard_stats():
|
def get_dashboard_stats():
|
||||||
"""Global stats for the manager dashboard."""
|
"""Global stats for the manager dashboard."""
|
||||||
conn = get_master_conn()
|
conn = get_master_conn()
|
||||||
|
|||||||
@@ -661,3 +661,42 @@ body {
|
|||||||
.sidebar-brand span, .nav-item span, .user-info span { display: none; }
|
.sidebar-brand span, .nav-item span, .user-info span { display: none; }
|
||||||
.stats-grid { grid-template-columns: 1fr; }
|
.stats-grid { grid-template-columns: 1fr; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Toggle switch for modules modal */
|
||||||
|
.toggle-switch {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
width: 44px;
|
||||||
|
height: 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.toggle-switch input {
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
.toggle-slider {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 24px;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.toggle-slider::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
height: 18px;
|
||||||
|
width: 18px;
|
||||||
|
left: 3px;
|
||||||
|
bottom: 3px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
.toggle-switch input:checked + .toggle-slider {
|
||||||
|
background: var(--success);
|
||||||
|
}
|
||||||
|
.toggle-switch input:checked + .toggle-slider::before {
|
||||||
|
transform: translateX(20px);
|
||||||
|
}
|
||||||
|
|||||||
@@ -188,6 +188,7 @@ async function loadDemos() {
|
|||||||
<td><a href="https://${escapeHtml(d.subdomain)}.nexusautoparts.com.mx/pos/login" target="_blank" style="color:var(--accent)">${escapeHtml(d.subdomain)}</a></td>
|
<td><a href="https://${escapeHtml(d.subdomain)}.nexusautoparts.com.mx/pos/login" target="_blank" style="color:var(--accent)">${escapeHtml(d.subdomain)}</a></td>
|
||||||
<td>${d.demo_days_left !== null ? d.demo_days_left + " días" : "N/A"}</td>
|
<td>${d.demo_days_left !== null ? d.demo_days_left + " días" : "N/A"}</td>
|
||||||
<td>
|
<td>
|
||||||
|
<button class="btn-icon" onclick="openModulesModal(${d.id}, '${escapeHtml(d.name)}')" title="Módulos"><i class="fas fa-cubes" style="color:var(--accent)"></i></button>
|
||||||
<button class="btn-icon" onclick="resetTenant(${d.id})" title="Resetear"><i class="fas fa-undo"></i></button>
|
<button class="btn-icon" onclick="resetTenant(${d.id})" title="Resetear"><i class="fas fa-undo"></i></button>
|
||||||
<button class="btn-icon" onclick="toggleTenant(${d.id}, ${!d.is_active})" title="${d.is_active ? "Desactivar" : "Activar"}"><i class="fas fa-${d.is_active ? "pause" : "play"}"></i></button>
|
<button class="btn-icon" onclick="toggleTenant(${d.id}, ${!d.is_active})" title="${d.is_active ? "Desactivar" : "Activar"}"><i class="fas fa-${d.is_active ? "pause" : "play"}"></i></button>
|
||||||
<button class="btn-icon" onclick="confirmDelete(${d.id}, '${escapeHtml(d.name)}')" title="Eliminar"><i class="fas fa-trash" style="color:var(--danger)"></i></button>
|
<button class="btn-icon" onclick="confirmDelete(${d.id}, '${escapeHtml(d.name)}')" title="Eliminar"><i class="fas fa-trash" style="color:var(--danger)"></i></button>
|
||||||
@@ -254,6 +255,7 @@ async function loadTenants(withStats = false) {
|
|||||||
<td>${t.is_active ? tag("Activo", "success") : tag("Inactivo", "danger")}</td>
|
<td>${t.is_active ? tag("Activo", "success") : tag("Inactivo", "danger")}</td>
|
||||||
<td>${formatDate(t.created_at)}</td>
|
<td>${formatDate(t.created_at)}</td>
|
||||||
<td>
|
<td>
|
||||||
|
<button class="btn-icon" onclick="openModulesModal(${t.id}, '${escapeHtml(t.name)}')" title="Módulos"><i class="fas fa-cubes" style="color:var(--accent)"></i></button>
|
||||||
<button class="btn-icon" onclick="resetTenant(${t.id})" title="Resetear datos"><i class="fas fa-undo"></i></button>
|
<button class="btn-icon" onclick="resetTenant(${t.id})" title="Resetear datos"><i class="fas fa-undo"></i></button>
|
||||||
<button class="btn-icon" onclick="toggleTenant(${t.id}, ${!t.is_active})" title="${t.is_active ? "Desactivar" : "Activar"}"><i class="fas fa-${t.is_active ? "pause" : "play"}"></i></button>
|
<button class="btn-icon" onclick="toggleTenant(${t.id}, ${!t.is_active})" title="${t.is_active ? "Desactivar" : "Activar"}"><i class="fas fa-${t.is_active ? "pause" : "play"}"></i></button>
|
||||||
<button class="btn-icon" onclick="confirmDelete(${t.id}, '${escapeHtml(t.name)}')" title="Eliminar"><i class="fas fa-trash" style="color:var(--danger)"></i></button>
|
<button class="btn-icon" onclick="confirmDelete(${t.id}, '${escapeHtml(t.name)}')" title="Eliminar"><i class="fas fa-trash" style="color:var(--danger)"></i></button>
|
||||||
@@ -475,5 +477,59 @@ function copyText(text) {
|
|||||||
navigator.clipboard.writeText(text).then(() => toast("Copiado al portapapeles", "success"));
|
navigator.clipboard.writeText(text).then(() => toast("Copiado al portapapeles", "success"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Modules ───────────────────────────────────────────────────────────────
|
||||||
|
let currentModulesTenantId = null;
|
||||||
|
|
||||||
|
async function openModulesModal(tenantId, name) {
|
||||||
|
currentModulesTenantId = tenantId;
|
||||||
|
document.getElementById("modules-modal-title").textContent = `Módulos — ${escapeHtml(name)}`;
|
||||||
|
document.getElementById("modules-modal").style.display = "flex";
|
||||||
|
|
||||||
|
// Load current state
|
||||||
|
const res = await api(`/api/tenants/${tenantId}/modules`);
|
||||||
|
if (res && res.status === 200) {
|
||||||
|
const m = res.data.data;
|
||||||
|
document.getElementById("mod-whatsapp").checked = m.whatsapp !== false;
|
||||||
|
document.getElementById("mod-marketplace").checked = m.marketplace !== false;
|
||||||
|
document.getElementById("mod-meli").checked = m.meli !== false;
|
||||||
|
} else {
|
||||||
|
toast("Error al cargar módulos", "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModulesModal() {
|
||||||
|
document.getElementById("modules-modal").style.display = "none";
|
||||||
|
currentModulesTenantId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveModules() {
|
||||||
|
if (!currentModulesTenantId) return;
|
||||||
|
const btn = document.getElementById("modules-save-btn");
|
||||||
|
const originalText = btn.innerHTML;
|
||||||
|
btn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> Guardando...`;
|
||||||
|
btn.disabled = true;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
whatsapp: document.getElementById("mod-whatsapp").checked,
|
||||||
|
marketplace: document.getElementById("mod-marketplace").checked,
|
||||||
|
meli: document.getElementById("mod-meli").checked,
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await api(`/api/tenants/${currentModulesTenantId}/modules`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: payload
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res && res.status === 200) {
|
||||||
|
toast("Módulos actualizados", "success");
|
||||||
|
closeModulesModal();
|
||||||
|
} else {
|
||||||
|
toast(res?.data?.error || "Error al guardar", "error");
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.innerHTML = originalText;
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Init ──────────────────────────────────────────────────────────────────
|
// ─── Init ──────────────────────────────────────────────────────────────────
|
||||||
document.addEventListener("DOMContentLoaded", initAuth);
|
document.addEventListener("DOMContentLoaded", initAuth);
|
||||||
|
|||||||
@@ -316,6 +316,53 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Modules Modal -->
|
||||||
|
<div id="modules-modal" class="modal" style="display:none;">
|
||||||
|
<div class="modal-overlay" onclick="closeModulesModal()"></div>
|
||||||
|
<div class="modal-content" style="max-width:480px;">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 id="modules-modal-title">Módulos del Tenant</h3>
|
||||||
|
<button class="btn-icon" onclick="closeModulesModal()"><i class="fas fa-times"></i></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="form-group" style="display:flex;align-items:center;justify-content:space-between;padding:12px 0;border-bottom:1px solid var(--border);">
|
||||||
|
<div>
|
||||||
|
<div style="font-weight:600;color:var(--text);">WhatsApp</div>
|
||||||
|
<div style="font-size:12px;color:var(--text-muted);">Mostrar menú de WhatsApp Bridge</div>
|
||||||
|
</div>
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" id="mod-whatsapp">
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="display:flex;align-items:center;justify-content:space-between;padding:12px 0;border-bottom:1px solid var(--border);">
|
||||||
|
<div>
|
||||||
|
<div style="font-weight:600;color:var(--text);">Marketplace</div>
|
||||||
|
<div style="font-size:12px;color:var(--text-muted);">Mostrar menú de Marketplace interno</div>
|
||||||
|
</div>
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" id="mod-marketplace">
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="display:flex;align-items:center;justify-content:space-between;padding:12px 0;">
|
||||||
|
<div>
|
||||||
|
<div style="font-weight:600;color:var(--text);">MercadoLibre</div>
|
||||||
|
<div style="font-size:12px;color:var(--text-muted);">Mostrar menú de MercadoLibre</div>
|
||||||
|
</div>
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" id="mod-meli">
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary" onclick="closeModulesModal()">Cancelar</button>
|
||||||
|
<button class="btn btn-primary" id="modules-save-btn" onclick="saveModules()">Guardar</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Toast -->
|
<!-- Toast -->
|
||||||
<div id="toast-container"></div>
|
<div id="toast-container"></div>
|
||||||
|
|
||||||
|
|||||||
@@ -579,6 +579,58 @@ def update_whatsapp_config():
|
|||||||
return jsonify({'message': 'WhatsApp configuration updated'})
|
return jsonify({'message': 'WhatsApp configuration updated'})
|
||||||
|
|
||||||
|
|
||||||
|
@config_bp.route('/modules', methods=['GET'])
|
||||||
|
@require_auth('config.view')
|
||||||
|
def get_modules():
|
||||||
|
"""Get enabled modules for this tenant."""
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("SELECT key, value FROM tenant_config WHERE key LIKE 'module_%'")
|
||||||
|
rows = {row[0]: row[1] for row in cur.fetchall()}
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def _bool(key):
|
||||||
|
return rows.get(key, 'true').lower() == 'true'
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'whatsapp': _bool('module_whatsapp'),
|
||||||
|
'marketplace': _bool('module_marketplace'),
|
||||||
|
'meli': _bool('module_meli'),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@config_bp.route('/modules', methods=['PUT'])
|
||||||
|
@require_auth('config.edit')
|
||||||
|
def update_modules():
|
||||||
|
"""Update enabled modules for this tenant."""
|
||||||
|
data = request.get_json() or {}
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
settings = {
|
||||||
|
'module_whatsapp': 'true' if data.get('whatsapp') else 'false',
|
||||||
|
'module_marketplace': 'true' if data.get('marketplace') else 'false',
|
||||||
|
'module_meli': 'true' if data.get('meli') else 'false',
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, value in settings.items():
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO tenant_config (key, value) VALUES (%s, %s)
|
||||||
|
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
||||||
|
""", (key, value))
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return jsonify({'message': 'Modules updated', 'modules': {
|
||||||
|
'whatsapp': data.get('whatsapp'),
|
||||||
|
'marketplace': data.get('marketplace'),
|
||||||
|
'meli': data.get('meli'),
|
||||||
|
}})
|
||||||
|
|
||||||
|
|
||||||
@config_bp.route('/onboarding-status', methods=['GET'])
|
@config_bp.route('/onboarding-status', methods=['GET'])
|
||||||
@require_auth('pos.view')
|
@require_auth('pos.view')
|
||||||
def get_onboarding_status():
|
def get_onboarding_status():
|
||||||
|
|||||||
@@ -1864,6 +1864,14 @@ def complete_layaway(layaway_id):
|
|||||||
new_value={'sale_id': sale['id'], 'total': total})
|
new_value={'sale_id': sale['id'], 'total': total})
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
# WhatsApp learning hook (non-blocking)
|
||||||
|
try:
|
||||||
|
from services.wa_learning import check_learning_resolution
|
||||||
|
check_learning_resolution(sale['id'], cust_id, conn)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
cur.close(); conn.close()
|
cur.close(); conn.close()
|
||||||
return jsonify(sale), 201
|
return jsonify(sale), 201
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from middleware import require_auth
|
|||||||
from tenant_db import get_tenant_conn, get_master_conn
|
from tenant_db import get_tenant_conn, get_master_conn
|
||||||
from services import whatsapp_service
|
from services import whatsapp_service
|
||||||
from config import WHATSAPP_BRIDGE_URL, WHATSAPP_BRIDGE_KEY
|
from config import WHATSAPP_BRIDGE_URL, WHATSAPP_BRIDGE_KEY
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
whatsapp_bp = Blueprint('whatsapp', __name__, url_prefix='/pos/api/whatsapp')
|
whatsapp_bp = Blueprint('whatsapp', __name__, url_prefix='/pos/api/whatsapp')
|
||||||
|
|
||||||
@@ -50,6 +51,27 @@ def _get_whatsapp_config(conn):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_branch_phone(tenant_conn, branch_id=None):
|
||||||
|
"""Obtener teléfono de la sucursal."""
|
||||||
|
if not tenant_conn:
|
||||||
|
return '(pendiente)'
|
||||||
|
try:
|
||||||
|
cur = tenant_conn.cursor()
|
||||||
|
if branch_id:
|
||||||
|
cur.execute("SELECT phone FROM branches WHERE id = %s", (branch_id,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
if row and row[0]:
|
||||||
|
cur.close()
|
||||||
|
return row[0]
|
||||||
|
cur.execute("SELECT value FROM tenant_config WHERE key = 'tenant_phone'")
|
||||||
|
row = cur.fetchone()
|
||||||
|
cur.close()
|
||||||
|
return row[0] if row and row[0] else '(pendiente)'
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[WA-SM] get_branch_phone error: {e}")
|
||||||
|
return '(pendiente)'
|
||||||
|
|
||||||
|
|
||||||
def _resolve_mye_ids(vehicle, master_conn):
|
def _resolve_mye_ids(vehicle, master_conn):
|
||||||
"""Return list of MYE ids matching vehicle brand/model/year text."""
|
"""Return list of MYE ids matching vehicle brand/model/year text."""
|
||||||
if not master_conn or not vehicle:
|
if not master_conn or not vehicle:
|
||||||
@@ -329,11 +351,7 @@ def logout():
|
|||||||
def webhook():
|
def webhook():
|
||||||
"""Receive messages from Baileys bridge (public, no auth).
|
"""Receive messages from Baileys bridge (public, no auth).
|
||||||
|
|
||||||
Flow:
|
Nuevo flujo: máquina de estados estructurada.
|
||||||
1. Persist the incoming message to the tenant's whatsapp_messages log.
|
|
||||||
2. Build inventory context for the AI (what this tenant has in stock).
|
|
||||||
3. Ask the chatbot for a reply, enriched with that context.
|
|
||||||
4. Send the reply back via the Baileys bridge.
|
|
||||||
"""
|
"""
|
||||||
data = request.get_json(force=True, silent=True) or {}
|
data = request.get_json(force=True, silent=True) or {}
|
||||||
|
|
||||||
@@ -344,421 +362,227 @@ def webhook():
|
|||||||
if not msg.get('phone') or msg.get('from_me'):
|
if not msg.get('phone') or msg.get('from_me'):
|
||||||
return jsonify({'ok': True})
|
return jsonify({'ok': True})
|
||||||
|
|
||||||
# Resolve tenant: try query param first, then fallback to first enabled tenant
|
phone = msg['phone']
|
||||||
|
reply_to = msg.get('sender_pn') or msg.get('jid') or phone
|
||||||
|
text = msg.get('text', '')
|
||||||
|
media_kind = msg.get('media_kind', 'text')
|
||||||
|
|
||||||
|
# Audio transcription (voice notes)
|
||||||
|
if media_kind == 'audio' and msg.get('media_base64'):
|
||||||
|
try:
|
||||||
|
from services.whisper_local import transcribe_audio_base64
|
||||||
|
transcript = transcribe_audio_base64(
|
||||||
|
msg['media_base64'],
|
||||||
|
mimetype=msg.get('media_mimetype') or 'audio/ogg',
|
||||||
|
)
|
||||||
|
if transcript:
|
||||||
|
text = transcript
|
||||||
|
print(f"[WA-SM] Voice note transcribed: {transcript[:100]}")
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[WA-SM] Whisper transcription failed: {e}")
|
||||||
|
|
||||||
|
# Location message: if current state expects it, store coordinates
|
||||||
|
if media_kind == 'location' and msg.get('latitude') is not None:
|
||||||
|
text = f"Ubicación: {msg['latitude']},{msg['longitude']}"
|
||||||
|
|
||||||
|
# Image without caption: provide a default text so the state machine can handle it
|
||||||
|
if media_kind == 'image' and not text:
|
||||||
|
text = "(imagen)"
|
||||||
|
|
||||||
|
|
||||||
|
# Resolve tenant
|
||||||
tenant_id = request.args.get('tenant_id', type=int)
|
tenant_id = request.args.get('tenant_id', type=int)
|
||||||
if not tenant_id:
|
if not tenant_id:
|
||||||
# Fallback: find first tenant with whatsapp enabled
|
|
||||||
try:
|
try:
|
||||||
mconn = get_master_conn()
|
mconn = get_master_conn()
|
||||||
mcur = mconn.cursor()
|
mcur = mconn.cursor()
|
||||||
mcur.execute("""
|
mcur.execute("""
|
||||||
SELECT t.id FROM tenants t
|
SELECT id, db_name FROM tenants
|
||||||
JOIN tenant_config c ON c.key = 'whatsapp_enabled' AND c.value = 'true'
|
WHERE is_active = true
|
||||||
WHERE t.is_active = true
|
ORDER BY id
|
||||||
ORDER BY t.id LIMIT 1
|
|
||||||
""")
|
""")
|
||||||
row = mcur.fetchone()
|
tenants = mcur.fetchall()
|
||||||
mcur.close()
|
mcur.close()
|
||||||
mconn.close()
|
mconn.close()
|
||||||
tenant_id = row[0] if row else None
|
# Find first tenant with whatsapp_enabled in their config
|
||||||
|
for tid, db_name in tenants:
|
||||||
|
try:
|
||||||
|
from tenant_db import get_tenant_conn_by_dbname
|
||||||
|
tconn = get_tenant_conn_by_dbname(db_name)
|
||||||
|
tcur = tconn.cursor()
|
||||||
|
tcur.execute(
|
||||||
|
"SELECT value FROM tenant_config WHERE key = 'whatsapp_enabled'"
|
||||||
|
)
|
||||||
|
row = tcur.fetchone()
|
||||||
|
tcur.close()
|
||||||
|
tconn.close()
|
||||||
|
if row and row[0].lower() == 'true':
|
||||||
|
tenant_id = tid
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
except Exception:
|
except Exception:
|
||||||
tenant_id = None
|
tenant_id = None
|
||||||
|
|
||||||
# Prepare phone and reply target early
|
|
||||||
reply_to = msg.get('jid') or msg['phone']
|
|
||||||
media_kind = msg.get('media_kind', 'text')
|
|
||||||
clean_phone = msg.get('phone', '')
|
|
||||||
|
|
||||||
tenant_conn = None
|
tenant_conn = None
|
||||||
master_conn = None
|
master_conn = None
|
||||||
inventory_context = None
|
|
||||||
wa_config = {}
|
|
||||||
try:
|
try:
|
||||||
tenant_conn = get_tenant_conn(tenant_id)
|
tenant_conn = get_tenant_conn(tenant_id)
|
||||||
master_conn = get_master_conn()
|
master_conn = get_master_conn()
|
||||||
wa_config = _get_whatsapp_config(tenant_conn)
|
wa_config = _get_whatsapp_config(tenant_conn)
|
||||||
|
|
||||||
# 1. Log the incoming message (with contact display name)
|
# Deduplicate by wa_message_id
|
||||||
|
wa_message_id = msg.get('message_id')
|
||||||
|
if wa_message_id:
|
||||||
|
cur = tenant_conn.cursor()
|
||||||
|
cur.execute("SELECT 1 FROM whatsapp_messages WHERE wa_message_id = %s LIMIT 1", (wa_message_id,))
|
||||||
|
if cur.fetchone():
|
||||||
|
cur.close()
|
||||||
|
return jsonify({'ok': True})
|
||||||
|
cur.close()
|
||||||
|
|
||||||
|
# 1. Log incoming message
|
||||||
cur = tenant_conn.cursor()
|
cur = tenant_conn.cursor()
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
INSERT INTO whatsapp_messages (phone, direction, message_text, wa_message_id, push_name)
|
INSERT INTO whatsapp_messages (phone, direction, message_text, wa_message_id, push_name)
|
||||||
VALUES (%s, 'incoming', %s, %s, %s)
|
VALUES (%s, 'incoming', %s, %s, %s)
|
||||||
ON CONFLICT DO NOTHING
|
ON CONFLICT DO NOTHING
|
||||||
""", (msg['phone'], msg['text'], msg['message_id'], msg.get('push_name') or None))
|
""", (phone, text, wa_message_id, msg.get('push_name')))
|
||||||
tenant_conn.commit()
|
tenant_conn.commit()
|
||||||
cur.close()
|
cur.close()
|
||||||
|
|
||||||
# 2. Build inventory context once per webhook call so the chatbot
|
# 2. Load session state
|
||||||
# can say things like "tengo 5 Bosch BP-123 por $450".
|
from services.wa_state_machine import get_session, save_session, process_message, StateContext
|
||||||
try:
|
session = get_session(tenant_conn, phone)
|
||||||
from services.ai_chat import get_inventory_context
|
|
||||||
inventory_context = get_inventory_context(tenant_conn)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[WA-AI] inventory_context failed: {e}")
|
|
||||||
inventory_context = None
|
|
||||||
|
|
||||||
# 2c. Urgency detection — if customer signals urgency, add a note
|
# 3. Check session expiry (30 minutes)
|
||||||
try:
|
current_state = session.get('state', 'idle')
|
||||||
from services.part_kits import is_urgent, urgency_note
|
state_data = session.get('state_data', {})
|
||||||
if msg.get('text') and is_urgent(msg['text']):
|
last_updated = session.get('updated_at')
|
||||||
if inventory_context:
|
|
||||||
inventory_context += urgency_note()
|
|
||||||
else:
|
|
||||||
inventory_context = urgency_note().strip()
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[WA-AI] urgency detection failed: {e}")
|
|
||||||
|
|
||||||
# 2d. Purchase history — append recent confirmed orders for this customer
|
if last_updated and hasattr(last_updated, 'strftime'):
|
||||||
try:
|
# PostgreSQL returns datetime objects (often timezone-aware)
|
||||||
from services.part_kits import get_purchase_history
|
from datetime import timezone
|
||||||
history = get_purchase_history(clean_phone, tenant_conn)
|
now = datetime.now(timezone.utc)
|
||||||
if history:
|
if last_updated.tzinfo is None:
|
||||||
if inventory_context:
|
now = now.replace(tzinfo=None)
|
||||||
inventory_context += "\n\n" + history
|
elapsed = (now - last_updated).total_seconds()
|
||||||
else:
|
if elapsed > 1800:
|
||||||
inventory_context = history
|
current_state = 'idle'
|
||||||
except Exception as e:
|
state_data = {'customer_id': state_data.get('customer_id')}
|
||||||
print(f"[WA-AI] Purchase history failed: {e}")
|
elif last_updated and isinstance(last_updated, str):
|
||||||
|
from datetime import datetime as dt
|
||||||
# 2b. Append previously-detected vehicle so the AI keeps context
|
|
||||||
# even when we don't send full conversation history (Hermes is slow with it)
|
|
||||||
try:
|
|
||||||
from services.wa_quotation import get_vehicle
|
|
||||||
saved_vehicle = get_vehicle(tenant_conn, clean_phone)
|
|
||||||
if saved_vehicle and inventory_context:
|
|
||||||
v_str = f"{saved_vehicle.get('brand','')} {saved_vehicle.get('model','')} {saved_vehicle.get('year','')}".strip()
|
|
||||||
if v_str:
|
|
||||||
inventory_context += f"\n\nVEHICULO DEL CLIENTE: {v_str}"
|
|
||||||
elif saved_vehicle:
|
|
||||||
v_str = f"{saved_vehicle.get('brand','')} {saved_vehicle.get('model','')} {saved_vehicle.get('year','')}".strip()
|
|
||||||
if v_str:
|
|
||||||
inventory_context = f"VEHICULO DEL CLIENTE: {v_str}"
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[WA-AI] vehicle_context failed: {e}")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[WA-AI] tenant connection failed: {e}")
|
|
||||||
if tenant_conn:
|
|
||||||
try:
|
try:
|
||||||
tenant_conn.rollback()
|
parsed = dt.fromisoformat(last_updated.replace('Z', '+00:00'))
|
||||||
|
elapsed = (dt.now(dt.now().astimezone().tzinfo) - parsed).total_seconds()
|
||||||
|
if elapsed > 1800:
|
||||||
|
current_state = 'idle'
|
||||||
|
state_data = {'customer_id': state_data.get('customer_id')}
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# 3. Dispatch by media kind + quotation commands
|
# Global reset commands work from any state
|
||||||
reply = None
|
if text and text.strip().lower() in ('limpiar chat', 'nuevo chat', 'borrar conversacion', 'borrar conversación', 'reset', 'reiniciar', 'menu', 'menú'):
|
||||||
|
current_state = 'idle'
|
||||||
|
state_data = {'customer_id': state_data.get('customer_id')}
|
||||||
|
|
||||||
# ── Abandoned quotation follow-up ──
|
# Abandoned quotation follow-up
|
||||||
# If customer has an active quote and hasn't interacted in 15+ min,
|
try:
|
||||||
# send a gentle nudge before processing their current message.
|
from services.part_kits import should_send_followup
|
||||||
try:
|
followup = should_send_followup(phone, tenant_conn)
|
||||||
from services.part_kits import should_send_followup
|
if followup:
|
||||||
followup = should_send_followup(clean_phone, tenant_conn)
|
whatsapp_service.send_message(reply_to, followup, bridge_url=wa_config.get('bridge_url'))
|
||||||
if followup:
|
|
||||||
whatsapp_service.send_message(reply_to, followup, bridge_url=wa_config.get('bridge_url'))
|
|
||||||
if tenant_conn:
|
|
||||||
cur_fu = tenant_conn.cursor()
|
cur_fu = tenant_conn.cursor()
|
||||||
cur_fu.execute(
|
cur_fu.execute(
|
||||||
"INSERT INTO whatsapp_messages (phone, direction, message_text) VALUES (%s, 'outgoing', %s)",
|
"INSERT INTO whatsapp_messages (phone, direction, message_text) VALUES (%s, 'outgoing', %s)",
|
||||||
(clean_phone, followup)
|
(phone, followup)
|
||||||
)
|
)
|
||||||
tenant_conn.commit()
|
tenant_conn.commit()
|
||||||
cur_fu.close()
|
cur_fu.close()
|
||||||
except Exception as fu_err:
|
except Exception as fu_err:
|
||||||
print(f"[WA-AI] Follow-up send failed: {fu_err}")
|
print(f"[WA-SM] Follow-up send failed: {fu_err}")
|
||||||
|
|
||||||
# ── Location message → nearest branch ──
|
# 4. Build context
|
||||||
if media_kind == 'location' and msg.get('latitude') is not None and msg.get('longitude') is not None:
|
context = StateContext(
|
||||||
from services.geo_branches import find_nearest_branch
|
tenant_conn=tenant_conn,
|
||||||
nearest = find_nearest_branch(tenant_conn, msg['latitude'], msg['longitude'])
|
master_conn=master_conn,
|
||||||
if nearest:
|
wa_config=wa_config,
|
||||||
reply = (
|
tenant_id=tenant_id,
|
||||||
f"📍 *Sucursal más cercana:*\n\n"
|
phone=phone,
|
||||||
f"*{nearest['name']}*\n"
|
media_kind=media_kind,
|
||||||
f"📌 {nearest['address']}\n"
|
media_base64=msg.get('media_base64'),
|
||||||
f"📞 {nearest['phone']}\n"
|
push_name=msg.get('push_name'),
|
||||||
f"🚗 Aprox. *{nearest['distance_km']} km* de tu ubicación\n\n"
|
|
||||||
f"¿Te gustaría recoger tu pedido ahí o prefieres envío a domicilio?"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
reply = (
|
|
||||||
"📍 Gracias por tu ubicación.\n\n"
|
|
||||||
"Actualmente no tenemos sucursales registradas con coordenadas. "
|
|
||||||
"¿En qué ciudad te encuentras? Te puedo indicar nuestras opciones de envío."
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── Check for quotation commands FIRST (before AI) ──
|
|
||||||
if not reply and media_kind == 'text' and msg.get('text'):
|
|
||||||
from services.wa_quotation import (
|
|
||||||
detect_quote_intent, get_open_quotation, create_quotation,
|
|
||||||
add_item_to_quotation, get_quotation_detail, format_quotation_wa,
|
|
||||||
clear_quotation, confirm_quotation, get_last_shown_part, set_last_shown_part,
|
|
||||||
)
|
)
|
||||||
from services.quote_image import generate_quote_image
|
|
||||||
from services.whatsapp_service import send_image
|
|
||||||
has_open = bool(tenant_conn and get_open_quotation(tenant_conn, clean_phone))
|
|
||||||
intent, qty = detect_quote_intent(msg['text'], has_open_quote=has_open)
|
|
||||||
|
|
||||||
if intent == 'add':
|
# 5. Process through state machine
|
||||||
last_part = get_last_shown_part(tenant_conn, clean_phone)
|
reply, next_state, next_state_data = process_message(
|
||||||
if not last_part:
|
phone=phone,
|
||||||
reply = '⚠️ Primero pregunta por una parte y luego escribe "cotizar" para agregarla.'
|
text=text,
|
||||||
elif tenant_conn:
|
current_state=current_state,
|
||||||
qid = get_open_quotation(tenant_conn, clean_phone)
|
state_data=state_data,
|
||||||
if not qid:
|
context=context,
|
||||||
qid = create_quotation(tenant_conn, clean_phone)
|
)
|
||||||
add_item_to_quotation(tenant_conn, qid, last_part, quantity=qty or 1)
|
|
||||||
detail = get_quotation_detail(tenant_conn, qid)
|
|
||||||
item_count = len(detail['items']) if detail else 0
|
|
||||||
reply = (
|
|
||||||
f'✅ *{last_part.get("name", "")}* × {qty or 1} agregado a tu cotización.\n'
|
|
||||||
f'Llevas {item_count} producto{"s" if item_count != 1 else ""} — total parcial: ${detail["total"]:,.2f}\n\n'
|
|
||||||
f'_Sigue preguntando por más partes, o escribe "enviar cotización" cuando termines._'
|
|
||||||
)
|
|
||||||
# Smart kit suggestion — cross-sell related parts
|
|
||||||
try:
|
|
||||||
from services.part_kits import build_kit_text
|
|
||||||
kit_text = build_kit_text(last_part.get('name', ''))
|
|
||||||
if kit_text:
|
|
||||||
reply += kit_text
|
|
||||||
except Exception as kit_err:
|
|
||||||
print(f"[WA-AI] Kit suggestion failed: {kit_err}")
|
|
||||||
|
|
||||||
elif intent == 'send':
|
# 5b. Si el estado transicionó sin mensaje, procesar el siguiente inmediatamente
|
||||||
if tenant_conn:
|
# (algunos estados solo hacen transiciones y delegan el mensaje al siguiente estado)
|
||||||
qid = get_open_quotation(tenant_conn, clean_phone)
|
loop_guard = 0
|
||||||
if qid:
|
while reply is None and loop_guard < 5:
|
||||||
detail = get_quotation_detail(tenant_conn, qid)
|
loop_guard += 1
|
||||||
reply = format_quotation_wa(detail)
|
reply, next_state, next_state_data = process_message(
|
||||||
if not reply:
|
phone=phone,
|
||||||
reply = '⚠️ Tu cotización está vacía. Pregunta por partes y escribe "cotizar" para agregarlas.'
|
text=text,
|
||||||
else:
|
current_state=next_state,
|
||||||
# Generate rich visual quote image and send it
|
state_data=next_state_data,
|
||||||
try:
|
context=context,
|
||||||
quote_items = []
|
|
||||||
for it in detail.get('items', []):
|
|
||||||
quote_items.append({
|
|
||||||
'name': it.get('name', ''),
|
|
||||||
'sku': it.get('sku', ''),
|
|
||||||
'qty': it.get('quantity', 1),
|
|
||||||
'price': float(it.get('unit_price', 0)),
|
|
||||||
'total': float(it.get('total', 0)),
|
|
||||||
})
|
|
||||||
totals = {
|
|
||||||
'subtotal': float(detail.get('subtotal', 0)),
|
|
||||||
'tax': float(detail.get('tax', 0)),
|
|
||||||
'total': float(detail.get('total', 0)),
|
|
||||||
}
|
|
||||||
tenant_name = tenant_config.get('business_name', 'Autopartes')
|
|
||||||
b64_img = generate_quote_image(quote_items, totals, tenant_name=tenant_name)
|
|
||||||
img_result = send_image(clean_phone, caption="Aquí está tu cotización 👇", base64_image=b64_img, bridge_url=bridge_url)
|
|
||||||
if img_result.get('success'):
|
|
||||||
reply = "📎 *Te envié tu cotización en imagen.*\n\n" + reply
|
|
||||||
else:
|
|
||||||
print(f"[WA-AI] Image send failed: {img_result}")
|
|
||||||
except Exception as img_err:
|
|
||||||
print(f"[WA-AI] Quote image generation failed: {img_err}")
|
|
||||||
else:
|
|
||||||
reply = '⚠️ No tienes una cotización abierta. Pregunta por una parte primero.'
|
|
||||||
|
|
||||||
elif intent == 'clear':
|
|
||||||
if tenant_conn:
|
|
||||||
clear_quotation(tenant_conn, clean_phone)
|
|
||||||
reply = '🗑️ Cotización limpiada. Pregunta por partes para empezar una nueva.'
|
|
||||||
|
|
||||||
elif intent == 'confirm':
|
|
||||||
if tenant_conn:
|
|
||||||
qid = confirm_quotation(tenant_conn, clean_phone)
|
|
||||||
if qid:
|
|
||||||
reply = (
|
|
||||||
f'✅ *Pedido confirmado!*\n\n'
|
|
||||||
f'Tu cotización #{qid} fue registrada.\n'
|
|
||||||
f'Nos pondremos en contacto contigo para coordinar la entrega/recolección.\n\n'
|
|
||||||
f'¡Gracias por tu compra! 🙏'
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
reply = '⚠️ No tienes una cotización abierta para confirmar.'
|
|
||||||
|
|
||||||
# ── Check for conversation reset commands ──
|
|
||||||
if media_kind == 'text' and msg.get('text'):
|
|
||||||
txt_lower = msg['text'].lower().strip()
|
|
||||||
if txt_lower in ('limpiar chat', 'nuevo chat', 'borrar conversacion', 'borrar conversación', 'reset', 'reiniciar'):
|
|
||||||
if tenant_conn:
|
|
||||||
try:
|
|
||||||
cur_del = tenant_conn.cursor()
|
|
||||||
cur_del.execute("DELETE FROM whatsapp_messages WHERE phone = %s", (clean_phone,))
|
|
||||||
tenant_conn.commit()
|
|
||||||
cur_del.close()
|
|
||||||
except Exception as del_err:
|
|
||||||
print(f"[WA-AI] Failed to clear conversation history: {del_err}")
|
|
||||||
reply = '🗑️ *Conversación reiniciada.*\n\n¡Hola de nuevo! ¿En qué puedo ayudarte?'
|
|
||||||
result = whatsapp_service.send_message(reply_to, reply, bridge_url=wa_config.get('bridge_url'))
|
|
||||||
if tenant_conn:
|
|
||||||
try:
|
|
||||||
cur_save = tenant_conn.cursor()
|
|
||||||
cur_save.execute("INSERT INTO whatsapp_messages (phone, direction, message_text) VALUES (%s, 'outgoing', %s)", (clean_phone, reply))
|
|
||||||
tenant_conn.commit()
|
|
||||||
cur_save.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if tenant_conn:
|
|
||||||
try: tenant_conn.close()
|
|
||||||
except Exception: pass
|
|
||||||
return jsonify({'ok': True})
|
|
||||||
|
|
||||||
if intent is not None:
|
|
||||||
# It was a quote command — send reply and skip the AI
|
|
||||||
if reply:
|
|
||||||
result = whatsapp_service.send_message(reply_to, reply, bridge_url=wa_config.get('bridge_url'))
|
|
||||||
if tenant_conn:
|
|
||||||
try:
|
|
||||||
cur_save = tenant_conn.cursor()
|
|
||||||
cur_save.execute("INSERT INTO whatsapp_messages (phone, direction, message_text) VALUES (%s, 'outgoing', %s)", (clean_phone, reply))
|
|
||||||
tenant_conn.commit()
|
|
||||||
cur_save.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
# Clean up and return early
|
|
||||||
if tenant_conn:
|
|
||||||
try: tenant_conn.close()
|
|
||||||
except Exception: pass
|
|
||||||
return jsonify({'ok': True})
|
|
||||||
|
|
||||||
# Load conversation history so the AI remembers context (vehicle, parts, etc.)
|
|
||||||
conversation_history = []
|
|
||||||
if tenant_conn:
|
|
||||||
conversation_history = _get_conversation_history(clean_phone, tenant_conn, limit=4)
|
|
||||||
if conversation_history:
|
|
||||||
print(f"[WA-AI] Loaded {len(conversation_history)} history messages for {clean_phone}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
if media_kind == 'image' and msg.get('media_base64'):
|
|
||||||
from services.ai_chat import chat_with_image
|
|
||||||
# Prompt: use the caption if provided, else default to
|
|
||||||
# "identify this part" which chat_with_image handles gracefully.
|
|
||||||
prompt = msg.get('text') or 'Identifica esta parte automotriz y sugiere terminos de busqueda.'
|
|
||||||
ai_resp = chat_with_image(
|
|
||||||
user_message=prompt,
|
|
||||||
image_base64=msg['media_base64'],
|
|
||||||
conversation_history=conversation_history,
|
|
||||||
inventory_context=inventory_context,
|
|
||||||
)
|
)
|
||||||
reply = ai_resp.get('message', '') or ''
|
|
||||||
print(f"[WA-AI] Image from {reply_to}: {reply[:80]}...")
|
|
||||||
|
|
||||||
elif media_kind == 'audio' and msg.get('media_base64'):
|
# 6. Save new state
|
||||||
# Voice note handling — transcribe first, then chat().
|
save_session(tenant_conn, phone, next_state, next_state_data)
|
||||||
# See services.whisper_local for the transcriber.
|
|
||||||
try:
|
|
||||||
from services.whisper_local import transcribe_audio_base64
|
|
||||||
transcript = transcribe_audio_base64(
|
|
||||||
msg['media_base64'],
|
|
||||||
mimetype=msg.get('media_mimetype') or 'audio/ogg',
|
|
||||||
)
|
|
||||||
except ImportError:
|
|
||||||
transcript = None
|
|
||||||
print("[WA-AI] whisper_local not installed — voice notes skipped")
|
|
||||||
except Exception as e:
|
|
||||||
transcript = None
|
|
||||||
print(f"[WA-AI] Whisper transcription failed: {e}")
|
|
||||||
|
|
||||||
if transcript:
|
# 7. Send reply
|
||||||
print(f"[WA-AI] Voice note transcribed: {transcript[:100]}")
|
|
||||||
from services.ai_chat import chat
|
|
||||||
ai_resp = chat(transcript, conversation_history=conversation_history, inventory_context=inventory_context)
|
|
||||||
reply = ai_resp.get('message', '') or ''
|
|
||||||
# Prefix the reply so the sender knows we understood the voice note
|
|
||||||
if reply:
|
|
||||||
reply = f'🎙️ Entendi: "{transcript}"\n\n{reply}'
|
|
||||||
else:
|
|
||||||
reply = ('Recibi tu nota de voz pero no pude transcribirla. '
|
|
||||||
'Puedes escribirme el mensaje?')
|
|
||||||
|
|
||||||
elif msg.get('text'):
|
|
||||||
txt = msg['text'].strip().lower()
|
|
||||||
# Quick welcome menu for new customers with no vehicle
|
|
||||||
is_greeting = txt in ('hola', 'buenos dias', 'buenas tardes', 'buenas noches', 'hey', 'que onda', 'saludos')
|
|
||||||
if is_greeting:
|
|
||||||
try:
|
|
||||||
from services.wa_quotation import get_vehicle
|
|
||||||
veh = get_vehicle(tenant_conn, clean_phone)
|
|
||||||
if not veh:
|
|
||||||
reply = (
|
|
||||||
"¡Qué onda! Bienvenido a *Autopartes Estrada*.\n\n"
|
|
||||||
"Soy Juan, tu vendedor. Para ayudarte rápido, dime:\n\n"
|
|
||||||
"1️⃣ *Marca, modelo y año* de tu vehículo\n"
|
|
||||||
"2️⃣ La *parte* que necesitas\n"
|
|
||||||
"3️⃣ O escribe *menú* para ver opciones\n\n"
|
|
||||||
'_Ejemplo: "Necesito balatas para Tsuru 2015"_'
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if not reply:
|
|
||||||
# Plain text message — standard chatbot flow
|
|
||||||
from services.ai_chat import chat
|
|
||||||
ai_resp = chat(msg['text'], conversation_history=conversation_history, inventory_context=inventory_context)
|
|
||||||
reply = ai_resp.get('message', '') or ''
|
|
||||||
|
|
||||||
# Enrich: if the AI returned a search_query, look up real parts
|
|
||||||
# from the catalog and append them to the WhatsApp reply.
|
|
||||||
search_q = ai_resp.get('search_query')
|
|
||||||
vehicle = ai_resp.get('vehicle')
|
|
||||||
|
|
||||||
# Persist detected vehicle so we don't lose context between messages
|
|
||||||
if vehicle and isinstance(vehicle, dict) and vehicle.get('brand'):
|
|
||||||
try:
|
|
||||||
from services.wa_quotation import set_vehicle
|
|
||||||
set_vehicle(tenant_conn, clean_phone, vehicle)
|
|
||||||
except Exception as veh_err:
|
|
||||||
print(f"[WA-AI] Failed to save vehicle: {veh_err}")
|
|
||||||
|
|
||||||
if search_q and reply:
|
|
||||||
try:
|
|
||||||
enrichment, found_part = _enrich_wa_reply_with_part(search_q, vehicle, tenant_conn, master_conn)
|
|
||||||
if enrichment:
|
|
||||||
reply = reply + '\n\n' + enrichment
|
|
||||||
elif not found_part and vehicle and vehicle.get('brand'):
|
|
||||||
# Only say "not in stock" when we have a specific vehicle
|
|
||||||
# and still found nothing. Otherwise let the AI ask for vehicle info.
|
|
||||||
reply = reply + '\n\n' + "_No tengo esa pieza exacta en stock para tu modelo ahora, pero puedo pedirla por encargo o buscar alternativas. ¿Te interesa?_"
|
|
||||||
# Track the found part so "cotizar" can add it
|
|
||||||
if found_part:
|
|
||||||
from services.wa_quotation import set_last_shown_part
|
|
||||||
set_last_shown_part(tenant_conn, clean_phone, found_part)
|
|
||||||
except Exception as enrich_err:
|
|
||||||
print(f"[WA-AI] Enrichment failed: {enrich_err}")
|
|
||||||
|
|
||||||
# Send reply if we produced one
|
|
||||||
if reply:
|
if reply:
|
||||||
result = whatsapp_service.send_message(reply_to, reply, bridge_url=wa_config.get('bridge_url'))
|
result = whatsapp_service.send_message(reply_to, reply, bridge_url=wa_config.get('bridge_url'))
|
||||||
print(f"[WA-AI] Replied to {reply_to} ({media_kind}): {reply[:80]}... result={result}")
|
print(f"[WA-SM] Replied to {phone}: {reply[:80]}... result={result}")
|
||||||
|
|
||||||
# Save the bot's reply to DB so it shows in the WhatsApp UI
|
# Log outgoing
|
||||||
if tenant_conn:
|
cur = tenant_conn.cursor()
|
||||||
try:
|
cur.execute("""
|
||||||
cur2 = tenant_conn.cursor()
|
INSERT INTO whatsapp_messages (phone, direction, message_text)
|
||||||
cur2.execute("""
|
VALUES (%s, 'outgoing', %s)
|
||||||
INSERT INTO whatsapp_messages (phone, direction, message_text)
|
""", (phone, reply))
|
||||||
VALUES (%s, 'outgoing', %s)
|
tenant_conn.commit()
|
||||||
""", (msg['phone'], reply))
|
cur.close()
|
||||||
tenant_conn.commit()
|
|
||||||
cur2.close()
|
|
||||||
except Exception as db_err:
|
|
||||||
print(f"[WA-AI] Failed to save bot reply to DB: {db_err}")
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[WA-AI] Error handling {media_kind} from {reply_to}: {e}")
|
print(f"[WA-SM] Webhook error: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
# Fallback: enviar mensaje de error genérico
|
||||||
|
try:
|
||||||
|
if tenant_conn:
|
||||||
|
phone_branch = _get_branch_phone(tenant_conn, None)
|
||||||
|
fallback = (
|
||||||
|
"Estoy teniendo problemas técnicos en este momento. 😕\n\n"
|
||||||
|
f"Por favor llámanos directamente al {phone_branch}."
|
||||||
|
)
|
||||||
|
whatsapp_service.send_message(reply_to, fallback, bridge_url=wa_config.get('bridge_url'))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# 4. Clean up connections
|
finally:
|
||||||
if tenant_conn is not None:
|
if tenant_conn:
|
||||||
try:
|
try:
|
||||||
tenant_conn.close()
|
tenant_conn.close()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
if master_conn is not None:
|
if master_conn:
|
||||||
try:
|
try:
|
||||||
master_conn.close()
|
master_conn.close()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return jsonify({'ok': True})
|
return jsonify({'ok': True})
|
||||||
|
|
||||||
|
|||||||
100
pos/migrations/v3.5_whatsapp_state_machine.sql
Normal file
100
pos/migrations/v3.5_whatsapp_state_machine.sql
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- v3.5 WhatsApp State Machine
|
||||||
|
-- Reorganización del chatbot de AI libre a flujo estructurado
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- 1. Extender whatsapp_sessions con estado y contexto
|
||||||
|
-- ---------------------------------------------------
|
||||||
|
ALTER TABLE whatsapp_sessions
|
||||||
|
ADD COLUMN IF NOT EXISTS state VARCHAR(50) DEFAULT 'idle',
|
||||||
|
ADD COLUMN IF NOT EXISTS state_data JSONB DEFAULT '{}',
|
||||||
|
ADD COLUMN IF NOT EXISTS customer_id INTEGER REFERENCES customers(id),
|
||||||
|
ADD COLUMN IF NOT EXISTS branch_id INTEGER REFERENCES branches(id),
|
||||||
|
ADD COLUMN IF NOT EXISTS learning_cycle INTEGER DEFAULT 0,
|
||||||
|
ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ DEFAULT NOW();
|
||||||
|
|
||||||
|
-- Índices para lookups rápidos de sesión
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wa_sessions_state ON whatsapp_sessions(state);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wa_sessions_customer ON whatsapp_sessions(customer_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wa_sessions_updated ON whatsapp_sessions(updated_at);
|
||||||
|
|
||||||
|
-- 2. Tabla de vínculo persistente WA ID ↔ Cliente
|
||||||
|
-- ------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS wa_customer_links (
|
||||||
|
phone VARCHAR(50) PRIMARY KEY,
|
||||||
|
customer_id INTEGER NOT NULL REFERENCES customers(id),
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wa_cust_link_customer ON wa_customer_links(customer_id);
|
||||||
|
|
||||||
|
-- Trigger para updated_at
|
||||||
|
CREATE OR REPLACE FUNCTION update_wa_link_timestamp()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = NOW();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_wa_link_updated ON wa_customer_links;
|
||||||
|
CREATE TRIGGER trg_wa_link_updated
|
||||||
|
BEFORE UPDATE ON wa_customer_links
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_wa_link_timestamp();
|
||||||
|
|
||||||
|
-- 3. Tabla de sesiones de aprendizaje (piezas no resueltas)
|
||||||
|
-- ---------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS wa_learning_sessions (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
phone VARCHAR(50) NOT NULL,
|
||||||
|
customer_id INTEGER REFERENCES customers(id),
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
offered_parts JSONB DEFAULT '[]',
|
||||||
|
status VARCHAR(20) DEFAULT 'pending',
|
||||||
|
resolved_part_id INTEGER REFERENCES inventory(id),
|
||||||
|
resolution_sale_id INTEGER REFERENCES sales(id),
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
resolved_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wa_learn_phone ON wa_learning_sessions(phone);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wa_learn_status ON wa_learning_sessions(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wa_learn_customer ON wa_learning_sessions(customer_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wa_learn_created ON wa_learning_sessions(created_at);
|
||||||
|
|
||||||
|
-- 4. Tabla de configuración de envío por sucursal
|
||||||
|
-- ------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS branch_delivery_config (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
branch_id INTEGER NOT NULL UNIQUE REFERENCES branches(id),
|
||||||
|
is_enabled BOOLEAN DEFAULT FALSE,
|
||||||
|
delivery_fee NUMERIC(12,2) DEFAULT 0,
|
||||||
|
free_delivery_threshold NUMERIC(12,2) DEFAULT NULL,
|
||||||
|
coverage_radius_km INTEGER DEFAULT NULL,
|
||||||
|
delivery_hours VARCHAR(100) DEFAULT 'Lun-Vie 9:00-18:00',
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 5. Agregar push_name a whatsapp_messages (schema drift existente)
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
ALTER TABLE whatsapp_messages
|
||||||
|
ADD COLUMN IF NOT EXISTS push_name VARCHAR(200);
|
||||||
|
|
||||||
|
-- 6. Migrar datos existentes: vincular por teléfono
|
||||||
|
-- --------------------------------------------------
|
||||||
|
-- Intentar vincular sesiones WA existentes con customers por teléfono
|
||||||
|
INSERT INTO wa_customer_links (phone, customer_id)
|
||||||
|
SELECT ws.phone, c.id
|
||||||
|
FROM whatsapp_sessions ws
|
||||||
|
JOIN customers c ON c.phone = ws.phone
|
||||||
|
WHERE ws.phone IS NOT NULL AND c.phone IS NOT NULL
|
||||||
|
ON CONFLICT (phone) DO NOTHING;
|
||||||
|
|
||||||
|
-- Actualizar customer_id en whatsapp_sessions desde el vínculo
|
||||||
|
UPDATE whatsapp_sessions ws
|
||||||
|
SET customer_id = wcl.customer_id
|
||||||
|
FROM wa_customer_links wcl
|
||||||
|
WHERE ws.phone = wcl.phone AND ws.customer_id IS NULL;
|
||||||
@@ -440,6 +440,13 @@ def process_sale(conn, sale_data):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass # Savings errors never block sales
|
pass # Savings errors never block sales
|
||||||
|
|
||||||
|
# WhatsApp learning hook (non-blocking)
|
||||||
|
try:
|
||||||
|
from services.wa_learning import check_learning_resolution
|
||||||
|
check_learning_resolution(sale_id, customer_id, conn)
|
||||||
|
except Exception:
|
||||||
|
pass # Learning errors never block sales
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'id': sale_id,
|
'id': sale_id,
|
||||||
'branch_id': branch_id,
|
'branch_id': branch_id,
|
||||||
|
|||||||
140
pos/services/wa_customer.py
Normal file
140
pos/services/wa_customer.py
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
"""
|
||||||
|
WhatsApp Customer Service — identificación y vinculación de clientes.
|
||||||
|
|
||||||
|
Funciones para buscar, crear y vincular clientes desde el flujo de WhatsApp.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
def find_customer_by_phone(phone, tenant_conn):
|
||||||
|
"""Buscar cliente por número de teléfono exacto o parcial."""
|
||||||
|
if not tenant_conn or not phone:
|
||||||
|
return []
|
||||||
|
cur = tenant_conn.cursor()
|
||||||
|
# Limpiar phone de prefijos internacionales para búsqueda flexible
|
||||||
|
clean = phone.replace('+52', '').replace('52', '').lstrip('1')
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id, name, phone, address, rfc
|
||||||
|
FROM customers
|
||||||
|
WHERE phone = %s OR phone LIKE %s OR phone LIKE %s
|
||||||
|
LIMIT 5
|
||||||
|
""", (phone, f'%{clean}', f'%{clean[-10:]}' if len(clean) >= 10 else f'%{clean}'))
|
||||||
|
rows = cur.fetchall()
|
||||||
|
cur.close()
|
||||||
|
return [{'id': r[0], 'name': r[1], 'phone': r[2], 'address': r[3], 'rfc': r[4]} for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def find_customer_by_name(name, tenant_conn):
|
||||||
|
"""Buscar cliente por nombre (ILIKE)."""
|
||||||
|
if not tenant_conn or not name:
|
||||||
|
return []
|
||||||
|
cur = tenant_conn.cursor()
|
||||||
|
# Buscar por nombre completo o primer palabra
|
||||||
|
first_word = name.split()[0] if name else name
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id, name, phone, address, rfc
|
||||||
|
FROM customers
|
||||||
|
WHERE name ILIKE %s OR name ILIKE %s
|
||||||
|
LIMIT 5
|
||||||
|
""", (f'%{name}%', f'%{first_word}%'))
|
||||||
|
rows = cur.fetchall()
|
||||||
|
cur.close()
|
||||||
|
return [{'id': r[0], 'name': r[1], 'phone': r[2], 'address': r[3], 'rfc': r[4]} for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def search_customers(query, tenant_conn):
|
||||||
|
"""Buscar por teléfono o nombre."""
|
||||||
|
if not tenant_conn or not query:
|
||||||
|
return []
|
||||||
|
# Detectar si es número de teléfono
|
||||||
|
digits = re.sub(r'\D', '', query)
|
||||||
|
if len(digits) >= 7:
|
||||||
|
by_phone = find_customer_by_phone(digits, tenant_conn)
|
||||||
|
if by_phone:
|
||||||
|
return by_phone
|
||||||
|
return find_customer_by_name(query, tenant_conn)
|
||||||
|
|
||||||
|
|
||||||
|
def get_customer_by_id(tenant_conn, customer_id):
|
||||||
|
"""Obtener cliente por ID."""
|
||||||
|
if not tenant_conn or not customer_id:
|
||||||
|
return None
|
||||||
|
cur = tenant_conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id, name, phone, address, rfc, vehicle_info
|
||||||
|
FROM customers WHERE id = %s
|
||||||
|
""", (customer_id,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
cur.close()
|
||||||
|
if row:
|
||||||
|
return {
|
||||||
|
'id': row[0], 'name': row[1], 'phone': row[2],
|
||||||
|
'address': row[3], 'rfc': row[4], 'vehicle_info': row[5]
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def create_customer(tenant_conn, phone, name, email=None, address=None, rfc=None):
|
||||||
|
"""Crear cliente nuevo desde WhatsApp."""
|
||||||
|
if not tenant_conn:
|
||||||
|
return None
|
||||||
|
cur = tenant_conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO customers (name, phone, email, address, rfc, is_active, created_at)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, TRUE, NOW())
|
||||||
|
RETURNING id
|
||||||
|
""", (name, phone, email, address, rfc))
|
||||||
|
cid = cur.fetchone()[0]
|
||||||
|
tenant_conn.commit()
|
||||||
|
cur.close()
|
||||||
|
return cid
|
||||||
|
|
||||||
|
|
||||||
|
def link_wa_customer(phone, customer_id, tenant_conn):
|
||||||
|
"""Vincular número WA a cliente permanentemente."""
|
||||||
|
if not tenant_conn or not phone or not customer_id:
|
||||||
|
return
|
||||||
|
cur = tenant_conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO wa_customer_links (phone, customer_id, updated_at)
|
||||||
|
VALUES (%s, %s, NOW())
|
||||||
|
ON CONFLICT (phone) DO UPDATE SET customer_id = EXCLUDED.customer_id, updated_at = NOW()
|
||||||
|
""", (phone, customer_id))
|
||||||
|
tenant_conn.commit()
|
||||||
|
cur.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_linked_customer(phone, tenant_conn):
|
||||||
|
"""Obtener customer_id vinculado a un número WA."""
|
||||||
|
if not tenant_conn or not phone:
|
||||||
|
return None
|
||||||
|
cur = tenant_conn.cursor()
|
||||||
|
cur.execute("SELECT customer_id FROM wa_customer_links WHERE phone = %s", (phone,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
cur.close()
|
||||||
|
return row[0] if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def get_customer_address(tenant_conn, customer_id):
|
||||||
|
"""Obtener dirección del cliente."""
|
||||||
|
if not tenant_conn or not customer_id:
|
||||||
|
return None
|
||||||
|
cur = tenant_conn.cursor()
|
||||||
|
cur.execute("SELECT address FROM customers WHERE id = %s", (customer_id,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
cur.close()
|
||||||
|
return row[0] if row and row[0] else None
|
||||||
|
|
||||||
|
|
||||||
|
def update_customer_address(tenant_conn, customer_id, address):
|
||||||
|
"""Actualizar dirección del cliente."""
|
||||||
|
if not tenant_conn or not customer_id or not address:
|
||||||
|
return
|
||||||
|
cur = tenant_conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE customers SET address = %s WHERE id = %s",
|
||||||
|
(address, customer_id)
|
||||||
|
)
|
||||||
|
tenant_conn.commit()
|
||||||
|
cur.close()
|
||||||
127
pos/services/wa_learning.py
Normal file
127
pos/services/wa_learning.py
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
"""
|
||||||
|
WhatsApp Learning Service — ruta de aprendizaje para piezas no resueltas.
|
||||||
|
|
||||||
|
Registra sesiones donde el bot no pudo identificar una pieza, y las resuelve
|
||||||
|
asíncronamente cuando el cliente realiza una compra futura.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
def register_unresolved_search(phone, customer_id, description, offered_parts, tenant_conn):
|
||||||
|
"""Registrar una sesión no resuelta para aprendizaje futuro."""
|
||||||
|
if not tenant_conn or not phone or not description:
|
||||||
|
return None
|
||||||
|
cur = tenant_conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO wa_learning_sessions (phone, customer_id, description, offered_parts, status, created_at)
|
||||||
|
VALUES (%s, %s, %s, %s, 'pending', NOW())
|
||||||
|
RETURNING id
|
||||||
|
""", (phone, customer_id, description, json.dumps(offered_parts or [])))
|
||||||
|
sid = cur.fetchone()[0]
|
||||||
|
tenant_conn.commit()
|
||||||
|
cur.close()
|
||||||
|
return sid
|
||||||
|
|
||||||
|
|
||||||
|
def find_pending_sessions(phone, tenant_conn):
|
||||||
|
"""Buscar sesiones pendientes de aprendizaje para un número WA."""
|
||||||
|
if not tenant_conn or not phone:
|
||||||
|
return []
|
||||||
|
cur = tenant_conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id, description, offered_parts, created_at
|
||||||
|
FROM wa_learning_sessions
|
||||||
|
WHERE phone = %s AND status = 'pending'
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
""", (phone,))
|
||||||
|
rows = cur.fetchall()
|
||||||
|
cur.close()
|
||||||
|
return [{'id': r[0], 'description': r[1], 'offered_parts': r[2], 'created_at': str(r[3])} for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def find_pending_sessions_by_customer(customer_id, tenant_conn):
|
||||||
|
"""Buscar sesiones pendientes por customer_id."""
|
||||||
|
if not tenant_conn or not customer_id:
|
||||||
|
return []
|
||||||
|
cur = tenant_conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id, phone, description, offered_parts, created_at
|
||||||
|
FROM wa_learning_sessions
|
||||||
|
WHERE customer_id = %s AND status = 'pending'
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
""", (customer_id,))
|
||||||
|
rows = cur.fetchall()
|
||||||
|
cur.close()
|
||||||
|
return [{'id': r[0], 'phone': r[1], 'description': r[2], 'offered_parts': r[3], 'created_at': str(r[4])} for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_session(session_id, resolved_part_id, sale_id, tenant_conn):
|
||||||
|
"""Marcar sesión como resuelta con la pieza comprada."""
|
||||||
|
if not tenant_conn or not session_id:
|
||||||
|
return
|
||||||
|
cur = tenant_conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE wa_learning_sessions
|
||||||
|
SET status = 'learned', resolved_part_id = %s, resolution_sale_id = %s, resolved_at = NOW()
|
||||||
|
WHERE id = %s
|
||||||
|
""", (resolved_part_id, sale_id, session_id))
|
||||||
|
tenant_conn.commit()
|
||||||
|
cur.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_learning_pairs_for_training(tenant_conn, limit=100):
|
||||||
|
"""Obtener pares (descripción del cliente → pieza real) para entrenamiento."""
|
||||||
|
if not tenant_conn:
|
||||||
|
return []
|
||||||
|
cur = tenant_conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
SELECT l.description, i.name, i.part_number, i.brand
|
||||||
|
FROM wa_learning_sessions l
|
||||||
|
JOIN inventory i ON i.id = l.resolved_part_id
|
||||||
|
WHERE l.status = 'learned' AND l.resolved_at > NOW() - INTERVAL '90 days'
|
||||||
|
ORDER BY l.resolved_at DESC
|
||||||
|
LIMIT %s
|
||||||
|
""", (limit,))
|
||||||
|
rows = cur.fetchall()
|
||||||
|
cur.close()
|
||||||
|
return [{'description': r[0], 'part_name': r[1], 'part_number': r[2], 'brand': r[3]} for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def check_learning_resolution(sale_id, customer_id, tenant_conn):
|
||||||
|
"""
|
||||||
|
Hook para llamar después de completar una venta.
|
||||||
|
Verifica si esta venta resuelve una sesión de aprendizaje pendiente.
|
||||||
|
"""
|
||||||
|
if not tenant_conn or not customer_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
sessions = find_pending_sessions_by_customer(customer_id, tenant_conn)
|
||||||
|
if not sessions:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Obtener items de esta venta
|
||||||
|
cur = tenant_conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
SELECT si.inventory_id, i.name, i.part_number
|
||||||
|
FROM sale_items si
|
||||||
|
JOIN inventory i ON i.id = si.inventory_id
|
||||||
|
WHERE si.sale_id = %s
|
||||||
|
""", (sale_id,))
|
||||||
|
sale_items = cur.fetchall()
|
||||||
|
cur.close()
|
||||||
|
|
||||||
|
if not sale_items:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Matching heurístico
|
||||||
|
for sess in sessions:
|
||||||
|
desc_words = set(sess['description'].lower().split())
|
||||||
|
for inv_id, item_name, part_number in sale_items:
|
||||||
|
item_words = set(item_name.lower().split())
|
||||||
|
# Intersección de palabras significativas
|
||||||
|
common = desc_words & item_words - {'de', 'la', 'el', 'para', 'un', 'una', 'con', 'y', 'o', 'en', 'al', 'del', 'los', 'las'}
|
||||||
|
if len(common) >= 2:
|
||||||
|
resolve_session(sess['id'], inv_id, sale_id, tenant_conn)
|
||||||
|
print(f"[WA-LEARN] Resolved session {sess['id']} with sale {sale_id}, item {inv_id}")
|
||||||
|
break
|
||||||
@@ -105,7 +105,7 @@ def confirm_quotation(tenant_conn, phone):
|
|||||||
cur.execute("UPDATE quotations SET status = 'converted' WHERE id = %s", (qid,))
|
cur.execute("UPDATE quotations SET status = 'converted' WHERE id = %s", (qid,))
|
||||||
tenant_conn.commit()
|
tenant_conn.commit()
|
||||||
cur.close()
|
cur.close()
|
||||||
clear_last_shown(phone)
|
clear_last_shown(tenant_conn, phone)
|
||||||
return qid
|
return qid
|
||||||
|
|
||||||
|
|
||||||
@@ -342,7 +342,7 @@ def clear_quotation(tenant_conn, phone):
|
|||||||
cur.execute("UPDATE quotations SET status = 'cancelled' WHERE id = %s", (qid,))
|
cur.execute("UPDATE quotations SET status = 'cancelled' WHERE id = %s", (qid,))
|
||||||
tenant_conn.commit()
|
tenant_conn.commit()
|
||||||
cur.close()
|
cur.close()
|
||||||
clear_last_shown(phone)
|
clear_last_shown(tenant_conn, phone)
|
||||||
return qid
|
return qid
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
1366
pos/services/wa_state_machine.py
Normal file
1366
pos/services/wa_state_machine.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -88,9 +88,19 @@ def process_incoming(webhook_data):
|
|||||||
key = data.get('key', {})
|
key = data.get('key', {})
|
||||||
message = data.get('message', {})
|
message = data.get('message', {})
|
||||||
|
|
||||||
# remoteJid can be phone@s.whatsapp.net or LID@lid
|
# remoteJid can be phone@s.whatsapp.net or LID:instance@lid
|
||||||
remote_jid = key.get('remoteJid', '')
|
remote_jid = key.get('remoteJid', '')
|
||||||
phone = remote_jid.replace('@s.whatsapp.net', '').replace('@lid', '')
|
# Strip JID suffixes and LID instance suffix (:12)
|
||||||
|
phone = remote_jid.split('@')[0].split(':')[0] if remote_jid else ''
|
||||||
|
|
||||||
|
# DEBUG
|
||||||
|
import json
|
||||||
|
print(f"[WA-DEBUG] key fields: {json.dumps({k: v for k, v in key.items() if k in ('remoteJid', 'senderPn', 'fromMe', 'id')})}")
|
||||||
|
|
||||||
|
# senderPn contains the real phone number when remoteJid is a privacy LID
|
||||||
|
sender_pn = key.get('senderPn', '')
|
||||||
|
if sender_pn:
|
||||||
|
sender_pn = sender_pn.replace('@s.whatsapp.net', '')
|
||||||
|
|
||||||
# The bridge now classifies and passes these extra fields. Fall back to
|
# The bridge now classifies and passes these extra fields. Fall back to
|
||||||
# the old parsing if they're missing (older bridge version).
|
# the old parsing if they're missing (older bridge version).
|
||||||
@@ -122,6 +132,7 @@ def process_incoming(webhook_data):
|
|||||||
return {
|
return {
|
||||||
'phone': phone,
|
'phone': phone,
|
||||||
'jid': remote_jid,
|
'jid': remote_jid,
|
||||||
|
'sender_pn': sender_pn,
|
||||||
'text': text,
|
'text': text,
|
||||||
'from_me': key.get('fromMe', False),
|
'from_me': key.get('fromMe', False),
|
||||||
'message_id': key.get('id', ''),
|
'message_id': key.get('id', ''),
|
||||||
|
|||||||
@@ -180,4 +180,18 @@
|
|||||||
permissions: payload.permissions || []
|
permissions: payload.permissions || []
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ─── Preload enabled modules for sidebar filtering ───
|
||||||
|
try {
|
||||||
|
fetch('/pos/api/config/modules', {
|
||||||
|
headers: { 'Authorization': 'Bearer ' + token }
|
||||||
|
}).then(function(r) {
|
||||||
|
if (r.ok) return r.json();
|
||||||
|
}).then(function(data) {
|
||||||
|
if (data) {
|
||||||
|
localStorage.setItem('pos_modules', JSON.stringify(data));
|
||||||
|
window.POS_USER.modules = data;
|
||||||
|
}
|
||||||
|
}).catch(function() {});
|
||||||
|
} catch(e) {}
|
||||||
|
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -689,6 +689,53 @@ const Config = (() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Modules / Integrations
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
async function loadModules() {
|
||||||
|
try {
|
||||||
|
var res = await fetch(API + '/modules', { headers: headers() });
|
||||||
|
if (!res.ok) return;
|
||||||
|
var data = await res.json();
|
||||||
|
var cbWa = document.getElementById('cfg-module-whatsapp');
|
||||||
|
var cbMp = document.getElementById('cfg-module-marketplace');
|
||||||
|
var cbMeli = document.getElementById('cfg-module-meli');
|
||||||
|
if (cbWa) cbWa.checked = data.whatsapp !== false;
|
||||||
|
if (cbMp) cbMp.checked = data.marketplace !== false;
|
||||||
|
if (cbMeli) cbMeli.checked = data.meli !== false;
|
||||||
|
localStorage.setItem('pos_modules', JSON.stringify(data));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Config.loadModules:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveModules() {
|
||||||
|
var btn = event.target;
|
||||||
|
if (btn) { btn.disabled = true; btn.textContent = 'Guardando...'; }
|
||||||
|
try {
|
||||||
|
var data = {
|
||||||
|
whatsapp: document.getElementById('cfg-module-whatsapp').checked,
|
||||||
|
marketplace: document.getElementById('cfg-module-marketplace').checked,
|
||||||
|
meli: document.getElementById('cfg-module-meli').checked,
|
||||||
|
};
|
||||||
|
var res = await fetch(API + '/modules', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: headers(),
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
var err = await res.json().catch(function() { return { error: res.statusText }; });
|
||||||
|
throw new Error(err.error || 'Save failed');
|
||||||
|
}
|
||||||
|
localStorage.setItem('pos_modules', JSON.stringify(data));
|
||||||
|
toast('Módulos actualizados');
|
||||||
|
} catch (e) {
|
||||||
|
toast(e.message, 'error');
|
||||||
|
} finally {
|
||||||
|
if (btn) { btn.disabled = false; btn.textContent = 'Guardar módulos'; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Init
|
// Init
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -744,6 +791,7 @@ const Config = (() => {
|
|||||||
loadCurrency();
|
loadCurrency();
|
||||||
loadVehicleCompatSource();
|
loadVehicleCompatSource();
|
||||||
loadAllowedBrands();
|
loadAllowedBrands();
|
||||||
|
loadModules();
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', init);
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
@@ -753,6 +801,7 @@ const Config = (() => {
|
|||||||
loadBranches, loadEmployees, saveBranch, saveEmployee, editEmployee,
|
loadBranches, loadEmployees, saveBranch, saveEmployee, editEmployee,
|
||||||
loadBusiness, saveBusiness, saveTaxParams,
|
loadBusiness, saveBusiness, saveTaxParams,
|
||||||
loadCurrency, saveCurrency,
|
loadCurrency, saveCurrency,
|
||||||
|
loadModules, saveModules,
|
||||||
openModal, closeModal
|
openModal, closeModal
|
||||||
};
|
};
|
||||||
// Register Cmd+K items
|
// Register Cmd+K items
|
||||||
|
|||||||
@@ -17,6 +17,16 @@
|
|||||||
var currentTheme = localStorage.getItem('pos_theme') || 'industrial';
|
var currentTheme = localStorage.getItem('pos_theme') || 'industrial';
|
||||||
var currentLang = localStorage.getItem('pos_lang') || 'es';
|
var currentLang = localStorage.getItem('pos_lang') || 'es';
|
||||||
|
|
||||||
|
var modules = {};
|
||||||
|
try {
|
||||||
|
modules = JSON.parse(localStorage.getItem('pos_modules') || '{}');
|
||||||
|
} catch(e) { modules = {}; }
|
||||||
|
|
||||||
|
function moduleEnabled(key) {
|
||||||
|
// Default to true if not configured yet
|
||||||
|
return modules[key] !== false;
|
||||||
|
}
|
||||||
|
|
||||||
var navSections = [
|
var navSections = [
|
||||||
{ label: _t('nav_main'), items: [
|
{ label: _t('nav_main'), items: [
|
||||||
{ name: _t('dashboard'), href: '/pos/dashboard', icon: '<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>' },
|
{ name: _t('dashboard'), href: '/pos/dashboard', icon: '<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>' },
|
||||||
@@ -28,14 +38,14 @@
|
|||||||
{ label: _t('nav_management'), items: [
|
{ label: _t('nav_management'), items: [
|
||||||
{ name: _t('customers'), href: '/pos/customers', icon: '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/>' },
|
{ name: _t('customers'), href: '/pos/customers', icon: '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/>' },
|
||||||
{ name: 'Cotizaciones', href: '/pos/quotations', icon: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="9" y1="15" x2="15" y2="15"/><line x1="12" y1="12" x2="12" y2="18"/>' },
|
{ name: 'Cotizaciones', href: '/pos/quotations', icon: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="9" y1="15" x2="15" y2="15"/><line x1="12" y1="12" x2="12" y2="18"/>' },
|
||||||
{ name: 'Marketplace', href: '/pos/marketplace', icon: '<circle cx="9" cy="21" r="1"/><circle cx="20" cy="21" r="1"/><path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"/>' },
|
moduleEnabled('marketplace') ? { name: 'Marketplace', href: '/pos/marketplace', icon: '<circle cx="9" cy="21" r="1"/><circle cx="20" cy="21" r="1"/><path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"/>' } : null,
|
||||||
{ name: 'MercadoLibre', href: '/pos/marketplace-external', icon: '<rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/>' },
|
moduleEnabled('meli') ? { name: 'MercadoLibre', href: '/pos/marketplace-external', icon: '<rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/>' } : null,
|
||||||
{ name: _t('invoicing'), href: '/pos/invoicing', icon: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>' },
|
{ name: _t('invoicing'), href: '/pos/invoicing', icon: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>' },
|
||||||
{ name: _t('accounting'), href: '/pos/accounting', icon: '<line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>' },
|
{ name: _t('accounting'), href: '/pos/accounting', icon: '<line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>' },
|
||||||
{ name: _t('reports'), href: '/pos/reports', icon: '<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/>' },
|
{ name: _t('reports'), href: '/pos/reports', icon: '<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/>' },
|
||||||
{ name: _t('fleet'), href: '/pos/fleet', icon: '<path d="M1 13h22M1 13l2-6h6l2 6M9 7h6l2 6M15 13l2-6M5 17a2 2 0 1 0 0-4 2 2 0 0 0 0 4zM19 17a2 2 0 1 0 0-4 2 2 0 0 0 0 4z"/>' },
|
{ name: _t('fleet'), href: '/pos/fleet', icon: '<path d="M1 13h22M1 13l2-6h6l2 6M9 7h6l2 6M15 13l2-6M5 17a2 2 0 1 0 0-4 2 2 0 0 0 0 4zM19 17a2 2 0 1 0 0-4 2 2 0 0 0 0 4z"/>' },
|
||||||
{ name: _t('whatsapp'), href: '/pos/whatsapp', icon: '<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>' },
|
moduleEnabled('whatsapp') ? { name: _t('whatsapp'), href: '/pos/whatsapp', icon: '<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>' } : null,
|
||||||
]},
|
].filter(Boolean)},
|
||||||
{ label: _t('nav_system'), items: [
|
{ label: _t('nav_system'), items: [
|
||||||
{ name: _t('config'), href: '/pos/config', icon: '<circle cx="12" cy="12" r="3"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14M4.93 4.93a10 10 0 0 0 0 14.14"/>' },
|
{ name: _t('config'), href: '/pos/config', icon: '<circle cx="12" cy="12" r="3"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14M4.93 4.93a10 10 0 0 0 0 14.14"/>' },
|
||||||
]},
|
]},
|
||||||
|
|||||||
@@ -179,7 +179,7 @@ function cacheFirst(request) {
|
|||||||
return caches.match(request).then(function (cached) {
|
return caches.match(request).then(function (cached) {
|
||||||
if (cached) {
|
if (cached) {
|
||||||
fetch(request).then(function (response) {
|
fetch(request).then(function (response) {
|
||||||
if (response && response.status === 200) {
|
if (response && response.status === 200 && request.method === 'GET') {
|
||||||
caches.open(CACHE_NAME).then(function (cache) {
|
caches.open(CACHE_NAME).then(function (cache) {
|
||||||
cache.put(request, response);
|
cache.put(request, response);
|
||||||
});
|
});
|
||||||
@@ -188,7 +188,7 @@ function cacheFirst(request) {
|
|||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
return fetch(request).then(function (response) {
|
return fetch(request).then(function (response) {
|
||||||
if (response && response.status === 200) {
|
if (response && response.status === 200 && request.method === 'GET') {
|
||||||
var clone = response.clone();
|
var clone = response.clone();
|
||||||
caches.open(CACHE_NAME).then(function (cache) {
|
caches.open(CACHE_NAME).then(function (cache) {
|
||||||
cache.put(request, clone);
|
cache.put(request, clone);
|
||||||
@@ -201,7 +201,7 @@ function cacheFirst(request) {
|
|||||||
|
|
||||||
function networkFirst(request) {
|
function networkFirst(request) {
|
||||||
return fetch(request).then(function (response) {
|
return fetch(request).then(function (response) {
|
||||||
if (response && response.status === 200) {
|
if (response && response.status === 200 && request.method === 'GET') {
|
||||||
var clone = response.clone();
|
var clone = response.clone();
|
||||||
caches.open(CACHE_NAME).then(function (cache) {
|
caches.open(CACHE_NAME).then(function (cache) {
|
||||||
cache.put(request, clone);
|
cache.put(request, clone);
|
||||||
|
|||||||
@@ -251,7 +251,58 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ===============================================================
|
<!-- ===============================================================
|
||||||
SECTION 3: USUARIOS Y PERMISOS
|
SECTION 3: MÓDULOS E INTEGRACIONES
|
||||||
|
=============================================================== -->
|
||||||
|
<div class="settings-section">
|
||||||
|
<div class="settings-section__header">
|
||||||
|
<div class="settings-section__icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="settings-section__title">Módulos e Integraciones</div>
|
||||||
|
<div class="settings-section__desc">Activa o desactiva funcionalidades del menú para este tenant</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-card">
|
||||||
|
<div class="toggle-row">
|
||||||
|
<div class="toggle-row__info">
|
||||||
|
<span class="toggle-row__label">WhatsApp</span>
|
||||||
|
<span class="toggle-row__desc">Mostrar el menú de WhatsApp Bridge y chat</span>
|
||||||
|
</div>
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" id="cfg-module-whatsapp" checked />
|
||||||
|
<span class="toggle__slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="toggle-row">
|
||||||
|
<div class="toggle-row__info">
|
||||||
|
<span class="toggle-row__label">Marketplace</span>
|
||||||
|
<span class="toggle-row__desc">Mostrar el menú de Marketplace interno</span>
|
||||||
|
</div>
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" id="cfg-module-marketplace" checked />
|
||||||
|
<span class="toggle__slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="toggle-row">
|
||||||
|
<div class="toggle-row__info">
|
||||||
|
<span class="toggle-row__label">MercadoLibre</span>
|
||||||
|
<span class="toggle-row__desc">Mostrar el menú de integración con MercadoLibre</span>
|
||||||
|
</div>
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" id="cfg-module-meli" checked />
|
||||||
|
<span class="toggle__slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:var(--space-4);text-align:right;">
|
||||||
|
<button class="btn btn--primary" onclick="Config.saveModules()">Guardar módulos</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===============================================================
|
||||||
|
SECTION 4: USUARIOS Y PERMISOS
|
||||||
=============================================================== -->
|
=============================================================== -->
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<div class="settings-section__header">
|
<div class="settings-section__header">
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@whiskeysockets/baileys": "^6.7.16",
|
"@whiskeysockets/baileys": "^6.7.23",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"qrcode": "^1.5.3",
|
"qrcode": "^1.5.3",
|
||||||
"pino": "^8.16.2"
|
"pino": "^8.16.2"
|
||||||
|
|||||||
@@ -28,8 +28,9 @@ const sendQueue = [];
|
|||||||
let queueTimer = null;
|
let queueTimer = null;
|
||||||
let connectWatchdog = null;
|
let connectWatchdog = null;
|
||||||
let staleWatchdog = null;
|
let staleWatchdog = null;
|
||||||
const WATCHDOG_MS = 90000;
|
let queueFlushInterval = null;
|
||||||
const STALE_MS = 90000;
|
const WATCHDOG_MS = 300000; // 5 minutos para dar tiempo al QR scanning
|
||||||
|
const STALE_MS = 1800000; // 30 minutos (keepalive ya maneja pings, no forzamos reconexión por inactividad)
|
||||||
let lastActivity = Date.now();
|
let lastActivity = Date.now();
|
||||||
|
|
||||||
function updateActivity() {
|
function updateActivity() {
|
||||||
@@ -67,19 +68,14 @@ function flushSendQueue() {
|
|||||||
function clearWatchdog() {
|
function clearWatchdog() {
|
||||||
if (connectWatchdog) { clearTimeout(connectWatchdog); connectWatchdog = null; }
|
if (connectWatchdog) { clearTimeout(connectWatchdog); connectWatchdog = null; }
|
||||||
if (staleWatchdog) { clearInterval(staleWatchdog); staleWatchdog = null; }
|
if (staleWatchdog) { clearInterval(staleWatchdog); staleWatchdog = null; }
|
||||||
|
if (queueFlushInterval) { clearInterval(queueFlushInterval); queueFlushInterval = null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleStaleWatchdog() {
|
function scheduleStaleWatchdog() {
|
||||||
if (staleWatchdog) clearInterval(staleWatchdog);
|
// ELIMINADO: el stale watchdog forzaba reconexión cada 90s sin mensajes,
|
||||||
staleWatchdog = setInterval(() => {
|
// lo cual destruía la sesión de Baileys y provocaba 440 + limpieza de auth.
|
||||||
if (connectionState === 'open' && (Date.now() - lastActivity > STALE_MS)) {
|
// Baileys ya envía keepalive cada 15s (keepAliveIntervalMs). No es necesario.
|
||||||
console.log(`[Tenant ${TENANT_ID}] Stale watchdog: no activity for ${STALE_MS/1000}s while open, forcing reconnect`);
|
if (staleWatchdog) { clearInterval(staleWatchdog); staleWatchdog = null; }
|
||||||
try { sock?.ws?.close(); } catch (e) {}
|
|
||||||
sock = null;
|
|
||||||
connectionState = 'disconnected';
|
|
||||||
setTimeout(connectWhatsApp, 30000);
|
|
||||||
}
|
|
||||||
}, 30000);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleWatchdog() {
|
function scheduleWatchdog() {
|
||||||
@@ -118,7 +114,7 @@ async function connectWhatsApp() {
|
|||||||
auth: state,
|
auth: state,
|
||||||
logger,
|
logger,
|
||||||
// Use a generic Ubuntu + Chrome fingerprint — less suspicious than raw Linux
|
// Use a generic Ubuntu + Chrome fingerprint — less suspicious than raw Linux
|
||||||
browser: ['Ubuntu', 'Chrome', '120.0.0.0'],
|
browser: ['Chrome', 'Windows', '124.0.0.0'],
|
||||||
defaultQueryTimeoutMs: 60000,
|
defaultQueryTimeoutMs: 60000,
|
||||||
keepAliveIntervalMs: 15000,
|
keepAliveIntervalMs: 15000,
|
||||||
markOnlineOnConnect: false,
|
markOnlineOnConnect: false,
|
||||||
@@ -157,23 +153,24 @@ async function connectWhatsApp() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (reason === 440) {
|
if (reason === 440) {
|
||||||
// 440 = conflict/replaced. The session data is permanently invalid.
|
// 440 = conflict/replaced. WhatsApp reemplazó esta sesión (puede ser
|
||||||
// Clean auth immediately and wait 5 min so WhatsApp forgets the old session.
|
// por reconexión muy rápida, o porque el teléfono abrió otra sesión).
|
||||||
console.log(`[Tenant ${TENANT_ID}] 440 Session replaced — clearing auth, waiting 5 min`);
|
// NUNCA limpiamos auth automáticamente — solo esperamos más tiempo.
|
||||||
clearAuthState();
|
|
||||||
sock = null;
|
|
||||||
retry440Count++;
|
retry440Count++;
|
||||||
const delay = retry440Count >= 3 ? 600000 : 300000; // 10 min after 3 failures, else 5 min
|
const delay = retry440Count >= 3 ? 600000 : 120000; // 2min → 10min
|
||||||
|
console.log(`[Tenant ${TENANT_ID}] 440 Session replaced — reconnecting in ${delay/1000}s with existing creds (attempt ${retry440Count})`);
|
||||||
|
sock = null;
|
||||||
setTimeout(connectWhatsApp, delay);
|
setTimeout(connectWhatsApp, delay);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reason === 515) {
|
if (reason === 515) {
|
||||||
// 515 = stream error, often precedes 440. Treat same as 440.
|
// 515 = stream error / restart required. WhatsApp sends this after
|
||||||
console.log(`[Tenant ${TENANT_ID}] 515 Stream error — clearing auth, waiting 5 min`);
|
// successful pairing to force a reconnect with the new credentials.
|
||||||
clearAuthState();
|
// DO NOT clear auth — the credentials were just saved by creds.update.
|
||||||
|
console.log(`[Tenant ${TENANT_ID}] 515 Restart required — reconnecting in 5s with saved creds`);
|
||||||
sock = null;
|
sock = null;
|
||||||
setTimeout(connectWhatsApp, 300000);
|
setTimeout(connectWhatsApp, 5000);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,19 +182,11 @@ async function connectWhatsApp() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (reason === 408) {
|
if (reason === 408) {
|
||||||
// 408 during init queries usually means the server is overloaded
|
// 408 during init queries = rate-limit o auth parcialmente inválido.
|
||||||
// or our auth is partially invalid. Clear auth if this happens repeatedly.
|
// No limpiamos auth automáticamente; esperamos más tiempo.
|
||||||
console.log(`[Tenant ${TENANT_ID}] 408 Timeout — waiting 60s`);
|
console.log(`[Tenant ${TENANT_ID}] 408 Timeout — waiting 120s`);
|
||||||
sock = null;
|
sock = null;
|
||||||
retry440Count++;
|
setTimeout(connectWhatsApp, 120000);
|
||||||
if (retry440Count >= 5) {
|
|
||||||
console.log(`[Tenant ${TENANT_ID}] Too many timeouts — clearing auth for fresh QR`);
|
|
||||||
clearAuthState();
|
|
||||||
retry440Count = 0;
|
|
||||||
setTimeout(connectWhatsApp, 300000);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setTimeout(connectWhatsApp, 60000);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,20 +197,33 @@ async function connectWhatsApp() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (connection === 'open') {
|
if (connection === 'open') {
|
||||||
|
// Race-condition guard: if sock was nulled by a concurrent disconnect,
|
||||||
|
// ignore this stale 'open' event.
|
||||||
|
if (!sock) {
|
||||||
|
console.log(`[Tenant ${TENANT_ID}] Ignoring stale 'open' event (sock is null)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
clearWatchdog();
|
clearWatchdog();
|
||||||
connectionState = 'open';
|
connectionState = 'open';
|
||||||
qrCode = null;
|
qrCode = null;
|
||||||
retry440Count = 0;
|
retry440Count = 0;
|
||||||
updateActivity();
|
updateActivity();
|
||||||
scheduleStaleWatchdog();
|
// Stale watchdog eliminado — Baileys ya mantiene keepalive.
|
||||||
console.log(`[Tenant ${TENANT_ID}] Connected!`);
|
console.log(`[Tenant ${TENANT_ID}] Connected!`);
|
||||||
flushSendQueue();
|
flushSendQueue();
|
||||||
|
if (!queueFlushInterval) {
|
||||||
|
queueFlushInterval = setInterval(() => {
|
||||||
|
if (connectionState === 'open') flushSendQueue();
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
sock.ev.on('messages.upsert', async ({ messages }) => {
|
sock.ev.on('messages.upsert', async ({ messages }) => {
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
if (msg.key.fromMe) continue;
|
if (msg.key.fromMe) continue;
|
||||||
|
// Skip system/receipt messages with no meaningful content
|
||||||
|
if (!msg.message || Object.keys(msg.message).length === 0) continue;
|
||||||
const phone = msg.key.remoteJid.replace('@s.whatsapp.net', '');
|
const phone = msg.key.remoteJid.replace('@s.whatsapp.net', '');
|
||||||
const message = msg.message || {};
|
const message = msg.message || {};
|
||||||
|
|
||||||
@@ -289,7 +291,8 @@ async function connectWhatsApp() {
|
|||||||
media_ptt,
|
media_ptt,
|
||||||
latitude,
|
latitude,
|
||||||
longitude,
|
longitude,
|
||||||
push_name: msg.pushName || ''
|
push_name: msg.pushName || '',
|
||||||
|
sender_pn: msg.key?.senderPn || ''
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
signal: controller.signal
|
signal: controller.signal
|
||||||
@@ -332,21 +335,22 @@ app.post('/send', async (req, res) => {
|
|||||||
return res.status(400).json({ error: 'phone and message required' });
|
return res.status(400).json({ error: 'phone and message required' });
|
||||||
}
|
}
|
||||||
const jid = phone.includes('@') ? phone : phone + '@s.whatsapp.net';
|
const jid = phone.includes('@') ? phone : phone + '@s.whatsapp.net';
|
||||||
|
console.log(`[Tenant ${TENANT_ID}] /send called for ${jid}. state=${connectionState}, sock=${!!sock}`);
|
||||||
|
|
||||||
if (connectionState !== 'open' || !sock) {
|
if (connectionState === 'open' && sock) {
|
||||||
sendQueue.push({ jid, message });
|
try {
|
||||||
console.log(`[Tenant ${TENANT_ID}] Message queued for ${jid} (state: ${connectionState}, queue size: ${sendQueue.length})`);
|
const r = await sock.sendMessage(jid, { text: message });
|
||||||
return res.status(202).json({ queued: true, state: connectionState });
|
res.json({ success: true, id: r.key.id });
|
||||||
|
return;
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`[Tenant ${TENANT_ID}] Send failed, will queue:`, e.message);
|
||||||
|
// fall through to queue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
sendQueue.push({ jid, message });
|
||||||
const r = await sock.sendMessage(jid, { text: message });
|
console.log(`[Tenant ${TENANT_ID}] Message queued for ${jid} (state: ${connectionState}, queue size: ${sendQueue.length})`);
|
||||||
res.json({ success: true, id: r.key.id });
|
res.status(202).json({ queued: true, state: connectionState });
|
||||||
} catch (e) {
|
|
||||||
sendQueue.push({ jid, message });
|
|
||||||
console.log(`[Tenant ${TENANT_ID}] Send failed, queued for retry:`, e.message);
|
|
||||||
res.status(202).json({ queued: true, error: e.message });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
app.post('/send-image', async (req, res) => {
|
app.post('/send-image', async (req, res) => {
|
||||||
const { phone, caption, base64 } = req.body;
|
const { phone, caption, base64 } = req.body;
|
||||||
|
|||||||
Reference in New Issue
Block a user