Compare commits
80 Commits
79fa7984a1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6aff32f93b | |||
| 7d21d21200 | |||
| 0eb5984263 | |||
| b78523102d | |||
| 27358312dc | |||
| 5e9ac57f08 | |||
| 8796cadb56 | |||
| 3378d26a31 | |||
| a9052e63c2 | |||
| c1e93ed52a | |||
| 70233671a6 | |||
| 33df6e9280 | |||
| 1967ad1073 | |||
| 917ff00310 | |||
| 913e507adc | |||
| 383799ff3d | |||
| 203960fff3 | |||
| 0419f8285a | |||
| 3d70c3fcc9 | |||
| 041efd5c5c | |||
| 2cbd69d5fa | |||
| 98b3b1c8c1 | |||
| efbfadd17a | |||
| 43691ce83b | |||
| 7a4a676890 | |||
| 08362c5677 | |||
| 2b73c2c6db | |||
| ea29cc31c0 | |||
| 5ea667b80e | |||
| 77541e4c52 | |||
| 9f04bfe0bb | |||
| 718fa06888 | |||
| 999591e248 | |||
| 3d0d52c60b | |||
| c5fc8c5ec6 | |||
| 5c815bc2f5 | |||
| b6a327c98c | |||
| 68d6f81671 | |||
| 61bf84b2dc | |||
| 3009ffa1b0 | |||
| 7cef8db6af | |||
| 03b32f3b17 | |||
| eb107e2778 | |||
| 031c190635 | |||
| 7020890b0e | |||
| 23dbf54f3f | |||
| 3060dab471 | |||
| 716e19d079 | |||
| 51f64921a5 | |||
| 91caf91b79 | |||
| 584cc385b9 | |||
| 314075021e | |||
| f742cdaa42 | |||
| 79d3368041 | |||
| bfb4921ac0 | |||
| b314a781a1 | |||
| 4866823ba9 | |||
| a236187f3a | |||
| 159d0ed625 | |||
| 50c0dbe7d4 | |||
| 0b1dc89faf | |||
| dbf45e374b | |||
| 07b9b9130a | |||
| ae2273f864 | |||
| d9741b21f6 | |||
| e38148e8d5 | |||
| 912fe4cef5 | |||
| a7334513ac | |||
| 2f8b9dd5aa | |||
| 60dd8162f7 | |||
| bfa7bc2997 | |||
| 6196234d8b | |||
| e8db3e926c | |||
| d725ed2e0c | |||
| 36dd6634e3 | |||
| 24cdd71262 | |||
| 9ad624d26c | |||
| 2af2389294 | |||
| be4bb8d9ad | |||
| da362e32a6 |
@@ -57,6 +57,13 @@ METABASE_ADMIN_EMAIL=admin@nexus.local
|
|||||||
METABASE_ADMIN_PASS=change-me-to-a-strong-password
|
METABASE_ADMIN_PASS=change-me-to-a-strong-password
|
||||||
METABASE_DB_PASS=metabase_secret
|
METABASE_DB_PASS=metabase_secret
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# FACTURAPI (OPTIONAL — auto-organization mode for new tenants)
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# If set, new tenants can create Facturapi organizations automatically.
|
||||||
|
# Otherwise each tenant must store its secret key in tenant_config.cfdi_facturapi_key.
|
||||||
|
FACTURAPI_USER_KEY=sk_user_xxxxxxxxxxxxxxxx
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
# CURRENCY
|
# CURRENCY
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -195,7 +195,9 @@ Ver [docs/INSTALACION.md](docs/INSTALACION.md) para instrucciones detalladas.
|
|||||||
| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | Arquitectura del sistema |
|
| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | Arquitectura del sistema |
|
||||||
| [docs/DATABASE.md](docs/DATABASE.md) | Esquema de base de datos |
|
| [docs/DATABASE.md](docs/DATABASE.md) | Esquema de base de datos |
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Nexus Autoparts** -- Tu conexion directa con las partes que necesitas
|
**Nexus Autoparts** -- Tu conexion directa con las partes que necesitas
|
||||||
|
|
||||||
|
cloudflared tunnel run --token eyJhIjoiZDRjYzMwN2MzOTM2ODFlMGJiNTIwODZlZmNkZDFiM2MiLCJ0IjoiNDA3OTgwNDItNmMyZC00ZmY4LTgwNzgtMDYwZDA0ZDdhZTY0IiwicyI6Ik5qSXdPVGN4TXpBdE5HWTVOeTAwTldOaExUazFZV1l0WWpobU9XVXdORGc1WTJJMyJ9
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Nexus Autoparts — Sistema completo para refaccionarias</title>
|
<title>Nexus Autoparts — Sistema completo para refaccionarias</title>
|
||||||
<meta name="description" content="POS + Catalogo TecDoc 1.5M+ partes + Marketplace B2B + IA. Todo lo que necesita una refaccionaria en una sola plataforma.">
|
<meta name="description" content="POS + Catalogo 1.5M+ partes + IA + Venta en linea. Todo lo que necesita una refaccionaria en una sola plataforma.">
|
||||||
<script>
|
<script>
|
||||||
(function(){
|
(function(){
|
||||||
var t = localStorage.getItem('nexus-theme') || 'industrial';
|
var t = localStorage.getItem('nexus-theme') || 'industrial';
|
||||||
@@ -32,7 +32,6 @@
|
|||||||
<span id="themeIcon">☾</span>
|
<span id="themeIcon">☾</span>
|
||||||
</button>
|
</button>
|
||||||
<a href="/catalog" class="btn btn-primary">Ver Catalogo</a>
|
<a href="/catalog" class="btn btn-primary">Ver Catalogo</a>
|
||||||
<a href="/pos/login" class="btn btn-secondary">Acceder POS</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -42,13 +41,12 @@
|
|||||||
<canvas id="heroCanvas"></canvas>
|
<canvas id="heroCanvas"></canvas>
|
||||||
<div class="hero-content">
|
<div class="hero-content">
|
||||||
<h1 class="nx-reveal">Nexus Autoparts</h1>
|
<h1 class="nx-reveal">Nexus Autoparts</h1>
|
||||||
<p class="subtitle nx-reveal">Todo lo que necesita una refaccionaria en una sola plataforma. POS, inventario, catalogo TecDoc, facturacion, marketplace B2B e inteligencia artificial.</p>
|
<p class="subtitle nx-reveal">Todo lo que necesita una refaccionaria en una sola plataforma. POS, inventario, catalogo de partes, facturacion, venta en linea e inteligencia artificial.</p>
|
||||||
<div class="typewriter-line nx-reveal">
|
<div class="typewriter-line nx-reveal">
|
||||||
<span id="typewriterText"></span><span class="typewriter-cursor"></span>
|
<span id="typewriterText"></span><span class="typewriter-cursor"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="hero-buttons nx-reveal">
|
<div class="hero-buttons nx-reveal">
|
||||||
<a href="/catalog" class="btn btn-primary btn-lg">Explorar Catalogo</a>
|
<a href="/catalog" class="btn btn-primary btn-lg">Explorar Catalogo</a>
|
||||||
<a href="/pos/login" class="btn btn-secondary btn-lg">Probar el POS</a>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="hero-stats nx-stagger">
|
<div class="hero-stats nx-stagger">
|
||||||
<div class="stat-card nx-reveal">
|
<div class="stat-card nx-reveal">
|
||||||
@@ -80,59 +78,46 @@
|
|||||||
<section class="product">
|
<section class="product">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h2 class="section-title nx-reveal">El Producto</h2>
|
<h2 class="section-title nx-reveal">El Producto</h2>
|
||||||
<p class="section-subtitle nx-reveal">El unico sistema que combina POS + Inventario + CFDI + Catalogo + Marketplace + IA en una sola plataforma</p>
|
<p class="section-subtitle nx-reveal">Las 3 funcionalidades principales que hacen crecer tu refaccionaria</p>
|
||||||
<div class="product-grid nx-stagger">
|
<div class="product-grid nx-stagger">
|
||||||
|
|
||||||
<div class="product-card product-card--orange nx-reveal">
|
<div class="product-card product-card--orange nx-reveal">
|
||||||
<h3>Ventas & POS</h3>
|
<h3>Catalogo Completo + POS + Inventario</h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Punto de venta completo con F-keys y escaner</li>
|
<li>Catalogo completo: 1.5M+ partes OEM y 304K+ aftermarket</li>
|
||||||
<li>Caja registradora multi-caja, cortes X/Z</li>
|
<li>Punto de venta completo con escaner y teclas rapidas</li>
|
||||||
<li>Cotizaciones, apartados, devoluciones</li>
|
<li>Inventario append-only con toma fisica y alertas de stock</li>
|
||||||
<li>Clientes con credito y 3 niveles de precio</li>
|
<li>Navegacion por vehiculo: Marca > Modelo > Ano > Motor</li>
|
||||||
<li>Facturacion CFDI 4.0 (Ingreso, Egreso, Pago)</li>
|
<li>Decodificador VIN + busqueda por placas MX</li>
|
||||||
<li>Impresion termica ESC/POS</li>
|
<li>Facturacion CFDI 4.0 integrada</li>
|
||||||
<li>Contabilidad con polizas automaticas</li>
|
|
||||||
<li>Reportes: ventas, ABC, cortes, utilidad</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="product-card product-card--cyan nx-reveal">
|
<div class="product-card product-card--cyan nx-reveal">
|
||||||
<h3>Catalogo & Inventario</h3>
|
<h3>Agente AI para WhatsApp</h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Catalogo TecDoc: 1.5M+ partes OEM</li>
|
<li>Atiende consultas de autopartes 24/7 automaticamente</li>
|
||||||
<li>304K+ partes aftermarket con cross-refs</li>
|
<li>Genera cotizaciones inteligentes desde la conversacion</li>
|
||||||
<li>Navegacion: Ano > Marca > Modelo > Motor</li>
|
<li>Reconoce piezas por foto con Vision AI</li>
|
||||||
<li>VIN decoder + busqueda por placas MX</li>
|
<li>Transcripcion de notas de voz a texto</li>
|
||||||
<li>Inventario append-only, toma fisica</li>
|
<li>Envia catalogos y cotizaciones directo al cliente</li>
|
||||||
<li>Imagenes de productos con upload masivo</li>
|
<li>Reduce llamadas y aumenta conversiones</li>
|
||||||
<li>Traduccion automatica EN > ES (326 partes)</li>
|
|
||||||
<li>Marketplace B2B: bodegas ↔ talleres</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="product-card product-card--green nx-reveal">
|
<div class="product-card product-card--green nx-reveal">
|
||||||
<h3>IA & Plataforma</h3>
|
<h3>Vinculacion con Mercado Libre</h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Chatbot IA: diagnostico, cotizacion inteligente</li>
|
<li>Publica tu inventario en Mercado Libre en minutos</li>
|
||||||
<li>Entrada por voz (Web Speech API)</li>
|
<li>Sincronizacion automatica de stock y precios</li>
|
||||||
<li>Reconocimiento de partes por foto (Vision AI)</li>
|
<li>Descarga ordenes y conviertelas en ventas del POS</li>
|
||||||
<li>WhatsApp Business integrado (envio de cotizaciones)</li>
|
<li>Gestiona listados, preguntas y ventas desde un solo lugar</li>
|
||||||
<li>Gestion de flotillas y mantenimiento</li>
|
<li>Empieza a vender en linea sin complicaciones</li>
|
||||||
<li>PWA + App Android, modo kiosko</li>
|
<li>Mas canales, mas ventas, mismo inventario</li>
|
||||||
<li>Offline-first con sync automatico</li>
|
|
||||||
<li>2 temas, 2 idiomas (ES/EN), 2 monedas (MXN/USD)</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="hw-banner nx-reveal">
|
|
||||||
<div class="hw-banner-inner">
|
|
||||||
<span>🖥</span>
|
|
||||||
<div class="hw-text">A partir del plan <strong>Pro</strong>: servidor en <strong>rack 3D personalizado</strong> — Mini PC + switch + AP + UPS.<br>Todo incluido por <strong>$2,000 MXN/mes</strong>. Solo conectar y empezar a vender.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -154,12 +139,12 @@
|
|||||||
<div class="step nx-reveal">
|
<div class="step nx-reveal">
|
||||||
<div class="step-number">2</div>
|
<div class="step-number">2</div>
|
||||||
<h3>Catalogo + Inventario</h3>
|
<h3>Catalogo + Inventario</h3>
|
||||||
<p>Tu inventario conectado al catalogo TecDoc. Busca por vehiculo, parte o VIN.</p>
|
<p>Tu inventario conectado al catalogo de partes. Busca por vehiculo, parte o VIN.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="step nx-reveal">
|
<div class="step nx-reveal">
|
||||||
<div class="step-number">3</div>
|
<div class="step-number">3</div>
|
||||||
<h3>Vende y Crece</h3>
|
<h3>Vende y Crece</h3>
|
||||||
<p>POS, facturacion, marketplace B2B, WhatsApp e IA — todo desde un solo lugar.</p>
|
<p>POS, facturacion, venta en linea, WhatsApp e IA — todo desde un solo lugar.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -178,7 +163,7 @@
|
|||||||
<div class="diff-grid nx-stagger">
|
<div class="diff-grid nx-stagger">
|
||||||
<div class="diff-card nx-reveal">
|
<div class="diff-card nx-reveal">
|
||||||
<div class="diff-icon">🔍</div>
|
<div class="diff-icon">🔍</div>
|
||||||
<h4>Catalogo TecDoc</h4>
|
<h4>Catalogo Completo</h4>
|
||||||
<p>1.5M+ partes con cross-references. Nadie mas lo tiene en MX.</p>
|
<p>1.5M+ partes con cross-references. Nadie mas lo tiene en MX.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="diff-card nx-reveal">
|
<div class="diff-card nx-reveal">
|
||||||
@@ -193,13 +178,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="diff-card nx-reveal">
|
<div class="diff-card nx-reveal">
|
||||||
<div class="diff-icon">🚀</div>
|
<div class="diff-icon">🚀</div>
|
||||||
<h4>Marketplace B2B</h4>
|
<h4>Venta en Linea</h4>
|
||||||
<p>Conecta bodegas con talleres. Mas ventas, menos llamadas.</p>
|
<p>Conecta tu inventario con Mercado Libre y vende 24/7.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="diff-card nx-reveal">
|
<div class="diff-card nx-reveal">
|
||||||
<div class="diff-icon">🖥</div>
|
<div class="diff-icon">🖥</div>
|
||||||
<h4>Hardware incluido</h4>
|
<h4>Hardware opcional</h4>
|
||||||
<p>Rack 3D con servidor. Renta todo por $2,000/mes.</p>
|
<p>Mini rack 3D con servidor. Disponible como add-on.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="diff-card nx-reveal">
|
<div class="diff-card nx-reveal">
|
||||||
<div class="diff-icon">🌐</div>
|
<div class="diff-icon">🌐</div>
|
||||||
@@ -229,41 +214,46 @@
|
|||||||
<section class="pricing">
|
<section class="pricing">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h2 class="section-title nx-reveal">Planes</h2>
|
<h2 class="section-title nx-reveal">Planes</h2>
|
||||||
<p class="section-subtitle nx-reveal">Software desde $999/mes. Hardware incluido a partir del plan Pro.</p>
|
<p class="section-subtitle nx-reveal">Elige el plan que se ajuste a tu refaccionaria. Paga anual y ahorra 2 meses.</p>
|
||||||
<div class="pricing-grid nx-stagger">
|
<div class="pricing-grid nx-stagger">
|
||||||
<div class="pricing-card nx-reveal">
|
<div class="pricing-card nx-reveal">
|
||||||
<h4>Basico</h4>
|
<h4>POS Basico</h4>
|
||||||
<div class="pricing-price">$999</div>
|
<div class="pricing-price">$650</div>
|
||||||
<div class="pricing-period">MXN / mes — solo software</div>
|
<div class="pricing-period">MXN / mes</div>
|
||||||
<ul>
|
<ul>
|
||||||
<li>POS + Inventario</li>
|
<li>Punto de venta completo</li>
|
||||||
<li>Catalogo TecDoc</li>
|
<li>Inventario y catalogo de partes</li>
|
||||||
<li>CFDI 4.0</li>
|
<li>Facturacion CFDI 4.0</li>
|
||||||
<li>Reportes basicos</li>
|
<li>Reportes basicos</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="pricing-card featured nx-reveal">
|
<div class="pricing-card featured nx-reveal">
|
||||||
<h4>Pro</h4>
|
<h4>Sistema Completo</h4>
|
||||||
<div class="pricing-price">$2,000</div>
|
<div class="pricing-price">$1,660</div>
|
||||||
<div class="pricing-period">MXN / mes — hardware incluido</div>
|
<div class="pricing-period">MXN / mes</div>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Todo Basico +</li>
|
<li>Todo lo del POS Basico +</li>
|
||||||
|
<li>Agente AI para WhatsApp</li>
|
||||||
|
<li>Vinculacion con Mercado Libre</li>
|
||||||
|
<li>Sync automatico de stock y ordenes</li>
|
||||||
<li>Contabilidad automatica</li>
|
<li>Contabilidad automatica</li>
|
||||||
<li>Chatbot IA + WhatsApp</li>
|
<li>Multi-sucursal y flotillas</li>
|
||||||
<li>Marketplace B2B</li>
|
|
||||||
<li>🖥 Mini PC + rack 3D + red incluidos</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="pricing-card nx-reveal">
|
</div>
|
||||||
<h4>Enterprise</h4>
|
<div class="pricing-note nx-reveal" style="text-align:center; margin-top:var(--space-6); font-size:var(--text-body-sm); color:var(--color-text-secondary);">
|
||||||
<div class="pricing-price">$3,999</div>
|
<p><strong>Paga anual y ahorra 2 meses.</strong> Aplica a meses sin intereses (MSI).</p>
|
||||||
<div class="pricing-period">MXN / mes — hardware incluido</div>
|
</div>
|
||||||
|
<div class="pricing-grid nx-stagger" style="margin-top:var(--space-8);">
|
||||||
|
<div class="pricing-card nx-reveal" style="grid-column: 1 / -1; max-width: 600px; margin: 0 auto;">
|
||||||
|
<h4>Add-on: Mini Rack con Servidor</h4>
|
||||||
|
<div class="pricing-price">$3,000</div>
|
||||||
|
<div class="pricing-period">MXN / mes</div>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Todo Pro +</li>
|
<li>Mini PC con POS preinstalado</li>
|
||||||
<li>Flotillas + Multi-bodega</li>
|
<li>Switch + Access Point + UPS</li>
|
||||||
<li>API dedicada</li>
|
<li>Rack 3D personalizado</li>
|
||||||
<li>Soporte prioritario</li>
|
<li>Solo conectar y empezar a vender</li>
|
||||||
<li>🖥 Hardware dedicado por sucursal</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -283,12 +273,12 @@
|
|||||||
<div class="contact-card nx-reveal">
|
<div class="contact-card nx-reveal">
|
||||||
<div class="contact-icon">✉</div>
|
<div class="contact-icon">✉</div>
|
||||||
<h4>Email</h4>
|
<h4>Email</h4>
|
||||||
<a href="mailto:ialcarazsalazar@consultoria-as.com">ialcarazsalazar@consultoria-as.com</a>
|
<a href="mailto:ivan@nexusautoparts.com.mx">ivan@nexusautoparts.com.mx</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="contact-card nx-reveal">
|
<div class="contact-card nx-reveal">
|
||||||
<div class="contact-icon">📱</div>
|
<div class="contact-icon">📱</div>
|
||||||
<h4>WhatsApp</h4>
|
<h4>WhatsApp</h4>
|
||||||
<a href="https://wa.me/526641234567" class="btn-whatsapp" target="_blank" rel="noopener">Enviar Mensaje</a>
|
<a href="https://wa.me/526642170990" class="btn-whatsapp" target="_blank" rel="noopener">Enviar Mensaje</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="contact-card nx-reveal">
|
<div class="contact-card nx-reveal">
|
||||||
<div class="contact-icon">📍</div>
|
<div class="contact-icon">📍</div>
|
||||||
|
|||||||
@@ -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)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
# Nexus POS — Resumen de Fases Implementadas
|
# Nexus POS — Resumen de Fases Implementadas
|
||||||
|
|
||||||
**Fecha:** 2026-04-29
|
**Fecha:** 2026-06-11
|
||||||
**Versión DB:** v3.2
|
**Versión DB:** v4.1
|
||||||
**Tests:** 73/73 pasando (pytest)
|
**Tests:** 73/73 pasando (pytest)
|
||||||
|
**Commit:** `2b73c2c`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -200,6 +201,50 @@ METABASE_URL=http://localhost:3000
|
|||||||
| — | **nexus-pos.service systemd** | 2026-04-29 | `c766571` |
|
| — | **nexus-pos.service systemd** | 2026-04-29 | `c766571` |
|
||||||
| — | **QWEN 3.6 AI Vehicle Fitment** | 2026-04-29 | `623c57b` |
|
| — | **QWEN 3.6 AI Vehicle Fitment** | 2026-04-29 | `623c57b` |
|
||||||
|
|
||||||
|
## FASE 7: Precios de Proveedor + Multi-sucursal + Factura Global
|
||||||
|
|
||||||
|
**Commit:** `2b73c2c` (2026-06-11)
|
||||||
|
|
||||||
|
### 7.1 Lista de Precios de Proveedor
|
||||||
|
|
||||||
|
| Feature | Archivos | Capacidades |
|
||||||
|
|---------|----------|-------------|
|
||||||
|
| **Precios por proveedor** | `supplier_catalog_prices` (master DB) | Precio, moneda, vigencia (effective_from/to), activo/inactivo |
|
||||||
|
| **Upload masivo** | `supplier_catalog_bp.py` | CSV/Excel con supplier_name, sku, price, currency |
|
||||||
|
| **Visualización** | `catalog.js`, `catalog_service.py` | `supplier_price` + `supplier_currency` en tarjetas y búsqueda |
|
||||||
|
| **Endpoints** | `supplier_catalog_bp.py` | `GET/POST/PUT/DELETE /pos/api/supplier-catalog/prices/*` |
|
||||||
|
|
||||||
|
### 7.2 Multi-sucursal Completo
|
||||||
|
|
||||||
|
| Feature | Archivos | Capacidades |
|
||||||
|
|---------|----------|-------------|
|
||||||
|
| **Schema migration v4.0** | `v4.0_multi_branch.sql` | `inventory.branch_id=NULL` (catálogo compartido), tabla `inventory_stock` |
|
||||||
|
| **Datos fiscales por sucursal** | `branches` (tenant DB) | `rfc`, `razon_social`, `regimen_fiscal`, `codigo_postal`, `serie_cfdi`, `folio_inicial`, `licencia_fiscal`, `certificado_pem`, `llave_pem`, `is_main` |
|
||||||
|
| **Sincronización de stock** | Trigger `trg_update_inventory_stock` | `inventory_operations` → `inventory_stock` automático |
|
||||||
|
| **Backend branches** | `config_bp.py` | CRUD completo con campos fiscales, validación de única sucursal `is_main` |
|
||||||
|
| **Backend inventario** | `inventory_bp.py`, `inventory_engine.py`, `pos_bp.py` | Stock por sucursal vía `inventory_stock`, catálogo compartido, verificación de stock en POS |
|
||||||
|
| **Backend facturación** | `invoicing_bp.py` | CFDI usa datos fiscales de la sucursal de la venta (`_get_issuer_config`) |
|
||||||
|
| **Frontend config** | `config.html`, `config.js` | Modal de sucursal expandido con todos los campos fiscales, edición inline |
|
||||||
|
|
||||||
|
### 7.3 Factura Global Mensual
|
||||||
|
|
||||||
|
| Feature | Archivos | Capacidades |
|
||||||
|
|---------|----------|-------------|
|
||||||
|
| **Schema migration v4.1** | `v4.1_global_invoice.sql` | `global_invoice_sales`, `sales.global_invoiced_at` |
|
||||||
|
| **Builder CFDI global** | `cfdi_builder.py` | `build_global_invoice_xml()` con `InformacionGlobal` SAT-compliant (`Periodicidad="04"`) |
|
||||||
|
| **Servicio** | `global_invoice.py` | Agrupa ventas PUE ≤$2,000 sin CFDI individual del mes/año solicitado |
|
||||||
|
| **Endpoints** | `invoicing_bp.py` | `POST /global-invoice`, `GET /global-invoice/<id>`, `GET /global-invoice/eligible-sales` |
|
||||||
|
| **Frontend** | `invoicing.html`, `invoicing.js` | Botón "Factura Global" con modal de año/mes + vista previa de ventas elegibles |
|
||||||
|
|
||||||
|
### 7.4 Mercado Libre — Mejoras
|
||||||
|
|
||||||
|
| Feature | Archivos | Capacidades |
|
||||||
|
|---------|----------|-------------|
|
||||||
|
| **Importar publicaciones existentes** | `meli_service.py`, `marketplace_external_service.py` | `get_user_items()` + `import_existing_listings()` — importa items del vendedor a `marketplace_listings` intentando match por SKU/part_number |
|
||||||
|
| **Sync stock POS → ML** | `inventory_engine.py`, `marketplace_external_service.py` | Trigger en `inventory_operations` inserta en `meli_sync_queue`; `process_meli_sync_queue()` actualiza `available_quantity` en ML vía API |
|
||||||
|
| **Sync órdenes ML → POS** | `marketplace_external_bp.py` | `POST /orders/sync` para sincronización manual; webhook `/webhook/meli` ya maneja notificaciones de órdenes vía Celery |
|
||||||
|
| **Migration v4.2** | `v4.2_meli_sync_queue.sql` | Tabla `meli_sync_queue` para encolar actualizaciones de stock |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Mejoras Pendientes (Roadmap Actualizado)
|
## Mejoras Pendientes (Roadmap Actualizado)
|
||||||
@@ -215,7 +260,7 @@ METABASE_URL=http://localhost:3000
|
|||||||
| 1 | **WhatsApp Business API (Meta Cloud) real** | Migrar de Baileys a Meta Cloud API. Requiere verificación de cuenta Meta, Business Manager, número de teléfono verificado. | 2-3 semanas | Stub creado (`whatsapp_cloud_bp.py`) |
|
| 1 | **WhatsApp Business API (Meta Cloud) real** | Migrar de Baileys a Meta Cloud API. Requiere verificación de cuenta Meta, Business Manager, número de teléfono verificado. | 2-3 semanas | Stub creado (`whatsapp_cloud_bp.py`) |
|
||||||
| 2 | **BNPL real** | Integrar APLAZO/Kueski/Clip con credenciales de sandbox/producción. | 2 semanas | Stub creado (`bnpl_bp.py`) |
|
| 2 | **BNPL real** | Integrar APLAZO/Kueski/Clip con credenciales de sandbox/producción. | 2 semanas | Stub creado (`bnpl_bp.py`) |
|
||||||
| 3 | **ERP Sync real** | Conectar Aspel/CONTPAQi/SAP/Odoo vía API o archivos de intercambio. | 2-3 semanas | Stub creado (`erp_bp.py`) |
|
| 3 | **ERP Sync real** | Conectar Aspel/CONTPAQi/SAP/Odoo vía API o archivos de intercambio. | 2-3 semanas | Stub creado (`erp_bp.py`) |
|
||||||
| 4 | **Mercado Libre / Amazon sync** | Publicar inventario de bodegas en marketplaces. API de ML Seller + Amazon SP-API. | 3 semanas | No iniciado |
|
| 4 | **Mercado Libre / Amazon sync** | Publicar inventario de bodegas en marketplaces. API de ML Seller + Amazon SP-API. | 3 semanas | **Parcialmente listo** — ver Fase 7.4 |
|
||||||
|
|
||||||
### 🟡 Medio — Diferenciadores
|
### 🟡 Medio — Diferenciadores
|
||||||
|
|
||||||
|
|||||||
82
docs/GLOBAL_INVOICE.md
Normal file
82
docs/GLOBAL_INVOICE.md
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
# Factura Global Mensual — Documentación Técnica
|
||||||
|
|
||||||
|
**Versión DB:** v4.1
|
||||||
|
**Commit:** `2b73c2c`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requerimiento SAT
|
||||||
|
|
||||||
|
El SAT permite agrupar tickets de contado (menores a $2,000) en una sola factura mensual tipo **Ingreso** con `InformacionGlobal`.
|
||||||
|
|
||||||
|
## Criterios de elegibilidad
|
||||||
|
|
||||||
|
Una venta es elegible para factura global si:
|
||||||
|
1. `metodo_pago_sat = 'PUE'` (pagado al momento)
|
||||||
|
2. `total <= $2,000`
|
||||||
|
3. `status = 'completed'`
|
||||||
|
4. No tiene CFDI individual timbrado (`cfdi_queue.status = 'stamped'`)
|
||||||
|
5. No está ya en una factura global (`sales.global_invoiced_at IS NULL`)
|
||||||
|
6. Fecha dentro del mes/año solicitado
|
||||||
|
|
||||||
|
## Arquitectura
|
||||||
|
|
||||||
|
### Tablas
|
||||||
|
- `global_invoice_sales (global_invoice_id, sale_id)` — relación N:M
|
||||||
|
- `sales.global_invoiced_at` — marca de inclusión
|
||||||
|
|
||||||
|
### XML
|
||||||
|
- `build_global_invoice_xml()` en `cfdi_builder.py`
|
||||||
|
- `InformacionGlobal Periodicidad="04"` (mensual)
|
||||||
|
- Receptor: `PUBLICO EN GENERAL` (RFC XAXX010101000)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
| Método | Endpoint | Descripción |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| `GET` | `/pos/api/invoicing/global-invoice/eligible-sales?year=&month=&branch_id=` | Preview de ventas elegibles |
|
||||||
|
| `POST` | `/pos/api/invoicing/global-invoice` | Genera factura global |
|
||||||
|
| `GET` | `/pos/api/invoicing/global-invoice/<id>` | Estado y ventas vinculadas |
|
||||||
|
|
||||||
|
### POST body
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"year": 2026,
|
||||||
|
"month": 6,
|
||||||
|
"branch_id": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 42,
|
||||||
|
"status": "pending",
|
||||||
|
"sales_count": 15,
|
||||||
|
"total": 18450.00,
|
||||||
|
"provisional_folio": "PRE-00042",
|
||||||
|
"xml": "<?xml ...>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Flujo de uso (Frontend)
|
||||||
|
|
||||||
|
1. Ir a **Facturación**
|
||||||
|
2. Clic en botón **Factura Global**
|
||||||
|
3. Seleccionar año y mes
|
||||||
|
4. Clic en **Vista previa** para ver ventas elegibles
|
||||||
|
5. Clic en **Generar** para crear y encolar el CFDI
|
||||||
|
6. Procesar cola de timbrado normalmente
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Timbrado
|
||||||
|
|
||||||
|
La factura global entra en la cola `cfdi_queue` con:
|
||||||
|
- `type = 'ingreso'`
|
||||||
|
- `sale_id = NULL`
|
||||||
|
- Se timbra igual que cualquier otro CFDI vía Horux360
|
||||||
52
docs/MULTI_BRANCH.md
Normal file
52
docs/MULTI_BRANCH.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# Multi-sucursal — Documentación Técnica
|
||||||
|
|
||||||
|
**Versión DB:** v4.0
|
||||||
|
**Commit:** `2b73c2c`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Arquitectura
|
||||||
|
|
||||||
|
### Catálogo compartido
|
||||||
|
- `inventory.branch_id` es siempre `NULL` (catálogo compartido a nivel tenant).
|
||||||
|
- `part_number` tiene unique index `idx_inventory_part_unique`.
|
||||||
|
- Productos duplicados por `part_number` en múltiples sucursales fueron consolidados en la migración v4.0.
|
||||||
|
|
||||||
|
### Stock por sucursal
|
||||||
|
- Tabla `inventory_stock (inventory_id, branch_id, stock, location)`.
|
||||||
|
- Trigger `trg_update_inventory_stock` en `inventory_operations` mantiene `inventory_stock` sincronizado automáticamente.
|
||||||
|
- `inventory_stock_summary` sigue existiendo como stock total agregado (sin `branch_id`).
|
||||||
|
|
||||||
|
### Datos fiscales por sucursal
|
||||||
|
- Tabla `branches` incluye: `rfc`, `razon_social`, `regimen_fiscal`, `codigo_postal`, `serie_cfdi`, `folio_inicial`, `licencia_fiscal`, `certificado_pem`, `llave_pem`, `is_main`.
|
||||||
|
- Solo una sucursal puede ser `is_main = true`.
|
||||||
|
- Al facturar, `_get_issuer_config(cur, branch_id)` usa datos de la sucursal de la venta; fallback a config global del tenant.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
### Config
|
||||||
|
- `GET /pos/api/config/branches` — lista sucursales (sin PEM)
|
||||||
|
- `GET /pos/api/config/branches/<id>` — detalle completo (con PEM)
|
||||||
|
- `POST /pos/api/config/branches` — crear
|
||||||
|
- `PUT /pos/api/config/branches/<id>` — editar
|
||||||
|
|
||||||
|
### Inventario
|
||||||
|
- `GET /pos/api/inventory/items` — acepta `?branch_id=` para mostrar stock por sucursal
|
||||||
|
- Stock se lee de `inventory_stock` cuando se filtra por sucursal
|
||||||
|
|
||||||
|
### POS
|
||||||
|
- Ventas verifican stock vía `get_stock(conn, inventory_id, branch_id)`
|
||||||
|
- `inventory_operations` registra `branch_id` de la venta
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migración
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/Autopartes/pos
|
||||||
|
python3 migrations/runner.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Archivo: `pos/migrations/v4.0_multi_branch.sql`
|
||||||
25
manager/.env.example
Normal file
25
manager/.env.example
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# Nexus Instance Manager — Environment Variables
|
||||||
|
# Copy to .env and fill in your values.
|
||||||
|
|
||||||
|
# ─── Database (REQUIRED) ───────────────────────────────────────────────────
|
||||||
|
# If manager runs on a separate VM, use the IP of the PostgreSQL server.
|
||||||
|
MASTER_DB_URL=postgresql://nexus:PASSWORD@192.168.10.91/nexus_autopartes
|
||||||
|
TENANT_DB_URL_TEMPLATE=postgresql://nexus:PASSWORD@192.168.10.91/{db_name}
|
||||||
|
|
||||||
|
# ─── Remote Nexus Server IP (for VM-separated deployment) ──────────────────
|
||||||
|
# IP or hostname of the server running POS, Dashboard, Quart, Redis.
|
||||||
|
NEXUS_SERVER_HOST=192.168.10.91
|
||||||
|
|
||||||
|
# ─── Security (REQUIRED) ───────────────────────────────────────────────────
|
||||||
|
MANAGER_JWT_SECRET=change-me-to-a-random-64-char-hex-string
|
||||||
|
|
||||||
|
# ─── Demo Defaults ─────────────────────────────────────────────────────────
|
||||||
|
DEMO_DEFAULT_DAYS=14
|
||||||
|
DEMO_DEFAULT_PIN=0000
|
||||||
|
|
||||||
|
# ─── Redis (OPTIONAL — health check only) ──────────────────────────────────
|
||||||
|
# Redis may only listen on localhost. If so, health check will show warning.
|
||||||
|
REDIS_URL=redis://192.168.10.91:6379/0
|
||||||
|
|
||||||
|
# ─── Internal ──────────────────────────────────────────────────────────────
|
||||||
|
POS_DIR=/home/Autopartes/pos
|
||||||
203
manager/README.md
Normal file
203
manager/README.md
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
# Nexus Instance Manager
|
||||||
|
|
||||||
|
Panel de control central para gestionar instancias multi-tenant de Nexus POS.
|
||||||
|
|
||||||
|
## Qué hace
|
||||||
|
|
||||||
|
- **Crear demos** en 1 clic con subdominio, PIN de acceso y fecha de expiración
|
||||||
|
- **Monitorear** salud de todos los servicios (POS, DB, Redis, Quart, Systemd)
|
||||||
|
- **Gestionar tenants**: activar/desactivar, resetear datos, eliminar
|
||||||
|
- **Ejecutar migraciones** de schema en todos los tenants desde una UI
|
||||||
|
- **Dashboard** con estadísticas globales y alertas de demos por expirar
|
||||||
|
|
||||||
|
## Estructura
|
||||||
|
|
||||||
|
```
|
||||||
|
manager/
|
||||||
|
├── app.py # Flask app principal
|
||||||
|
├── config.py # Variables de entorno
|
||||||
|
├── wsgi.py # Entry point para Gunicorn
|
||||||
|
├── requirements.txt # Dependencias
|
||||||
|
├── services/ # Lógica de negocio
|
||||||
|
│ ├── health_service.py # Health checks de infraestructura
|
||||||
|
│ ├── tenant_service.py # CRUD tenants (usa tenant_manager del POS)
|
||||||
|
│ └── migration_service.py# Orquestación de migraciones
|
||||||
|
├── blueprints/ # API REST
|
||||||
|
│ ├── auth_bp.py # Login/logout JWT
|
||||||
|
│ ├── tenants_bp.py # Gestión de tenants
|
||||||
|
│ ├── demos_bp.py # Creación de demos
|
||||||
|
│ ├── health_bp.py # Health checks
|
||||||
|
│ └── admin_bp.py # Dashboard stats y migraciones
|
||||||
|
├── static/ # Frontend SPA
|
||||||
|
│ ├── css/manager.css
|
||||||
|
│ └── js/manager.js
|
||||||
|
├── templates/
|
||||||
|
│ └── index.html # Single Page App
|
||||||
|
├── scripts/
|
||||||
|
│ └── init_manager.py # Inicialización de DB + admin
|
||||||
|
├── systemd/
|
||||||
|
│ └── nexus-manager.service # Servicio systemd
|
||||||
|
└── README.md # Documentación completa
|
||||||
|
```
|
||||||
|
|
||||||
|
## Instalación rápida (mismo servidor)
|
||||||
|
|
||||||
|
### 1. Dependencias
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/Autopartes/manager
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Inicializar base de datos y usuario admin
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/Autopartes/manager
|
||||||
|
python scripts/init_manager.py --email admin@nexus.local --password nexus2026 --name "Super Admin"
|
||||||
|
```
|
||||||
|
|
||||||
|
Esto crea:
|
||||||
|
- Tabla `manager_users` (login del panel)
|
||||||
|
- Tabla `manager_audit_log` (registro de acciones)
|
||||||
|
- Usuario admin por defecto
|
||||||
|
|
||||||
|
### 3. Configurar variables de entorno
|
||||||
|
|
||||||
|
Asegúrate de que estas variables estén disponibles (en systemd o `.env`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MASTER_DB_URL=postgresql://postgres@localhost/nexus_autoparts
|
||||||
|
TENANT_DB_URL_TEMPLATE=postgresql://postgres@localhost/{db_name}
|
||||||
|
MANAGER_JWT_SECRET=genera-un-segredo-largo-aqui
|
||||||
|
POS_DIR=/home/Autopartes/pos
|
||||||
|
REDIS_URL=redis://localhost:6379/0
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Registrar servicio systemd
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp systemd/nexus-manager.service /etc/systemd/system/
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable nexus-manager
|
||||||
|
systemctl start nexus-manager
|
||||||
|
```
|
||||||
|
|
||||||
|
Accede en: `http://TU_IP:5003`
|
||||||
|
|
||||||
|
### 5. (Opcional) Agregar a nginx
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name manager.nexusautoparts.com.mx;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:5003;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Instalación en Máquina Virtual separada (misma red local)
|
||||||
|
|
||||||
|
Si el manager corre en una VM diferente al servidor principal (donde está PostgreSQL + POS):
|
||||||
|
|
||||||
|
### Requisitos de red
|
||||||
|
- PostgreSQL del servidor principal debe escuchar en `0.0.0.0:5432` (verificar `listen_addresses = '*'` en `postgresql.conf`)
|
||||||
|
- POS (5001), Dashboard (5000) y Quart (5002) ya escuchan en `0.0.0.0` por defecto
|
||||||
|
- Redis puede estar solo en `127.0.0.1`; en ese caso el health check mostrará advertencia pero no afecta el funcionamiento
|
||||||
|
|
||||||
|
### 1. Clonar el repo en la VM
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.consultoria-as.com/consultoria-as/Autoparts-DB.git /home/Autopartes
|
||||||
|
cd /home/Autopartes/manager
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Instalar dependencias
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Configurar variables para conexión remota
|
||||||
|
|
||||||
|
Crea `/home/Autopartes/manager/.env` o edita el servicio systemd:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# IP del servidor principal donde corre PostgreSQL y POS
|
||||||
|
NEXUS_SERVER_HOST=192.168.10.91
|
||||||
|
|
||||||
|
# PostgreSQL remoto (cambiar localhost por la IP del servidor)
|
||||||
|
MASTER_DB_URL=postgresql://nexus:PASSWORD@192.168.10.91/nexus_autoparts
|
||||||
|
TENANT_DB_URL_TEMPLATE=postgresql://nexus:PASSWORD@192.168.10.91/{db_name}
|
||||||
|
|
||||||
|
# Redis remoto (puede no funcionar si Redis solo escucha en localhost)
|
||||||
|
REDIS_URL=redis://192.168.10.91:6379/0
|
||||||
|
|
||||||
|
# Seguridad
|
||||||
|
MANAGER_JWT_SECRET=genera-un-segredo-largo-aqui
|
||||||
|
POS_DIR=/home/Autopartes/pos
|
||||||
|
```
|
||||||
|
|
||||||
|
**Nota importante:** La VM manager no necesita una instalación completa del POS. Solo necesita:
|
||||||
|
- Los archivos de `manager/`
|
||||||
|
- Los archivos de `pos/` (para reutilizar `tenant_manager.py` y migraciones)
|
||||||
|
- Conectividad TCP al puerto 5432 del servidor principal
|
||||||
|
|
||||||
|
### 4. Inicializar DB y admin
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/Autopartes/manager
|
||||||
|
python scripts/init_manager.py --email admin@nexus.local --password TU_PASSWORD
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Systemd
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp systemd/nexus-manager.service /etc/systemd/system/
|
||||||
|
# Edita el archivo y cambia localhost por la IP del servidor en MASTER_DB_URL y NEXUS_SERVER_HOST
|
||||||
|
nano /etc/systemd/system/nexus-manager.service
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable nexus-manager
|
||||||
|
systemctl start nexus-manager
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Uso
|
||||||
|
|
||||||
|
### Crear una demo
|
||||||
|
1. Ve a la sección **Crear Demos**
|
||||||
|
2. Llena nombre del negocio, email, días de vigencia
|
||||||
|
3. El subdominio se genera automáticamente (puedes personalizarlo)
|
||||||
|
4. Click en **Crear Demo**
|
||||||
|
5. El panel muestra la URL de acceso y el PIN del owner
|
||||||
|
|
||||||
|
### Resetear una demo
|
||||||
|
- Presiona el ícono de 🔄 en la tabla de demos
|
||||||
|
- Limpia TODO el inventario, ventas, clientes, facturas
|
||||||
|
- Conserva empleados (incluyendo el owner) y configuración fiscal
|
||||||
|
|
||||||
|
### Eliminar una demo
|
||||||
|
- Presiona 🗑️ y confirma
|
||||||
|
- Borra permanentemente la base de datos del tenant
|
||||||
|
|
||||||
|
### Migraciones
|
||||||
|
- Ve a **Migraciones** para ver la versión de schema de cada tenant
|
||||||
|
- **Ejecutar todas pendientes** aplica migraciones en TODOS los tenants
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notas de seguridad
|
||||||
|
|
||||||
|
- Cambia `MANAGER_JWT_SECRET` en producción
|
||||||
|
- El panel expone acciones destructivas (delete/reset); protege el acceso con firewall o VPN
|
||||||
|
- Usa HTTPS en producción
|
||||||
|
- Si despliegas en VM separada, asegúrate de que el firewall del servidor principal permite conexiones desde la IP de la VM manager al puerto 5432 (PostgreSQL)
|
||||||
99
manager/app.py
Normal file
99
manager/app.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
"""Nexus Instance Manager — Flask Application."""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from flask import Flask, jsonify, render_template, send_from_directory, request
|
||||||
|
|
||||||
|
# Ensure POS modules are importable for tenant_manager reuse
|
||||||
|
POS_DIR = os.environ.get("POS_DIR", "/home/Autopartes/pos")
|
||||||
|
if POS_DIR not in sys.path:
|
||||||
|
sys.path.insert(0, POS_DIR)
|
||||||
|
|
||||||
|
from config import APP_NAME, APP_VERSION
|
||||||
|
from blueprints.auth_bp import auth_bp, require_manager_auth
|
||||||
|
from blueprints.tenants_bp import tenants_bp
|
||||||
|
from blueprints.demos_bp import demos_bp
|
||||||
|
from blueprints.health_bp import health_bp
|
||||||
|
from blueprints.admin_bp import admin_bp
|
||||||
|
|
||||||
|
|
||||||
|
def create_app():
|
||||||
|
app = Flask(
|
||||||
|
__name__,
|
||||||
|
template_folder="templates",
|
||||||
|
static_folder="static"
|
||||||
|
)
|
||||||
|
app.secret_key = os.environ.get("MANAGER_JWT_SECRET", "dev-secret-change-me")
|
||||||
|
|
||||||
|
# Register blueprints
|
||||||
|
app.register_blueprint(auth_bp)
|
||||||
|
app.register_blueprint(tenants_bp)
|
||||||
|
app.register_blueprint(demos_bp)
|
||||||
|
app.register_blueprint(health_bp)
|
||||||
|
app.register_blueprint(admin_bp)
|
||||||
|
|
||||||
|
# ─── Frontend Routes ───────────────────────────────────────────────────
|
||||||
|
@app.route("/")
|
||||||
|
def index():
|
||||||
|
return render_template("index.html")
|
||||||
|
|
||||||
|
@app.route("/login")
|
||||||
|
def login_page():
|
||||||
|
return render_template("index.html")
|
||||||
|
|
||||||
|
@app.route("/dashboard")
|
||||||
|
def dashboard_page():
|
||||||
|
return render_template("index.html")
|
||||||
|
|
||||||
|
@app.route("/tenants")
|
||||||
|
def tenants_page():
|
||||||
|
return render_template("index.html")
|
||||||
|
|
||||||
|
@app.route("/demos")
|
||||||
|
def demos_page():
|
||||||
|
return render_template("index.html")
|
||||||
|
|
||||||
|
@app.route("/health")
|
||||||
|
def health_page():
|
||||||
|
return render_template("index.html")
|
||||||
|
|
||||||
|
@app.route("/migrations")
|
||||||
|
def migrations_page():
|
||||||
|
return render_template("index.html")
|
||||||
|
|
||||||
|
# ─── Static Asset Helpers ──────────────────────────────────────────────
|
||||||
|
@app.route("/static/<path:filename>")
|
||||||
|
def static_files(filename):
|
||||||
|
return send_from_directory("static", filename)
|
||||||
|
|
||||||
|
# ─── API Status ────────────────────────────────────────────────────────
|
||||||
|
@app.route("/api/status")
|
||||||
|
def api_status():
|
||||||
|
return jsonify({
|
||||||
|
"app": APP_NAME,
|
||||||
|
"version": APP_VERSION,
|
||||||
|
"timestamp": datetime.utcnow().isoformat(),
|
||||||
|
"pos_dir": POS_DIR
|
||||||
|
})
|
||||||
|
|
||||||
|
# ─── Error Handlers ────────────────────────────────────────────────────
|
||||||
|
@app.errorhandler(404)
|
||||||
|
def not_found(e):
|
||||||
|
if request.path.startswith("/api/"):
|
||||||
|
return jsonify({"error": "Not found"}), 404
|
||||||
|
return render_template("index.html")
|
||||||
|
|
||||||
|
@app.errorhandler(500)
|
||||||
|
def internal_error(e):
|
||||||
|
if request.path.startswith("/api/"):
|
||||||
|
return jsonify({"error": "Internal server error"}), 500
|
||||||
|
return render_template("index.html")
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
# Entry point for Gunicorn: gunicorn -w 2 -b 0.0.0.0:5003 app:app
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run(host="0.0.0.0", port=5003, debug=True)
|
||||||
0
manager/blueprints/__init__.py
Normal file
0
manager/blueprints/__init__.py
Normal file
35
manager/blueprints/admin_bp.py
Normal file
35
manager/blueprints/admin_bp.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
"""Admin dashboard blueprint."""
|
||||||
|
from flask import Blueprint, jsonify
|
||||||
|
from blueprints.auth_bp import require_manager_auth
|
||||||
|
from services import tenant_service, migration_service
|
||||||
|
|
||||||
|
admin_bp = Blueprint("admin", __name__, url_prefix="/api/admin")
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route("/stats", methods=["GET"])
|
||||||
|
@require_manager_auth
|
||||||
|
def dashboard_stats():
|
||||||
|
return jsonify(tenant_service.get_dashboard_stats())
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route("/migrations", methods=["GET"])
|
||||||
|
@require_manager_auth
|
||||||
|
def list_migrations():
|
||||||
|
return jsonify({
|
||||||
|
"migrations": migration_service.list_available_migrations(),
|
||||||
|
"tenants": migration_service.get_tenant_versions()
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route("/migrations/run-all", methods=["POST"])
|
||||||
|
@require_manager_auth
|
||||||
|
def run_all_migrations():
|
||||||
|
result = migration_service.run_all_pending_migrations()
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route("/migrations/run/<version>", methods=["POST"])
|
||||||
|
@require_manager_auth
|
||||||
|
def run_specific_migration(version):
|
||||||
|
result = migration_service.run_migration_on_all_tenants(version)
|
||||||
|
return jsonify({"results": result})
|
||||||
99
manager/blueprints/auth_bp.py
Normal file
99
manager/blueprints/auth_bp.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
"""Auth blueprint for Nexus Manager."""
|
||||||
|
import datetime
|
||||||
|
import jwt
|
||||||
|
import bcrypt
|
||||||
|
from flask import Blueprint, request, jsonify, current_app
|
||||||
|
from config import MANAGER_JWT_SECRET, MANAGER_JWT_EXPIRES
|
||||||
|
from services.tenant_service import get_master_conn
|
||||||
|
|
||||||
|
auth_bp = Blueprint("auth", __name__, url_prefix="/api/auth")
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password):
|
||||||
|
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def check_password(password, hashed):
|
||||||
|
return bcrypt.checkpw(password.encode(), hashed.encode())
|
||||||
|
|
||||||
|
|
||||||
|
def create_manager_token(user_id, email, role="admin"):
|
||||||
|
payload = {
|
||||||
|
"user_id": user_id,
|
||||||
|
"email": email,
|
||||||
|
"role": role,
|
||||||
|
"type": "access",
|
||||||
|
"exp": datetime.datetime.utcnow() + datetime.timedelta(seconds=MANAGER_JWT_EXPIRES),
|
||||||
|
"iat": datetime.datetime.utcnow()
|
||||||
|
}
|
||||||
|
return jwt.encode(payload, MANAGER_JWT_SECRET, algorithm="HS256")
|
||||||
|
|
||||||
|
|
||||||
|
def decode_manager_token(token):
|
||||||
|
try:
|
||||||
|
return jwt.decode(token, MANAGER_JWT_SECRET, algorithms=["HS256"])
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def require_manager_auth(f):
|
||||||
|
from functools import wraps
|
||||||
|
@wraps(f)
|
||||||
|
def decorated(*args, **kwargs):
|
||||||
|
auth_header = request.headers.get("Authorization", "")
|
||||||
|
token = None
|
||||||
|
if auth_header.startswith("Bearer "):
|
||||||
|
token = auth_header[7:]
|
||||||
|
elif request.cookies.get("manager_token"):
|
||||||
|
token = request.cookies.get("manager_token")
|
||||||
|
|
||||||
|
if not token:
|
||||||
|
return jsonify({"error": "Unauthorized"}), 401
|
||||||
|
|
||||||
|
payload = decode_manager_token(token)
|
||||||
|
if not payload or payload.get("type") != "access":
|
||||||
|
return jsonify({"error": "Invalid or expired token"}), 401
|
||||||
|
|
||||||
|
request.manager_user = payload
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
return decorated
|
||||||
|
|
||||||
|
|
||||||
|
@auth_bp.route("/login", methods=["POST"])
|
||||||
|
def login():
|
||||||
|
data = request.get_json() or {}
|
||||||
|
email = data.get("email", "").strip().lower()
|
||||||
|
password = data.get("password", "")
|
||||||
|
|
||||||
|
if not email or not password:
|
||||||
|
return jsonify({"error": "Email and password required"}), 400
|
||||||
|
|
||||||
|
conn = get_master_conn()
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id, email, password_hash, role, name
|
||||||
|
FROM manager_users
|
||||||
|
WHERE email = %s AND is_active = true
|
||||||
|
""", (email,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
return jsonify({"error": "Invalid credentials"}), 401
|
||||||
|
|
||||||
|
user_id, db_email, pwd_hash, role, name = row
|
||||||
|
if not check_password(password, pwd_hash):
|
||||||
|
return jsonify({"error": "Invalid credentials"}), 401
|
||||||
|
|
||||||
|
token = create_manager_token(user_id, db_email, role)
|
||||||
|
return jsonify({
|
||||||
|
"access_token": token,
|
||||||
|
"user": {"id": user_id, "email": db_email, "role": role, "name": name}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@auth_bp.route("/me", methods=["GET"])
|
||||||
|
@require_manager_auth
|
||||||
|
def me():
|
||||||
|
return jsonify({"user": request.manager_user})
|
||||||
42
manager/blueprints/demos_bp.py
Normal file
42
manager/blueprints/demos_bp.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
"""Demo provisioning blueprint."""
|
||||||
|
from flask import Blueprint, request, jsonify
|
||||||
|
from blueprints.auth_bp import require_manager_auth
|
||||||
|
from services import tenant_service
|
||||||
|
|
||||||
|
demos_bp = Blueprint("demos", __name__, url_prefix="/api/demos")
|
||||||
|
|
||||||
|
|
||||||
|
@demos_bp.route("", methods=["POST"])
|
||||||
|
@require_manager_auth
|
||||||
|
def create_demo():
|
||||||
|
data = request.get_json() or {}
|
||||||
|
name = data.get("name", "").strip()
|
||||||
|
email = data.get("email", "").strip()
|
||||||
|
days = data.get("days")
|
||||||
|
subdomain = data.get("subdomain", "").strip() or None
|
||||||
|
pin = data.get("pin", "0000").strip()
|
||||||
|
|
||||||
|
if not name:
|
||||||
|
return jsonify({"error": "Business name is required"}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = tenant_service.create_demo(
|
||||||
|
name=name,
|
||||||
|
email=email,
|
||||||
|
demo_days=days,
|
||||||
|
subdomain=subdomain,
|
||||||
|
pin=pin
|
||||||
|
)
|
||||||
|
return jsonify({"data": result}), 201
|
||||||
|
except ValueError as e:
|
||||||
|
return jsonify({"error": str(e)}), 409
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@demos_bp.route("", methods=["GET"])
|
||||||
|
@require_manager_auth
|
||||||
|
def list_demos():
|
||||||
|
all_tenants = tenant_service.list_tenants(include_stats=True)
|
||||||
|
demos = [t for t in all_tenants if t.get("is_demo")]
|
||||||
|
return jsonify({"data": demos})
|
||||||
18
manager/blueprints/health_bp.py
Normal file
18
manager/blueprints/health_bp.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
"""Health check blueprint."""
|
||||||
|
from flask import Blueprint, jsonify
|
||||||
|
from blueprints.auth_bp import require_manager_auth
|
||||||
|
from services import health_service
|
||||||
|
|
||||||
|
health_bp = Blueprint("health", __name__, url_prefix="/api/health")
|
||||||
|
|
||||||
|
|
||||||
|
@health_bp.route("", methods=["GET"])
|
||||||
|
@require_manager_auth
|
||||||
|
def full_health():
|
||||||
|
return jsonify(health_service.get_full_health_report())
|
||||||
|
|
||||||
|
|
||||||
|
@health_bp.route("/tenant/<db_name>", methods=["GET"])
|
||||||
|
@require_manager_auth
|
||||||
|
def tenant_health(db_name):
|
||||||
|
return jsonify(health_service.get_tenant_health(db_name))
|
||||||
81
manager/blueprints/tenants_bp.py
Normal file
81
manager/blueprints/tenants_bp.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
"""Tenant management blueprint."""
|
||||||
|
from flask import Blueprint, request, jsonify
|
||||||
|
from blueprints.auth_bp import require_manager_auth
|
||||||
|
from services import tenant_service
|
||||||
|
|
||||||
|
tenants_bp = Blueprint("tenants", __name__, url_prefix="/api/tenants")
|
||||||
|
|
||||||
|
|
||||||
|
@tenants_bp.route("", methods=["GET"])
|
||||||
|
@require_manager_auth
|
||||||
|
def list_tenants():
|
||||||
|
include_stats = request.args.get("stats", "false").lower() == "true"
|
||||||
|
return jsonify({"data": tenant_service.list_tenants(include_stats=include_stats)})
|
||||||
|
|
||||||
|
|
||||||
|
@tenants_bp.route("/<int:tenant_id>", methods=["GET"])
|
||||||
|
@require_manager_auth
|
||||||
|
def get_tenant(tenant_id):
|
||||||
|
tenant = tenant_service.get_tenant(tenant_id)
|
||||||
|
if not tenant:
|
||||||
|
return jsonify({"error": "Tenant not found"}), 404
|
||||||
|
return jsonify({"data": tenant})
|
||||||
|
|
||||||
|
|
||||||
|
@tenants_bp.route("/<int:tenant_id>/stats", methods=["GET"])
|
||||||
|
@require_manager_auth
|
||||||
|
def get_tenant_stats(tenant_id):
|
||||||
|
tenant = tenant_service.get_tenant(tenant_id)
|
||||||
|
if not tenant:
|
||||||
|
return jsonify({"error": "Tenant not found"}), 404
|
||||||
|
return jsonify({"data": tenant_service._get_tenant_quick_stats(tenant["db_name"])})
|
||||||
|
|
||||||
|
|
||||||
|
@tenants_bp.route("/<int:tenant_id>/toggle", methods=["POST"])
|
||||||
|
@require_manager_auth
|
||||||
|
def toggle_tenant(tenant_id):
|
||||||
|
data = request.get_json() or {}
|
||||||
|
active = data.get("active", True)
|
||||||
|
result = tenant_service.toggle_tenant(tenant_id, active)
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
|
@tenants_bp.route("/<int:tenant_id>/reset", methods=["POST"])
|
||||||
|
@require_manager_auth
|
||||||
|
def reset_tenant(tenant_id):
|
||||||
|
try:
|
||||||
|
result = tenant_service.reset_tenant(tenant_id)
|
||||||
|
return jsonify(result)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@tenants_bp.route("/<int:tenant_id>", methods=["DELETE"])
|
||||||
|
@require_manager_auth
|
||||||
|
def delete_tenant(tenant_id):
|
||||||
|
try:
|
||||||
|
result = tenant_service.delete_tenant(tenant_id)
|
||||||
|
return jsonify(result)
|
||||||
|
except Exception as e:
|
||||||
|
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
|
||||||
57
manager/config.py
Normal file
57
manager/config.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
"""Nexus Instance Manager — Configuration."""
|
||||||
|
import os
|
||||||
|
|
||||||
|
# ─── Database ──────────────────────────────────────────────────────────────
|
||||||
|
MASTER_DB_URL = os.environ.get("MASTER_DB_URL") or os.environ.get("DATABASE_URL")
|
||||||
|
if not MASTER_DB_URL:
|
||||||
|
raise ValueError(
|
||||||
|
"MASTER_DB_URL environment variable is required. "
|
||||||
|
"Example: postgresql://user:pass@localhost/nexus_autoparts"
|
||||||
|
)
|
||||||
|
|
||||||
|
TENANT_DB_URL_TEMPLATE = os.environ.get("TENANT_DB_URL_TEMPLATE")
|
||||||
|
if not TENANT_DB_URL_TEMPLATE:
|
||||||
|
raise ValueError(
|
||||||
|
"TENANT_DB_URL_TEMPLATE environment variable is required. "
|
||||||
|
"Example: postgresql://user:pass@localhost/{db_name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ─── Security ──────────────────────────────────────────────────────────────
|
||||||
|
MANAGER_JWT_SECRET = os.environ.get("MANAGER_JWT_SECRET")
|
||||||
|
if not MANAGER_JWT_SECRET:
|
||||||
|
raise ValueError(
|
||||||
|
"MANAGER_JWT_SECRET environment variable is required. "
|
||||||
|
"Generate one with: python3 -c 'import secrets; print(secrets.token_hex(32))'"
|
||||||
|
)
|
||||||
|
|
||||||
|
MANAGER_JWT_EXPIRES = int(os.environ.get("MANAGER_JWT_EXPIRES", "28800")) # 8 hours
|
||||||
|
|
||||||
|
# Internal API key for manager-to-POS operations
|
||||||
|
INTERNAL_API_KEY = os.environ.get("INTERNAL_API_KEY", "")
|
||||||
|
|
||||||
|
# ─── POS Server (for internal API calls from manager VM) ───────────────────
|
||||||
|
POS_INTERNAL_URL = os.environ.get("POS_INTERNAL_URL", "http://192.168.10.91:5001")
|
||||||
|
|
||||||
|
# ─── Demo Settings ─────────────────────────────────────────────────────────
|
||||||
|
DEMO_DEFAULT_DAYS = int(os.environ.get("DEMO_DEFAULT_DAYS", "14"))
|
||||||
|
DEMO_DEFAULT_PIN = os.environ.get("DEMO_DEFAULT_PIN", "0000")
|
||||||
|
DEMO_SUBDOMAIN_PREFIX = os.environ.get("DEMO_SUBDOMAIN_PREFIX", "demo")
|
||||||
|
|
||||||
|
# ─── Remote Nexus Server (for VM-separated manager) ────────────────────────
|
||||||
|
# Set this to the IP/hostname of the server running POS/PostgreSQL/Redis
|
||||||
|
NEXUS_SERVER_HOST = os.environ.get("NEXUS_SERVER_HOST", "127.0.0.1")
|
||||||
|
|
||||||
|
# ─── Services Health Check ─────────────────────────────────────────────────
|
||||||
|
POS_URL = os.environ.get("POS_URL", f"http://{NEXUS_SERVER_HOST}:5001/pos/health")
|
||||||
|
DASHBOARD_URL = os.environ.get("DASHBOARD_URL", f"http://{NEXUS_SERVER_HOST}:5000/")
|
||||||
|
QUART_URL = os.environ.get("QUART_URL", f"http://{NEXUS_SERVER_HOST}:5002/")
|
||||||
|
REDIS_URL = os.environ.get("REDIS_URL", f"redis://{NEXUS_SERVER_HOST}:6379/0")
|
||||||
|
|
||||||
|
# ─── Paths ─────────────────────────────────────────────────────────────────
|
||||||
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
POS_DIR = os.environ.get("POS_DIR", "/home/Autopartes/pos")
|
||||||
|
MIGRATIONS_DIR = os.path.join(POS_DIR, "migrations")
|
||||||
|
|
||||||
|
# ─── App Identity ──────────────────────────────────────────────────────────
|
||||||
|
APP_NAME = "Nexus Instance Manager"
|
||||||
|
APP_VERSION = "1.0.0"
|
||||||
5
manager/requirements.txt
Normal file
5
manager/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
Flask>=2.3.0
|
||||||
|
psycopg2-binary>=2.9.0
|
||||||
|
bcrypt>=4.0.0
|
||||||
|
PyJWT>=2.8.0
|
||||||
|
redis>=5.0.0
|
||||||
86
manager/scripts/init_manager.py
Normal file
86
manager/scripts/init_manager.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Initialize Nexus Instance Manager: create admin tables and default user."""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import bcrypt
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
# Add manager to path
|
||||||
|
MANAGER_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
if MANAGER_DIR not in sys.path:
|
||||||
|
sys.path.insert(0, MANAGER_DIR)
|
||||||
|
|
||||||
|
from services.tenant_service import get_master_conn
|
||||||
|
|
||||||
|
|
||||||
|
def init_schema():
|
||||||
|
"""Create manager_users table in master DB if not exists."""
|
||||||
|
conn = get_master_conn()
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS manager_users (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
email VARCHAR(200) UNIQUE NOT NULL,
|
||||||
|
name VARCHAR(200) NOT NULL DEFAULT 'Admin',
|
||||||
|
password_hash VARCHAR(200) NOT NULL,
|
||||||
|
role VARCHAR(20) DEFAULT 'admin',
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS manager_audit_log (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_email VARCHAR(200),
|
||||||
|
action VARCHAR(100) NOT NULL,
|
||||||
|
details JSONB,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
print("[OK] Manager schema initialized.")
|
||||||
|
|
||||||
|
|
||||||
|
def create_admin(email, password, name="Admin"):
|
||||||
|
"""Create or update admin user."""
|
||||||
|
pwd_hash = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
|
||||||
|
|
||||||
|
conn = get_master_conn()
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO manager_users (email, name, password_hash, role)
|
||||||
|
VALUES (%s, %s, %s, 'admin')
|
||||||
|
ON CONFLICT (email) DO UPDATE SET
|
||||||
|
password_hash = EXCLUDED.password_hash,
|
||||||
|
name = EXCLUDED.name,
|
||||||
|
is_active = TRUE
|
||||||
|
""", (email.lower(), name, pwd_hash))
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
print(f"[OK] Admin user '{email}' created/updated.")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Initialize Nexus Manager")
|
||||||
|
parser.add_argument("--email", default="admin@nexus.local", help="Admin email")
|
||||||
|
parser.add_argument("--password", default="nexus2026", help="Admin password")
|
||||||
|
parser.add_argument("--name", default="Super Admin", help="Admin display name")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
print("Nexus Instance Manager — Initialization")
|
||||||
|
print("=" * 40)
|
||||||
|
init_schema()
|
||||||
|
create_admin(args.email, args.password, args.name)
|
||||||
|
print("=" * 40)
|
||||||
|
print(f"Login: {args.email}")
|
||||||
|
print(f"URL: http://manager-ip:5003")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
0
manager/services/__init__.py
Normal file
0
manager/services/__init__.py
Normal file
175
manager/services/health_service.py
Normal file
175
manager/services/health_service.py
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
"""Health monitoring service for Nexus infrastructure."""
|
||||||
|
import subprocess
|
||||||
|
import shutil
|
||||||
|
import socket
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
import psycopg2
|
||||||
|
import redis
|
||||||
|
from config import (
|
||||||
|
MASTER_DB_URL, REDIS_URL, POS_URL, DASHBOARD_URL, QUART_URL,
|
||||||
|
TENANT_DB_URL_TEMPLATE, NEXUS_SERVER_HOST
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def check_postgresql():
|
||||||
|
"""Check PostgreSQL connectivity."""
|
||||||
|
try:
|
||||||
|
conn = psycopg2.connect(MASTER_DB_URL, connect_timeout=5)
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("SELECT version(), pg_database_size('nexus_autoparts')")
|
||||||
|
version, size = cur.fetchone()
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"version": version.split()[1] if version else "unknown",
|
||||||
|
"master_size_mb": round(size / (1024 * 1024), 2)
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {"status": "error", "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def check_redis():
|
||||||
|
"""Check Redis connectivity. May be unreachable if Redis only binds to localhost."""
|
||||||
|
try:
|
||||||
|
r = redis.from_url(REDIS_URL, socket_connect_timeout=3)
|
||||||
|
info = r.info()
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"version": info.get("redis_version", "unknown"),
|
||||||
|
"used_memory_human": info.get("used_memory_human", "?"),
|
||||||
|
"connected_clients": info.get("connected_clients", 0)
|
||||||
|
}
|
||||||
|
except redis.ConnectionError:
|
||||||
|
return {
|
||||||
|
"status": "warning",
|
||||||
|
"error": "Redis unreachable. If manager runs on a separate VM, ensure Redis binds to 0.0.0.0 or a VPN interface, or that a tunnel is active."
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {"status": "error", "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def check_http_service(name, url, timeout=5):
|
||||||
|
"""Generic HTTP health check."""
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(url, method="GET")
|
||||||
|
req.add_header("User-Agent", "Nexus-Manager/1.0")
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"http_status": resp.status,
|
||||||
|
"latency_ms": None # Could add timing later
|
||||||
|
}
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return {"status": "warning", "http_status": e.code, "error": str(e)}
|
||||||
|
except Exception as e:
|
||||||
|
return {"status": "error", "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def check_disk_space(path="/"):
|
||||||
|
"""Check disk usage."""
|
||||||
|
try:
|
||||||
|
total, used, free = shutil.disk_usage(path)
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"total_gb": round(total / (1024**3), 2),
|
||||||
|
"used_gb": round(used / (1024**3), 2),
|
||||||
|
"free_gb": round(free / (1024**3), 2),
|
||||||
|
"percent_used": round((used / total) * 100, 1)
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {"status": "error", "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def check_memory():
|
||||||
|
"""Check system memory via /proc/meminfo."""
|
||||||
|
try:
|
||||||
|
with open("/proc/meminfo") as f:
|
||||||
|
meminfo = f.read()
|
||||||
|
data = {}
|
||||||
|
for line in meminfo.splitlines():
|
||||||
|
if ":" in line:
|
||||||
|
key, value = line.split(":", 1)
|
||||||
|
data[key.strip()] = int(value.strip().split()[0]) # kB
|
||||||
|
total = data.get("MemTotal", 0) / 1024 / 1024 # GB
|
||||||
|
available = data.get("MemAvailable", data.get("MemFree", 0)) / 1024 / 1024
|
||||||
|
used = total - available
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"total_gb": round(total, 2),
|
||||||
|
"used_gb": round(used, 2),
|
||||||
|
"available_gb": round(available, 2),
|
||||||
|
"percent_used": round((used / total) * 100, 1) if total else 0
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {"status": "error", "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def check_systemd_service(service_name):
|
||||||
|
"""Check systemd service status."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["systemctl", "is-active", service_name],
|
||||||
|
capture_output=True, text=True, timeout=5
|
||||||
|
)
|
||||||
|
active = result.stdout.strip() == "active"
|
||||||
|
return {
|
||||||
|
"status": "ok" if active else "warning",
|
||||||
|
"active": active,
|
||||||
|
"state": result.stdout.strip()
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {"status": "error", "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def get_full_health_report():
|
||||||
|
"""Aggregate health report for all services."""
|
||||||
|
return {
|
||||||
|
"_meta": {
|
||||||
|
"nexus_server_host": NEXUS_SERVER_HOST,
|
||||||
|
"note": "disk/memory are local to this manager VM. PostgreSQL/HTTP checks target the remote Nexus server."
|
||||||
|
},
|
||||||
|
"postgresql": check_postgresql(),
|
||||||
|
"redis": check_redis(),
|
||||||
|
"pos": check_http_service("pos", POS_URL),
|
||||||
|
"dashboard": check_http_service("dashboard", DASHBOARD_URL),
|
||||||
|
"quart": check_http_service("quart", QUART_URL),
|
||||||
|
"disk": check_disk_space(),
|
||||||
|
"memory": check_memory(),
|
||||||
|
"services": {
|
||||||
|
"nexus": check_systemd_service("nexus.service"),
|
||||||
|
"nexus-pos": check_systemd_service("nexus-pos.service"),
|
||||||
|
"nexus-quart": check_systemd_service("nexus-quart.service"),
|
||||||
|
"nexus-celery": check_systemd_service("nexus-celery.service"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_tenant_health(db_name, timeout=5):
|
||||||
|
"""Check connectivity to a specific tenant database."""
|
||||||
|
dsn = TENANT_DB_URL_TEMPLATE.format(db_name=db_name)
|
||||||
|
try:
|
||||||
|
conn = psycopg2.connect(dsn, connect_timeout=timeout)
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
SELECT
|
||||||
|
(SELECT COUNT(*) FROM employees WHERE is_active = true) as employees,
|
||||||
|
(SELECT COUNT(*) FROM inventory WHERE is_active = true) as inventory,
|
||||||
|
(SELECT COUNT(*) FROM customers WHERE is_active = true) as customers,
|
||||||
|
(SELECT COUNT(*) FROM sales WHERE created_at > NOW() - INTERVAL '30 days') as sales_30d,
|
||||||
|
pg_database_size(current_database()) as db_size
|
||||||
|
""")
|
||||||
|
row = cur.fetchone()
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"employees": row[0],
|
||||||
|
"inventory": row[1],
|
||||||
|
"customers": row[2],
|
||||||
|
"sales_30d": row[3],
|
||||||
|
"db_size_mb": round(row[4] / (1024 * 1024), 2)
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {"status": "error", "error": str(e)}
|
||||||
100
manager/services/migration_service.py
Normal file
100
manager/services/migration_service.py
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
"""Migration orchestration service."""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
POS_DIR = os.environ.get("POS_DIR", "/home/Autopartes/pos")
|
||||||
|
if POS_DIR not in sys.path:
|
||||||
|
sys.path.insert(0, POS_DIR)
|
||||||
|
|
||||||
|
from tenant_db import get_master_conn
|
||||||
|
from config import MIGRATIONS_DIR
|
||||||
|
|
||||||
|
|
||||||
|
def list_available_migrations():
|
||||||
|
"""List migrations found in POS migrations directory."""
|
||||||
|
migrations = []
|
||||||
|
if os.path.isdir(MIGRATIONS_DIR):
|
||||||
|
for fname in sorted(os.listdir(MIGRATIONS_DIR)):
|
||||||
|
if fname.endswith(".sql") and fname.startswith("v"):
|
||||||
|
version = fname.replace(".sql", "")
|
||||||
|
migrations.append({"version": version, "file": fname})
|
||||||
|
return migrations
|
||||||
|
|
||||||
|
|
||||||
|
def get_tenant_versions():
|
||||||
|
"""Get schema version for every tenant."""
|
||||||
|
conn = get_master_conn()
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
SELECT t.id, t.name, t.db_name, COALESCE(v.version, 'v0.0') as version
|
||||||
|
FROM tenants t
|
||||||
|
LEFT JOIN tenant_schema_version v ON v.tenant_id = t.id
|
||||||
|
WHERE t.is_active = true
|
||||||
|
ORDER BY t.id
|
||||||
|
""")
|
||||||
|
results = []
|
||||||
|
for row in cur.fetchall():
|
||||||
|
results.append({
|
||||||
|
"tenant_id": row[0], "name": row[1], "db_name": row[2], "version": row[3]
|
||||||
|
})
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def run_migration_on_tenant(db_name, version):
|
||||||
|
"""Apply a single migration file to a tenant DB."""
|
||||||
|
from migrations.runner import apply_migration
|
||||||
|
return apply_migration(db_name, version)
|
||||||
|
|
||||||
|
|
||||||
|
def run_all_pending_migrations():
|
||||||
|
"""Run all pending migrations on all active tenants (wrapper around POS runner)."""
|
||||||
|
from migrations.runner import run_migrations
|
||||||
|
import io
|
||||||
|
import contextlib
|
||||||
|
|
||||||
|
# Capture stdout to return as log
|
||||||
|
f = io.StringIO()
|
||||||
|
with contextlib.redirect_stdout(f):
|
||||||
|
run_migrations()
|
||||||
|
return {"log": f.getvalue()}
|
||||||
|
|
||||||
|
|
||||||
|
def run_migration_on_all_tenants(version):
|
||||||
|
"""Apply one specific migration version to all tenants that don't have it."""
|
||||||
|
from migrations.runner import MIGRATIONS, apply_migration
|
||||||
|
|
||||||
|
conn = get_master_conn()
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
SELECT t.id, t.db_name, COALESCE(v.version, 'v0.0') as version
|
||||||
|
FROM tenants t
|
||||||
|
LEFT JOIN tenant_schema_version v ON v.tenant_id = t.id
|
||||||
|
WHERE t.is_active = true
|
||||||
|
""")
|
||||||
|
tenants = cur.fetchall()
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for tenant_id, db_name, current_version in tenants:
|
||||||
|
if current_version >= version:
|
||||||
|
results.append({"tenant_id": tenant_id, "db_name": db_name, "skipped": True, "reason": "already at or past version"})
|
||||||
|
continue
|
||||||
|
|
||||||
|
success = apply_migration(db_name, version)
|
||||||
|
if success:
|
||||||
|
# Update version tracker
|
||||||
|
conn2 = get_master_conn()
|
||||||
|
cur2 = conn2.cursor()
|
||||||
|
cur2.execute("""
|
||||||
|
INSERT INTO tenant_schema_version (tenant_id, version)
|
||||||
|
VALUES (%s, %s)
|
||||||
|
ON CONFLICT (tenant_id) DO UPDATE SET version = %s, updated_at = NOW()
|
||||||
|
""", (tenant_id, version, version))
|
||||||
|
conn2.commit()
|
||||||
|
cur2.close()
|
||||||
|
conn2.close()
|
||||||
|
results.append({"tenant_id": tenant_id, "db_name": db_name, "success": success})
|
||||||
|
return results
|
||||||
400
manager/services/tenant_service.py
Normal file
400
manager/services/tenant_service.py
Normal file
@@ -0,0 +1,400 @@
|
|||||||
|
"""Tenant management service wrapping POS tenant_manager."""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import psycopg2
|
||||||
|
from psycopg2 import sql
|
||||||
|
from psycopg2.extensions import ISOLATION_LEVEL_AUTOCOMMIT
|
||||||
|
|
||||||
|
# Add POS to path so we can reuse tenant_manager
|
||||||
|
POS_DIR = os.environ.get("POS_DIR", "/home/Autopartes/pos")
|
||||||
|
if POS_DIR not in sys.path:
|
||||||
|
sys.path.insert(0, POS_DIR)
|
||||||
|
|
||||||
|
from config import MASTER_DB_URL, TENANT_DB_URL_TEMPLATE, DEMO_DEFAULT_DAYS
|
||||||
|
|
||||||
|
|
||||||
|
def get_master_conn():
|
||||||
|
return psycopg2.connect(MASTER_DB_URL)
|
||||||
|
|
||||||
|
|
||||||
|
def list_tenants(include_stats=False):
|
||||||
|
"""List all tenants with optional per-tenant stats."""
|
||||||
|
conn = get_master_conn()
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
SELECT t.id, t.name, t.db_name, t.subdomain, t.rfc, t.plan, t.is_active,
|
||||||
|
t.created_at, COALESCE(s.expires_at, NULL) as expires_at,
|
||||||
|
COALESCE(v.version, 'v0.0') as schema_version
|
||||||
|
FROM tenants t
|
||||||
|
LEFT JOIN subscriptions s ON s.tenant_id = t.id
|
||||||
|
LEFT JOIN tenant_schema_version v ON v.tenant_id = t.id
|
||||||
|
ORDER BY t.id DESC
|
||||||
|
""")
|
||||||
|
cols = [desc[0] for desc in cur.description]
|
||||||
|
tenants = []
|
||||||
|
for row in cur.fetchall():
|
||||||
|
tenant = dict(zip(cols, row))
|
||||||
|
tenant["created_at"] = str(tenant["created_at"]) if tenant["created_at"] else None
|
||||||
|
tenant["expires_at"] = str(tenant["expires_at"]) if tenant["expires_at"] else None
|
||||||
|
tenant["is_demo"] = tenant["plan"] in ("demo", "trial")
|
||||||
|
tenant["demo_days_left"] = None
|
||||||
|
if tenant["expires_at"]:
|
||||||
|
from datetime import datetime
|
||||||
|
try:
|
||||||
|
exp = datetime.fromisoformat(tenant["expires_at"].replace("Z", "+00:00"))
|
||||||
|
now = datetime.now(exp.tzinfo) if exp.tzinfo else datetime.now()
|
||||||
|
tenant["demo_days_left"] = max(0, (exp - now).days)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
tenants.append(tenant)
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if include_stats:
|
||||||
|
for t in tenants:
|
||||||
|
t["stats"] = _get_tenant_quick_stats(t["db_name"])
|
||||||
|
return tenants
|
||||||
|
|
||||||
|
|
||||||
|
def get_tenant(tenant_id):
|
||||||
|
"""Get single tenant details."""
|
||||||
|
conn = get_master_conn()
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
SELECT t.id, t.name, t.db_name, t.subdomain, t.rfc, t.plan, t.is_active,
|
||||||
|
t.created_at, COALESCE(s.expires_at, NULL) as expires_at,
|
||||||
|
COALESCE(s.status, 'unknown') as subscription_status,
|
||||||
|
COALESCE(v.version, 'v0.0') as schema_version
|
||||||
|
FROM tenants t
|
||||||
|
LEFT JOIN subscriptions s ON s.tenant_id = t.id
|
||||||
|
LEFT JOIN tenant_schema_version v ON v.tenant_id = t.id
|
||||||
|
WHERE t.id = %s
|
||||||
|
""", (tenant_id,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
keys = ["id", "name", "db_name", "subdomain", "rfc", "plan", "is_active",
|
||||||
|
"created_at", "expires_at", "subscription_status", "schema_version"]
|
||||||
|
return {k: str(v) if v is not None else None for k, v in zip(keys, row)}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_tenant_quick_stats(db_name):
|
||||||
|
"""Quick stats for a tenant DB."""
|
||||||
|
dsn = TENANT_DB_URL_TEMPLATE.format(db_name=db_name)
|
||||||
|
try:
|
||||||
|
conn = psycopg2.connect(dsn, connect_timeout=5)
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
SELECT
|
||||||
|
(SELECT COUNT(*) FROM employees WHERE is_active = true),
|
||||||
|
(SELECT COUNT(*) FROM inventory WHERE is_active = true),
|
||||||
|
(SELECT COUNT(*) FROM customers WHERE is_active = true),
|
||||||
|
(SELECT COUNT(*) FROM sales WHERE status = 'completed'),
|
||||||
|
pg_database_size(current_database())
|
||||||
|
""")
|
||||||
|
emp, inv, cust, sales, size = cur.fetchone()
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
return {
|
||||||
|
"employees": emp,
|
||||||
|
"inventory_items": inv,
|
||||||
|
"customers": cust,
|
||||||
|
"completed_sales": sales,
|
||||||
|
"db_size_mb": round(size / (1024 * 1024), 2)
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def create_demo(name, email, demo_days=None, subdomain=None, pin="0000"):
|
||||||
|
"""Provision a new demo tenant using POS tenant_manager."""
|
||||||
|
from services.tenant_manager import provision_tenant
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
days = demo_days or DEMO_DEFAULT_DAYS
|
||||||
|
if not subdomain:
|
||||||
|
from services.tenant_manager import generate_subdomain
|
||||||
|
subdomain = generate_subdomain(name)
|
||||||
|
# Ensure uniqueness by appending random suffix if needed
|
||||||
|
conn = get_master_conn()
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("SELECT 1 FROM tenants WHERE subdomain = %s", (subdomain,))
|
||||||
|
if cur.fetchone():
|
||||||
|
import secrets
|
||||||
|
subdomain = f"{subdomain}-{secrets.token_hex(2)}"
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
result = provision_tenant(
|
||||||
|
name=name,
|
||||||
|
rfc=None,
|
||||||
|
owner_name="Admin Demo",
|
||||||
|
owner_email=email,
|
||||||
|
owner_pin=pin,
|
||||||
|
subdomain=subdomain
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mark as demo plan and set expiration
|
||||||
|
tenant_id = result["tenant_id"]
|
||||||
|
conn = get_master_conn()
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("UPDATE tenants SET plan = 'demo' WHERE id = %s", (tenant_id,))
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO subscriptions (tenant_id, plan, status, expires_at)
|
||||||
|
VALUES (%s, 'demo', 'active', %s)
|
||||||
|
ON CONFLICT (tenant_id) DO UPDATE SET
|
||||||
|
plan = 'demo',
|
||||||
|
status = 'active',
|
||||||
|
expires_at = EXCLUDED.expires_at
|
||||||
|
""", (tenant_id, datetime.now() + timedelta(days=days)))
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# Auto-provision WhatsApp Bridge
|
||||||
|
try:
|
||||||
|
import urllib.request
|
||||||
|
import json as _json
|
||||||
|
from config import POS_INTERNAL_URL, INTERNAL_API_KEY
|
||||||
|
bridge_payload = _json.dumps({
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"subdomain": subdomain,
|
||||||
|
"db_name": result["db_name"]
|
||||||
|
}).encode()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{POS_INTERNAL_URL}/pos/api/internal/whatsapp-bridge",
|
||||||
|
data=bridge_payload,
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Internal-Key": INTERNAL_API_KEY
|
||||||
|
},
|
||||||
|
method="POST"
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||||
|
bridge_data = _json.loads(resp.read().decode())
|
||||||
|
result["whatsapp_bridge"] = bridge_data
|
||||||
|
except Exception as e:
|
||||||
|
result["whatsapp_bridge_error"] = str(e)
|
||||||
|
|
||||||
|
result["demo_days"] = days
|
||||||
|
result["expires_at"] = str(datetime.now() + timedelta(days=days))
|
||||||
|
result["access_url"] = f"https://{subdomain}.nexusautoparts.com.mx/pos/login"
|
||||||
|
result["owner_pin"] = pin
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def reset_tenant(tenant_id, keep_config=True):
|
||||||
|
"""Reset a tenant: truncate business data but keep structure and owner."""
|
||||||
|
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)
|
||||||
|
|
||||||
|
tables_to_truncate = [
|
||||||
|
"inventory_operations",
|
||||||
|
"inventory",
|
||||||
|
"sale_items",
|
||||||
|
"sales",
|
||||||
|
"customer_payments",
|
||||||
|
"cash_register_closings",
|
||||||
|
"cash_register_movements",
|
||||||
|
"cash_registers",
|
||||||
|
"invoices",
|
||||||
|
"accounting_entries",
|
||||||
|
"journal_entries",
|
||||||
|
"service_orders",
|
||||||
|
"fleet_vehicles",
|
||||||
|
"crm_activities",
|
||||||
|
"quotations",
|
||||||
|
"quotation_items",
|
||||||
|
"savings_transactions",
|
||||||
|
"savings_accounts",
|
||||||
|
"supplier_orders",
|
||||||
|
"supplier_order_items",
|
||||||
|
"warranty_claims",
|
||||||
|
"notifications",
|
||||||
|
"inventory_uploads",
|
||||||
|
]
|
||||||
|
|
||||||
|
conn = psycopg2.connect(dsn)
|
||||||
|
cur = conn.cursor()
|
||||||
|
try:
|
||||||
|
for table in tables_to_truncate:
|
||||||
|
try:
|
||||||
|
cur.execute(f"TRUNCATE TABLE {table} RESTART IDENTITY CASCADE")
|
||||||
|
except Exception:
|
||||||
|
pass # Table may not exist
|
||||||
|
conn.commit()
|
||||||
|
success = True
|
||||||
|
except Exception as e:
|
||||||
|
conn.rollback()
|
||||||
|
success = False
|
||||||
|
raise RuntimeError(f"Reset failed: {e}")
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return {"success": success, "tenant_id": tenant_id, "tables_reset": len(tables_to_truncate)}
|
||||||
|
|
||||||
|
|
||||||
|
def delete_tenant(tenant_id):
|
||||||
|
"""Permanently delete a tenant and its database."""
|
||||||
|
tenant = get_tenant(tenant_id)
|
||||||
|
if not tenant:
|
||||||
|
raise ValueError("Tenant not found")
|
||||||
|
db_name = tenant["db_name"]
|
||||||
|
subdomain = tenant.get("subdomain") or f"tenant-{tenant_id}"
|
||||||
|
|
||||||
|
# Destroy WhatsApp Bridge container
|
||||||
|
try:
|
||||||
|
import urllib.request
|
||||||
|
import json as _json
|
||||||
|
from config import POS_INTERNAL_URL, INTERNAL_API_KEY
|
||||||
|
bridge_payload = _json.dumps({"subdomain": subdomain}).encode()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{POS_INTERNAL_URL}/pos/api/internal/whatsapp-bridge",
|
||||||
|
data=bridge_payload,
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Internal-Key": INTERNAL_API_KEY
|
||||||
|
},
|
||||||
|
method="DELETE"
|
||||||
|
)
|
||||||
|
urllib.request.urlopen(req, timeout=15)
|
||||||
|
except Exception:
|
||||||
|
pass # Bridge may not exist
|
||||||
|
|
||||||
|
conn = get_master_conn()
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
# Drop database
|
||||||
|
try:
|
||||||
|
master_conn = psycopg2.connect(MASTER_DB_URL)
|
||||||
|
master_conn.set_isolation_level(ISOLATION_LEVEL_AUTOCOMMIT)
|
||||||
|
master_cur = master_conn.cursor()
|
||||||
|
master_cur.execute(
|
||||||
|
sql.SQL('DROP DATABASE IF EXISTS {}').format(sql.Identifier(db_name))
|
||||||
|
)
|
||||||
|
master_cur.close()
|
||||||
|
master_conn.close()
|
||||||
|
except Exception as e:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Clean master records
|
||||||
|
cur.execute("DELETE FROM tenant_schema_version WHERE tenant_id = %s", (tenant_id,))
|
||||||
|
cur.execute("DELETE FROM subscriptions WHERE tenant_id = %s", (tenant_id,))
|
||||||
|
cur.execute("DELETE FROM tenants WHERE id = %s", (tenant_id,))
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
return {"success": True, "tenant_id": tenant_id, "db_name": db_name}
|
||||||
|
|
||||||
|
|
||||||
|
def toggle_tenant(tenant_id, active):
|
||||||
|
"""Activate or deactivate a tenant."""
|
||||||
|
conn = get_master_conn()
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("UPDATE tenants SET is_active = %s WHERE id = %s", (active, tenant_id))
|
||||||
|
conn.commit()
|
||||||
|
rowcount = cur.rowcount
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
return {"success": rowcount > 0, "tenant_id": tenant_id, "is_active": active}
|
||||||
|
|
||||||
|
|
||||||
|
def get_tenant_login_url(subdomain):
|
||||||
|
"""Generate login URL for a tenant."""
|
||||||
|
domain = os.environ.get("NEXUS_DOMAIN", "nexusautoparts.com.mx")
|
||||||
|
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", "module_catalog"]:
|
||||||
|
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",
|
||||||
|
"catalog": "module_catalog",
|
||||||
|
}
|
||||||
|
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():
|
||||||
|
"""Global stats for the manager dashboard."""
|
||||||
|
conn = get_master_conn()
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
cur.execute("SELECT COUNT(*) FROM tenants")
|
||||||
|
total = cur.fetchone()[0]
|
||||||
|
|
||||||
|
cur.execute("SELECT COUNT(*) FROM tenants WHERE is_active = true")
|
||||||
|
active = cur.fetchone()[0]
|
||||||
|
|
||||||
|
cur.execute("SELECT COUNT(*) FROM tenants WHERE plan = 'demo'")
|
||||||
|
demos = cur.fetchone()[0]
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
SELECT COUNT(*) FROM subscriptions
|
||||||
|
WHERE status = 'active' AND expires_at < NOW() + INTERVAL '7 days'
|
||||||
|
""")
|
||||||
|
expiring_soon = cur.fetchone()[0]
|
||||||
|
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# Get system health summary
|
||||||
|
from services.health_service import check_disk_space, check_memory
|
||||||
|
disk = check_disk_space()
|
||||||
|
mem = check_memory()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"tenants": {"total": total, "active": active, "demos": demos, "expiring_soon": expiring_soon},
|
||||||
|
"system": {
|
||||||
|
"disk_percent": disk.get("percent_used"),
|
||||||
|
"memory_percent": mem.get("percent_used"),
|
||||||
|
"disk_free_gb": disk.get("free_gb"),
|
||||||
|
"memory_available_gb": mem.get("available_gb")
|
||||||
|
}
|
||||||
|
}
|
||||||
702
manager/static/css/manager.css
Normal file
702
manager/static/css/manager.css
Normal file
@@ -0,0 +1,702 @@
|
|||||||
|
:root {
|
||||||
|
--bg-dark: #0f1117;
|
||||||
|
--bg-card: #1a1d26;
|
||||||
|
--bg-sidebar: #161920;
|
||||||
|
--bg-hover: #232631;
|
||||||
|
--border: #2a2e3b;
|
||||||
|
--text-primary: #e8eaf0;
|
||||||
|
--text-secondary: #9ca3af;
|
||||||
|
--accent: #3b82f6;
|
||||||
|
--accent-hover: #2563eb;
|
||||||
|
--success: #22c55e;
|
||||||
|
--warning: #f59e0b;
|
||||||
|
--danger: #ef4444;
|
||||||
|
--info: #06b6d4;
|
||||||
|
--purple: #8b5cf6;
|
||||||
|
--radius: 10px;
|
||||||
|
--shadow: 0 4px 6px -1px rgba(0,0,0,0.3), 0 2px 4px -1px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
|
background: var(--bg-dark);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Login ─────────────────────────────────────────────────────────────── */
|
||||||
|
.login-screen {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(135deg, #0f1117 0%, #1a1d26 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 40px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-logo {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-logo i {
|
||||||
|
font-size: 48px;
|
||||||
|
color: var(--accent);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-logo h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-logo p {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Layout ────────────────────────────────────────────────────────────── */
|
||||||
|
.app {
|
||||||
|
display: flex;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: 260px;
|
||||||
|
background: var(--bg-sidebar);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-brand {
|
||||||
|
padding: 20px 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-brand i {
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav {
|
||||||
|
flex: 1;
|
||||||
|
padding: 16px 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 0.2s;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.active {
|
||||||
|
background: rgba(59, 130, 246, 0.15);
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item .badge {
|
||||||
|
margin-left: auto;
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-footer {
|
||||||
|
padding: 16px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
height: 60px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar h2 {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator i {
|
||||||
|
font-size: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator.warning { color: var(--warning); }
|
||||||
|
.status-indicator.error { color: var(--danger); }
|
||||||
|
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 24px 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page { animation: fadeIn 0.2s ease; }
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(8px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Cards & Grid ──────────────────────────────────────────────────────── */
|
||||||
|
.card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
padding: 20px 24px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header h3 {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header h3 i {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 20px;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-blue { background: linear-gradient(135deg, #3b82f6, #2563eb); }
|
||||||
|
.bg-green { background: linear-gradient(135deg, #22c55e, #16a34a); }
|
||||||
|
.bg-purple { background: linear-gradient(135deg, #8b5cf6, #7c3aed); }
|
||||||
|
.bg-orange { background: linear-gradient(135deg, #f59e0b, #d97706); }
|
||||||
|
.bg-red { background: linear-gradient(135deg, #ef4444, #dc2626); }
|
||||||
|
.bg-cyan { background: linear-gradient(135deg, #06b6d4, #0891b2); }
|
||||||
|
|
||||||
|
.stat-info h3 {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-info p {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-2 {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-3 {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Tables ────────────────────────────────────────────────────────────── */
|
||||||
|
.table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 12px 16px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table td {
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table tr:hover td {
|
||||||
|
background: rgba(255,255,255,0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table.compact td, .table.compact th {
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Forms ─────────────────────────────────────────────────────────────── */
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input,
|
||||||
|
.form-group select,
|
||||||
|
.form-group textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: var(--bg-dark);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus,
|
||||||
|
.form-group select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group input {
|
||||||
|
border-radius: 8px 0 0 8px;
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-suffix {
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0 8px 8px 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Buttons ───────────────────────────────────────────────────────────── */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 18px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover { background: var(--accent-hover); }
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover { background: var(--border); }
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: var(--danger);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover { background: #dc2626; }
|
||||||
|
|
||||||
|
.btn-success {
|
||||||
|
background: var(--success);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm { padding: 6px 12px; font-size: 12px; }
|
||||||
|
.btn-block { width: 100%; justify-content: center; }
|
||||||
|
.btn-icon {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Badges & Tags ─────────────────────────────────────────────────────── */
|
||||||
|
.tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-success { background: rgba(34,197,94,0.15); color: var(--success); }
|
||||||
|
.tag-warning { background: rgba(245,158,11,0.15); color: var(--warning); }
|
||||||
|
.tag-danger { background: rgba(239,68,68,0.15); color: var(--danger); }
|
||||||
|
.tag-info { background: rgba(6,182,212,0.15); color: var(--info); }
|
||||||
|
.tag-default { background: var(--bg-hover); color: var(--text-secondary); }
|
||||||
|
|
||||||
|
/* ─── Alerts & Boxes ────────────────────────────────────────────────────── */
|
||||||
|
.alert {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-error {
|
||||||
|
background: rgba(239,68,68,0.1);
|
||||||
|
border: 1px solid rgba(239,68,68,0.2);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-success {
|
||||||
|
background: rgba(34,197,94,0.1);
|
||||||
|
border: 1px solid rgba(34,197,94,0.2);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-box {
|
||||||
|
background: rgba(34,197,94,0.05);
|
||||||
|
border: 1px solid rgba(34,197,94,0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-box h4 {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-box .copy-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 6px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-box code {
|
||||||
|
background: var(--bg-dark);
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: 'Fira Code', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-box {
|
||||||
|
background: var(--bg-dark);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-family: 'Fira Code', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Modal ─────────────────────────────────────────────────────────────── */
|
||||||
|
.modal {
|
||||||
|
position: fixed;
|
||||||
|
top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0,0,0,0.6);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
position: relative;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 480px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
animation: modalIn 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes modalIn {
|
||||||
|
from { opacity: 0; transform: scale(0.95); }
|
||||||
|
to { opacity: 1; transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
padding: 20px 24px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 24px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
padding: 16px 24px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Toast ─────────────────────────────────────────────────────────────── */
|
||||||
|
#toast-container {
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
z-index: 2000;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
animation: toastIn 0.3s ease;
|
||||||
|
min-width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.success { border-left: 3px solid var(--success); }
|
||||||
|
.toast.error { border-left: 3px solid var(--danger); }
|
||||||
|
.toast.warning { border-left: 3px solid var(--warning); }
|
||||||
|
|
||||||
|
@keyframes toastIn {
|
||||||
|
from { transform: translateX(100%); opacity: 0; }
|
||||||
|
to { transform: translateX(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Utilities ─────────────────────────────────────────────────────────── */
|
||||||
|
.loading {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-style: italic;
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-muted { color: var(--text-secondary); }
|
||||||
|
.text-center { text-align: center; }
|
||||||
|
|
||||||
|
.health-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 10px 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-item:last-child { border-bottom: none; }
|
||||||
|
|
||||||
|
.health-label { color: var(--text-secondary); font-size: 13px; }
|
||||||
|
.health-value { font-weight: 500; font-size: 13px; }
|
||||||
|
|
||||||
|
.health-bar-bg {
|
||||||
|
height: 6px;
|
||||||
|
background: var(--bg-dark);
|
||||||
|
border-radius: 3px;
|
||||||
|
margin-top: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: width 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Responsive ────────────────────────────────────────────────────────── */
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.stats-grid { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
.grid-2, .grid-3 { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.sidebar { width: 64px; }
|
||||||
|
.sidebar-brand span, .nav-item span, .user-info span { display: none; }
|
||||||
|
.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);
|
||||||
|
}
|
||||||
536
manager/static/js/manager.js
Normal file
536
manager/static/js/manager.js
Normal file
@@ -0,0 +1,536 @@
|
|||||||
|
/**
|
||||||
|
* Nexus Instance Manager — Frontend SPA
|
||||||
|
*/
|
||||||
|
|
||||||
|
const API_BASE = "";
|
||||||
|
let currentToken = localStorage.getItem("manager_token") || "";
|
||||||
|
|
||||||
|
// ─── Router ────────────────────────────────────────────────────────────────
|
||||||
|
const routes = {
|
||||||
|
"#dashboard": "dashboard",
|
||||||
|
"#demos": "demos",
|
||||||
|
"#tenants": "tenants",
|
||||||
|
"#health": "health",
|
||||||
|
"#migrations": "migrations"
|
||||||
|
};
|
||||||
|
|
||||||
|
function navigate() {
|
||||||
|
const hash = window.location.hash || "#dashboard";
|
||||||
|
const page = routes[hash] || "dashboard";
|
||||||
|
|
||||||
|
document.querySelectorAll(".page").forEach(p => p.style.display = "none");
|
||||||
|
document.getElementById(`page-${page}`).style.display = "block";
|
||||||
|
|
||||||
|
document.querySelectorAll(".nav-item").forEach(n => n.classList.remove("active"));
|
||||||
|
const nav = document.querySelector(`.nav-item[data-page="${page}"]`);
|
||||||
|
if (nav) nav.classList.add("active");
|
||||||
|
|
||||||
|
const titles = {
|
||||||
|
dashboard: "Dashboard",
|
||||||
|
demos: "Crear Demos",
|
||||||
|
tenants: "Tenants",
|
||||||
|
health: "Salud del Sistema",
|
||||||
|
migrations: "Migraciones"
|
||||||
|
};
|
||||||
|
document.getElementById("page-title").textContent = titles[page] || "Dashboard";
|
||||||
|
|
||||||
|
// Load page data
|
||||||
|
if (page === "dashboard") loadDashboard();
|
||||||
|
if (page === "demos") loadDemos();
|
||||||
|
if (page === "tenants") loadTenants();
|
||||||
|
if (page === "health") loadHealth();
|
||||||
|
if (page === "migrations") loadMigrations();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("hashchange", navigate);
|
||||||
|
|
||||||
|
// ─── Auth ──────────────────────────────────────────────────────────────────
|
||||||
|
async function api(url, opts = {}) {
|
||||||
|
const options = {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": `Bearer ${currentToken}`
|
||||||
|
},
|
||||||
|
...opts
|
||||||
|
};
|
||||||
|
if (opts.body && typeof opts.body !== "string") {
|
||||||
|
options.body = JSON.stringify(opts.body);
|
||||||
|
}
|
||||||
|
const res = await fetch(`${API_BASE}${url}`, options);
|
||||||
|
if (res.status === 401) {
|
||||||
|
logout();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
return { status: res.status, data };
|
||||||
|
}
|
||||||
|
|
||||||
|
function showLogin() {
|
||||||
|
document.getElementById("login-screen").style.display = "flex";
|
||||||
|
document.getElementById("app").style.display = "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
function showApp() {
|
||||||
|
document.getElementById("login-screen").style.display = "none";
|
||||||
|
document.getElementById("app").style.display = "flex";
|
||||||
|
navigate();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initAuth() {
|
||||||
|
if (!currentToken) {
|
||||||
|
showLogin();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const res = await api("/api/auth/me");
|
||||||
|
if (res && res.status === 200) {
|
||||||
|
document.getElementById("user-email").textContent = res.data.user.email;
|
||||||
|
showApp();
|
||||||
|
} else {
|
||||||
|
showLogin();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("login-form").addEventListener("submit", async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const email = document.getElementById("login-email").value;
|
||||||
|
const password = document.getElementById("login-password").value;
|
||||||
|
const errEl = document.getElementById("login-error");
|
||||||
|
errEl.style.display = "none";
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE}/api/auth/login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email, password })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
currentToken = data.access_token;
|
||||||
|
localStorage.setItem("manager_token", currentToken);
|
||||||
|
document.getElementById("user-email").textContent = data.user.email;
|
||||||
|
showApp();
|
||||||
|
} else {
|
||||||
|
errEl.textContent = data.error || "Error de autenticación";
|
||||||
|
errEl.style.display = "block";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
currentToken = "";
|
||||||
|
localStorage.removeItem("manager_token");
|
||||||
|
showLogin();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Dashboard ─────────────────────────────────────────────────────────────
|
||||||
|
async function loadDashboard() {
|
||||||
|
const statsRes = await api("/api/admin/stats");
|
||||||
|
if (statsRes && statsRes.status === 200) {
|
||||||
|
const s = statsRes.data;
|
||||||
|
document.getElementById("stat-total").textContent = s.tenants.total;
|
||||||
|
document.getElementById("stat-active").textContent = s.tenants.active;
|
||||||
|
document.getElementById("stat-demos").textContent = s.tenants.demos;
|
||||||
|
document.getElementById("stat-expiring").textContent = s.tenants.expiring_soon;
|
||||||
|
|
||||||
|
const healthEl = document.getElementById("system-health-summary");
|
||||||
|
healthEl.innerHTML = `
|
||||||
|
<div class="health-item">
|
||||||
|
<span class="health-label">Disco usado</span>
|
||||||
|
<span class="health-value">${s.system.disk_percent}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="health-bar-bg"><div class="health-bar-fill bg-blue" style="width:${s.system.disk_percent}%; background:${getBarColor(s.system.disk_percent)}"></div></div>
|
||||||
|
<div class="health-item" style="margin-top:12px">
|
||||||
|
<span class="health-label">Memoria usada</span>
|
||||||
|
<span class="health-value">${s.system.memory_percent}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="health-bar-bg"><div class="health-bar-fill bg-blue" style="width:${s.system.memory_percent}%; background:${getBarColor(s.system.memory_percent)}"></div></div>
|
||||||
|
<div class="health-item" style="margin-top:12px">
|
||||||
|
<span class="health-label">Disco libre</span>
|
||||||
|
<span class="health-value">${s.system.disk_free_gb} GB</span>
|
||||||
|
</div>
|
||||||
|
<div class="health-item">
|
||||||
|
<span class="health-label">RAM disponible</span>
|
||||||
|
<span class="health-value">${s.system.memory_available_gb} GB</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tenantsRes = await api("/api/demos");
|
||||||
|
if (tenantsRes && tenantsRes.status === 200) {
|
||||||
|
const tbody = document.getElementById("recent-demos-table");
|
||||||
|
const demos = tenantsRes.data.data.slice(0, 5);
|
||||||
|
tbody.innerHTML = demos.map(d => `
|
||||||
|
<tr>
|
||||||
|
<td><strong>${escapeHtml(d.name)}</strong></td>
|
||||||
|
<td><code>${escapeHtml(d.subdomain)}</code></td>
|
||||||
|
<td>${d.demo_days_left !== null ? d.demo_days_left + " días" : "N/A"}</td>
|
||||||
|
<td>${d.is_active ? tag("Activo", "success") : tag("Inactivo", "danger")}</td>
|
||||||
|
</tr>
|
||||||
|
`).join("") || `<tr><td colspan="4" class="text-muted text-center">No hay demos activas</td></tr>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBarColor(pct) {
|
||||||
|
if (pct < 60) return "var(--success)";
|
||||||
|
if (pct < 85) return "var(--warning)";
|
||||||
|
return "var(--danger)";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Demos ─────────────────────────────────────────────────────────────────
|
||||||
|
async function loadDemos() {
|
||||||
|
const res = await api("/api/demos");
|
||||||
|
if (!res || res.status !== 200) return;
|
||||||
|
|
||||||
|
const tbody = document.getElementById("demos-table");
|
||||||
|
const demos = res.data.data;
|
||||||
|
tbody.innerHTML = demos.map(d => `
|
||||||
|
<tr>
|
||||||
|
<td><strong>${escapeHtml(d.name)}</strong></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>
|
||||||
|
<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="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>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join("") || `<tr><td colspan="4" class="text-muted text-center">No hay demos</td></tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("demo-form").addEventListener("submit", async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const btn = e.target.querySelector("button[type=submit]");
|
||||||
|
const originalText = btn.innerHTML;
|
||||||
|
btn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> Creando...`;
|
||||||
|
btn.disabled = true;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
name: document.getElementById("demo-name").value,
|
||||||
|
email: document.getElementById("demo-email").value,
|
||||||
|
days: parseInt(document.getElementById("demo-days").value),
|
||||||
|
pin: document.getElementById("demo-pin").value,
|
||||||
|
subdomain: document.getElementById("demo-subdomain").value || undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await api("/api/demos", { method: "POST", body: payload });
|
||||||
|
const resultBox = document.getElementById("demo-result");
|
||||||
|
|
||||||
|
if (res && res.status === 201) {
|
||||||
|
const d = res.data.data;
|
||||||
|
resultBox.innerHTML = `
|
||||||
|
<h4><i class="fas fa-check-circle"></i> Demo creada exitosamente</h4>
|
||||||
|
<div class="copy-row"><strong>URL:</strong> <code>${d.access_url}</code> <button class="btn-icon" onclick="copyText('${d.access_url}')"><i class="fas fa-copy"></i></button></div>
|
||||||
|
<div class="copy-row"><strong>Subdominio:</strong> <code>${d.subdomain}</code></div>
|
||||||
|
<div class="copy-row"><strong>PIN Owner:</strong> <code>${d.owner_pin}</code></div>
|
||||||
|
<div class="copy-row"><strong>Expira:</strong> ${new Date(d.expires_at).toLocaleDateString()}</div>
|
||||||
|
`;
|
||||||
|
resultBox.style.display = "block";
|
||||||
|
toast("Demo creada correctamente", "success");
|
||||||
|
document.getElementById("demo-form").reset();
|
||||||
|
loadDemos();
|
||||||
|
} else {
|
||||||
|
toast(res?.data?.error || "Error al crear demo", "error");
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.innerHTML = originalText;
|
||||||
|
btn.disabled = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Tenants ───────────────────────────────────────────────────────────────
|
||||||
|
async function loadTenants(withStats = false) {
|
||||||
|
const res = await api(`/api/tenants?stats=${withStats}`);
|
||||||
|
if (!res || res.status !== 200) return;
|
||||||
|
|
||||||
|
const tbody = document.getElementById("tenants-table");
|
||||||
|
const tenants = res.data.data;
|
||||||
|
document.getElementById("tenant-count").textContent = tenants.length;
|
||||||
|
|
||||||
|
tbody.innerHTML = tenants.map(t => `
|
||||||
|
<tr>
|
||||||
|
<td>${t.id}</td>
|
||||||
|
<td><strong>${escapeHtml(t.name)}</strong></td>
|
||||||
|
<td><code>${escapeHtml(t.subdomain)}</code></td>
|
||||||
|
<td>${tag(t.plan || "basic", t.plan === "demo" ? "info" : "default")}</td>
|
||||||
|
<td>${t.schema_version || "v0.0"}</td>
|
||||||
|
<td>${t.is_active ? tag("Activo", "success") : tag("Inactivo", "danger")}</td>
|
||||||
|
<td>${formatDate(t.created_at)}</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="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>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join("") || `<tr><td colspan="8" class="text-muted text-center">No hay tenants</td></tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("tenant-search")?.addEventListener("input", (e) => {
|
||||||
|
const term = e.target.value.toLowerCase();
|
||||||
|
document.querySelectorAll("#tenants-table tr").forEach(row => {
|
||||||
|
row.style.display = row.textContent.toLowerCase().includes(term) ? "" : "none";
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Health ────────────────────────────────────────────────────────────────
|
||||||
|
async function loadHealth() {
|
||||||
|
const res = await api("/api/health");
|
||||||
|
if (!res || res.status !== 200) return;
|
||||||
|
|
||||||
|
const h = res.data;
|
||||||
|
|
||||||
|
// PostgreSQL
|
||||||
|
const pg = h.postgresql;
|
||||||
|
document.getElementById("health-postgresql").innerHTML = pg.status === "ok" ? `
|
||||||
|
<div class="health-item"><span class="health-label">Estado</span><span class="health-value" style="color:var(--success)">Online</span></div>
|
||||||
|
<div class="health-item"><span class="health-label">Versión</span><span class="health-value">${pg.version}</span></div>
|
||||||
|
<div class="health-item"><span class="health-label">Master DB</span><span class="health-value">${pg.master_size_mb} MB</span></div>
|
||||||
|
` : renderError(pg.error);
|
||||||
|
|
||||||
|
// Redis
|
||||||
|
const rd = h.redis;
|
||||||
|
document.getElementById("health-redis").innerHTML = rd.status === "ok" ? `
|
||||||
|
<div class="health-item"><span class="health-label">Estado</span><span class="health-value" style="color:var(--success)">Online</span></div>
|
||||||
|
<div class="health-item"><span class="health-label">Versión</span><span class="health-value">${rd.version}</span></div>
|
||||||
|
<div class="health-item"><span class="health-label">Memoria</span><span class="health-value">${rd.used_memory_human}</span></div>
|
||||||
|
<div class="health-item"><span class="health-label">Clientes</span><span class="health-value">${rd.connected_clients}</span></div>
|
||||||
|
` : renderError(rd.error);
|
||||||
|
|
||||||
|
// Disk
|
||||||
|
const dk = h.disk;
|
||||||
|
document.getElementById("health-disk").innerHTML = dk.status === "ok" ? `
|
||||||
|
<div class="health-item"><span class="health-label">Total</span><span class="health-value">${dk.total_gb} GB</span></div>
|
||||||
|
<div class="health-item"><span class="health-label">Usado</span><span class="health-value">${dk.used_gb} GB (${dk.percent_used}%)</span></div>
|
||||||
|
<div class="health-bar-bg"><div class="health-bar-fill" style="width:${dk.percent_used}%; background:${getBarColor(dk.percent_used)}"></div></div>
|
||||||
|
<div class="health-item" style="margin-top:12px"><span class="health-label">Libre</span><span class="health-value">${dk.free_gb} GB</span></div>
|
||||||
|
` : renderError(dk.error);
|
||||||
|
|
||||||
|
// Memory
|
||||||
|
const mem = h.memory;
|
||||||
|
document.getElementById("health-memory").innerHTML = mem.status === "ok" ? `
|
||||||
|
<div class="health-item"><span class="health-label">Total</span><span class="health-value">${mem.total_gb} GB</span></div>
|
||||||
|
<div class="health-item"><span class="health-label">Usada</span><span class="health-value">${mem.used_gb} GB (${mem.percent_used}%)</span></div>
|
||||||
|
<div class="health-bar-bg"><div class="health-bar-fill" style="width:${mem.percent_used}%; background:${getBarColor(mem.percent_used)}"></div></div>
|
||||||
|
<div class="health-item" style="margin-top:12px"><span class="health-label">Disponible</span><span class="health-value">${mem.available_gb} GB</span></div>
|
||||||
|
` : renderError(mem.error);
|
||||||
|
|
||||||
|
// Services
|
||||||
|
const svcs = h.services || {};
|
||||||
|
document.getElementById("health-services").innerHTML = Object.entries(svcs).map(([name, s]) => `
|
||||||
|
<div class="health-item">
|
||||||
|
<span class="health-label"><i class="fas fa-${s.active ? "check-circle" : "times-circle"}" style="color:${s.active ? "var(--success)" : "var(--danger)"}; margin-right:6px"></i>${name}</span>
|
||||||
|
<span class="health-value" style="color:${s.active ? "var(--success)" : "var(--danger)"}">${s.state}</span>
|
||||||
|
</div>
|
||||||
|
`).join("");
|
||||||
|
|
||||||
|
// HTTP
|
||||||
|
const httpChecks = ["pos", "dashboard", "quart"];
|
||||||
|
document.getElementById("health-http").innerHTML = `
|
||||||
|
<div class="grid-3">
|
||||||
|
${httpChecks.map(key => {
|
||||||
|
const svc = h[key];
|
||||||
|
const ok = svc && svc.status === "ok";
|
||||||
|
return `
|
||||||
|
<div class="health-item">
|
||||||
|
<span class="health-label">${key.toUpperCase()}</span>
|
||||||
|
<span class="health-value" style="color:${ok ? "var(--success)" : "var(--danger)"}">
|
||||||
|
${ok ? `HTTP ${svc.http_status}` : (svc.error || "Offline")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join("")}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderError(msg) {
|
||||||
|
return `<div class="text-muted" style="padding:20px; text-align:center; color:var(--danger)"><i class="fas fa-exclamation-triangle"></i> ${escapeHtml(msg)}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Migrations ────────────────────────────────────────────────────────────
|
||||||
|
async function loadMigrations() {
|
||||||
|
const res = await api("/api/admin/migrations");
|
||||||
|
if (!res || res.status !== 200) return;
|
||||||
|
|
||||||
|
const tbody = document.getElementById("migrations-table");
|
||||||
|
const tenants = res.data.tenants || [];
|
||||||
|
tbody.innerHTML = tenants.map(t => {
|
||||||
|
const needsUpdate = t.version !== (res.data.migrations.slice(-1)[0]?.version || t.version);
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td>${escapeHtml(t.name)}</td>
|
||||||
|
<td><code>${t.db_name}</code></td>
|
||||||
|
<td>${t.version}</td>
|
||||||
|
<td>${needsUpdate ? tag("Pendiente", "warning") : tag("OK", "success")}</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join("") || `<tr><td colspan="4" class="text-muted text-center">No hay tenants</td></tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAllMigrations() {
|
||||||
|
if (!confirm("¿Ejecutar todas las migraciones pendientes en TODOS los tenants?")) return;
|
||||||
|
|
||||||
|
const logBox = document.getElementById("migration-log");
|
||||||
|
logBox.style.display = "block";
|
||||||
|
logBox.textContent = "Ejecutando migraciones...";
|
||||||
|
|
||||||
|
const res = await api("/api/admin/migrations/run-all", { method: "POST" });
|
||||||
|
if (res && res.status === 200) {
|
||||||
|
logBox.textContent = res.data.log || "Completado";
|
||||||
|
toast("Migraciones ejecutadas", "success");
|
||||||
|
loadMigrations();
|
||||||
|
} else {
|
||||||
|
logBox.textContent = "Error: " + (res?.data?.error || "Unknown");
|
||||||
|
toast("Error en migraciones", "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Actions ───────────────────────────────────────────────────────────────
|
||||||
|
async function toggleTenant(id, active) {
|
||||||
|
const res = await api(`/api/tenants/${id}/toggle`, {
|
||||||
|
method: "POST",
|
||||||
|
body: { active }
|
||||||
|
});
|
||||||
|
if (res && res.status === 200) {
|
||||||
|
toast(active ? "Tenant activado" : "Tenant desactivado", "success");
|
||||||
|
loadTenants();
|
||||||
|
loadDemos();
|
||||||
|
} else {
|
||||||
|
toast(res?.data?.error || "Error", "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetTenant(id) {
|
||||||
|
if (!confirm("¿Resetear TODOS los datos de negocio de este tenant? Se conservan empleados y configuración.")) return;
|
||||||
|
|
||||||
|
const res = await api(`/api/tenants/${id}/reset`, { method: "POST" });
|
||||||
|
if (res && res.status === 200) {
|
||||||
|
toast("Tenant reseteado", "success");
|
||||||
|
} else {
|
||||||
|
toast(res?.data?.error || "Error al resetear", "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDelete(id, name) {
|
||||||
|
openModal(
|
||||||
|
"Eliminar Tenant",
|
||||||
|
`¿Eliminar permanentemente <strong>${escapeHtml(name)}</strong>? Esta acción no se puede deshacer. Se borrará la base de datos completa.`,
|
||||||
|
async () => {
|
||||||
|
const res = await api(`/api/tenants/${id}`, { method: "DELETE" });
|
||||||
|
if (res && res.status === 200) {
|
||||||
|
toast("Tenant eliminado", "success");
|
||||||
|
loadTenants();
|
||||||
|
loadDemos();
|
||||||
|
} else {
|
||||||
|
toast(res?.data?.error || "Error al eliminar", "error");
|
||||||
|
}
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Modal ─────────────────────────────────────────────────────────────────
|
||||||
|
function openModal(title, body, onConfirm) {
|
||||||
|
document.getElementById("modal-title").textContent = title;
|
||||||
|
document.getElementById("modal-body").innerHTML = body;
|
||||||
|
const btn = document.getElementById("modal-confirm-btn");
|
||||||
|
btn.onclick = onConfirm;
|
||||||
|
document.getElementById("modal").style.display = "flex";
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
document.getElementById("modal").style.display = "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Toast ─────────────────────────────────────────────────────────────────
|
||||||
|
function toast(message, type = "info") {
|
||||||
|
const container = document.getElementById("toast-container");
|
||||||
|
const el = document.createElement("div");
|
||||||
|
el.className = `toast ${type}`;
|
||||||
|
el.innerHTML = `<i class="fas fa-${type === "success" ? "check-circle" : type === "error" ? "exclamation-circle" : "info-circle"}"></i> ${escapeHtml(message)}`;
|
||||||
|
container.appendChild(el);
|
||||||
|
setTimeout(() => {
|
||||||
|
el.style.opacity = "0";
|
||||||
|
el.style.transform = "translateX(100%)";
|
||||||
|
setTimeout(() => el.remove(), 300);
|
||||||
|
}, 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Utilities ─────────────────────────────────────────────────────────────
|
||||||
|
function escapeHtml(text) {
|
||||||
|
if (!text) return "";
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tag(text, type) {
|
||||||
|
return `<span class="tag tag-${type}">${escapeHtml(text)}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso) {
|
||||||
|
if (!iso) return "-";
|
||||||
|
const d = new Date(iso);
|
||||||
|
return d.toLocaleDateString("es-MX");
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyText(text) {
|
||||||
|
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,
|
||||||
|
catalog: document.getElementById("mod-catalog").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 ──────────────────────────────────────────────────────────────────
|
||||||
|
document.addEventListener("DOMContentLoaded", initAuth);
|
||||||
40
manager/systemd/nexus-manager.service
Normal file
40
manager/systemd/nexus-manager.service
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Nexus Instance Manager (Control Central)
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=root
|
||||||
|
WorkingDirectory=/home/Autopartes/manager
|
||||||
|
ExecStart=/usr/local/bin/gunicorn -w 2 --threads 4 -b 0.0.0.0:5003 "app:create_app()"
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
# ─── Local Paths ───────────────────────────────────────────────────────────
|
||||||
|
Environment=PYTHONUNBUFFERED=1
|
||||||
|
Environment=PYTHONPATH=/home/Autopartes/manager:/home/Autopartes/pos
|
||||||
|
Environment=POS_DIR=/home/Autopartes/pos
|
||||||
|
|
||||||
|
# ─── Database (UPDATE FOR REMOTE VM) ───────────────────────────────────────
|
||||||
|
# If manager runs on a separate VM, change localhost to the IP of the
|
||||||
|
# PostgreSQL server (e.g. 192.168.10.91).
|
||||||
|
Environment=MASTER_DB_URL=postgresql://postgres@localhost/nexus_autoparts
|
||||||
|
Environment=TENANT_DB_URL_TEMPLATE=postgresql://postgres@localhost/{db_name}
|
||||||
|
|
||||||
|
# ─── Remote Nexus Server IP ────────────────────────────────────────────────
|
||||||
|
# Set to the IP/hostname of the server running POS/Dashboard/Quart/Redis.
|
||||||
|
# Leave as 127.0.0.1 if manager runs on the same server.
|
||||||
|
Environment=NEXUS_SERVER_HOST=127.0.0.1
|
||||||
|
|
||||||
|
# ─── Security (CHANGE THIS) ────────────────────────────────────────────────
|
||||||
|
Environment=MANAGER_JWT_SECRET=change-me-to-a-random-64-char-hex-string
|
||||||
|
Environment=INTERNAL_API_KEY=c58db62766712e618a881dbe8de580960812e57a069ef92c9dd00e7e69158cb2
|
||||||
|
|
||||||
|
# ─── POS Internal API (for WhatsApp bridge orchestration) ──────────────────
|
||||||
|
Environment=POS_INTERNAL_URL=http://192.168.10.91:5001
|
||||||
|
|
||||||
|
# ─── Redis (optional, health check only) ───────────────────────────────────
|
||||||
|
Environment=REDIS_URL=redis://127.0.0.1:6379/0
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
381
manager/templates/index.html
Normal file
381
manager/templates/index.html
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Nexus Instance Manager</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/manager.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Login Screen -->
|
||||||
|
<div id="login-screen" class="login-screen">
|
||||||
|
<div class="login-card">
|
||||||
|
<div class="login-logo">
|
||||||
|
<i class="fas fa-cube"></i>
|
||||||
|
<h1>Nexus Manager</h1>
|
||||||
|
<p>Control Central de Instancias</p>
|
||||||
|
</div>
|
||||||
|
<form id="login-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Email</label>
|
||||||
|
<input type="email" id="login-email" required placeholder="admin@nexus.local">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Contraseña</label>
|
||||||
|
<input type="password" id="login-password" required placeholder="••••••••">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary btn-block">
|
||||||
|
<i class="fas fa-sign-in-alt"></i> Ingresar
|
||||||
|
</button>
|
||||||
|
<div id="login-error" class="alert alert-error" style="display:none;"></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main App -->
|
||||||
|
<div id="app" class="app" style="display:none;">
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="sidebar-brand">
|
||||||
|
<i class="fas fa-cube"></i>
|
||||||
|
<span>Nexus Manager</span>
|
||||||
|
</div>
|
||||||
|
<nav class="sidebar-nav">
|
||||||
|
<a href="#/dashboard" class="nav-item active" data-page="dashboard">
|
||||||
|
<i class="fas fa-chart-line"></i>
|
||||||
|
<span>Dashboard</span>
|
||||||
|
</a>
|
||||||
|
<a href="#/demos" class="nav-item" data-page="demos">
|
||||||
|
<i class="fas fa-rocket"></i>
|
||||||
|
<span>Crear Demos</span>
|
||||||
|
</a>
|
||||||
|
<a href="#/tenants" class="nav-item" data-page="tenants">
|
||||||
|
<i class="fas fa-building"></i>
|
||||||
|
<span>Tenants</span>
|
||||||
|
<span class="badge" id="tenant-count">0</span>
|
||||||
|
</a>
|
||||||
|
<a href="#/health" class="nav-item" data-page="health">
|
||||||
|
<i class="fas fa-heartbeat"></i>
|
||||||
|
<span>Salud</span>
|
||||||
|
</a>
|
||||||
|
<a href="#/migrations" class="nav-item" data-page="migrations">
|
||||||
|
<i class="fas fa-database"></i>
|
||||||
|
<span>Migraciones</span>
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<div class="user-info">
|
||||||
|
<span id="user-email">admin</span>
|
||||||
|
<button onclick="logout()" class="btn-icon" title="Cerrar sesión">
|
||||||
|
<i class="fas fa-sign-out-alt"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<main class="main">
|
||||||
|
<header class="topbar">
|
||||||
|
<h2 id="page-title">Dashboard</h2>
|
||||||
|
<div class="topbar-actions">
|
||||||
|
<span class="status-indicator" id="system-status">
|
||||||
|
<i class="fas fa-circle"></i> Online
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<!-- Dashboard Page -->
|
||||||
|
<section id="page-dashboard" class="page">
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon bg-blue"><i class="fas fa-building"></i></div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<h3 id="stat-total">0</h3>
|
||||||
|
<p>Total Tenants</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon bg-green"><i class="fas fa-check-circle"></i></div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<h3 id="stat-active">0</h3>
|
||||||
|
<p>Activos</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon bg-purple"><i class="fas fa-rocket"></i></div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<h3 id="stat-demos">0</h3>
|
||||||
|
<p>Demos</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon bg-orange"><i class="fas fa-clock"></i></div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<h3 id="stat-expiring">0</h3>
|
||||||
|
<p>Expiran pronto</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid-2">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3><i class="fas fa-server"></i> Estado del Sistema</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body" id="system-health-summary">
|
||||||
|
<div class="loading">Cargando...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3><i class="fas fa-building"></i> Demos Recientes</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<table class="table compact">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Nombre</th><th>Subdominio</th><th>Expira</th><th>Estado</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="recent-demos-table">
|
||||||
|
<tr><td colspan="4" class="text-muted">Cargando...</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Demos Page -->
|
||||||
|
<section id="page-demos" class="page" style="display:none;">
|
||||||
|
<div class="grid-2">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3><i class="fas fa-plus-circle"></i> Nueva Demo</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form id="demo-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Nombre del negocio *</label>
|
||||||
|
<input type="text" id="demo-name" required placeholder="Refaccionaria López">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Email de contacto</label>
|
||||||
|
<input type="email" id="demo-email" placeholder="cliente@email.com">
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Días de vigencia</label>
|
||||||
|
<input type="number" id="demo-days" value="14" min="1" max="90">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>PIN del owner</label>
|
||||||
|
<input type="text" id="demo-pin" value="0000" maxlength="10">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Subdominio (opcional)</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="text" id="demo-subdomain" placeholder="refaccionaria-lopez">
|
||||||
|
<span class="input-suffix">.nexusautoparts.com.mx</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="fas fa-rocket"></i> Crear Demo
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<div id="demo-result" class="result-box" style="display:none;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3><i class="fas fa-list"></i> Demos Activas</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Negocio</th><th>URL</th><th>Días rest.</th><th>Acciones</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="demos-table">
|
||||||
|
<tr><td colspan="4" class="text-muted">Cargando...</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Tenants Page -->
|
||||||
|
<section id="page-tenants" class="page" style="display:none;">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3><i class="fas fa-building"></i> Todos los Tenants</h3>
|
||||||
|
<div class="card-actions">
|
||||||
|
<input type="text" id="tenant-search" placeholder="Buscar..." class="input-sm">
|
||||||
|
<button class="btn btn-sm btn-secondary" onclick="loadTenants(true)">
|
||||||
|
<i class="fas fa-sync"></i> Refrescar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Nombre</th>
|
||||||
|
<th>Subdominio</th>
|
||||||
|
<th>Plan</th>
|
||||||
|
<th>Versión</th>
|
||||||
|
<th>Estado</th>
|
||||||
|
<th>Creado</th>
|
||||||
|
<th>Acciones</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="tenants-table">
|
||||||
|
<tr><td colspan="8" class="text-muted">Cargando...</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Health Page -->
|
||||||
|
<section id="page-health" class="page" style="display:none;">
|
||||||
|
<div class="grid-3">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><h3><i class="fas fa-database"></i> PostgreSQL</h3></div>
|
||||||
|
<div class="card-body" id="health-postgresql"><div class="loading">...</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><h3><i class="fas fa-bolt"></i> Redis</h3></div>
|
||||||
|
<div class="card-body" id="health-redis"><div class="loading">...</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><h3><i class="fas fa-hdd"></i> Disco</h3></div>
|
||||||
|
<div class="card-body" id="health-disk"><div class="loading">...</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid-2">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><h3><i class="fas fa-memory"></i> Memoria</h3></div>
|
||||||
|
<div class="card-body" id="health-memory"><div class="loading">...</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><h3><i class="fas fa-cogs"></i> Servicios Systemd</h3></div>
|
||||||
|
<div class="card-body" id="health-services"><div class="loading">...</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><h3><i class="fas fa-network-wired"></i> Servicios HTTP</h3></div>
|
||||||
|
<div class="card-body" id="health-http"><div class="loading">...</div></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Migrations Page -->
|
||||||
|
<section id="page-migrations" class="page" style="display:none;">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3><i class="fas fa-database"></i> Migraciones de Schema</h3>
|
||||||
|
<div class="card-actions">
|
||||||
|
<button class="btn btn-primary" onclick="runAllMigrations()">
|
||||||
|
<i class="fas fa-play"></i> Ejecutar todas pendientes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="migration-log" class="log-box" style="display:none;"></div>
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Tenant</th><th>DB</th><th>Versión actual</th><th>Estado</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="migrations-table">
|
||||||
|
<tr><td colspan="4" class="text-muted">Cargando...</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal -->
|
||||||
|
<div id="modal" class="modal" style="display:none;">
|
||||||
|
<div class="modal-overlay" onclick="closeModal()"></div>
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 id="modal-title">Confirmar</h3>
|
||||||
|
<button class="btn-icon" onclick="closeModal()"><i class="fas fa-times"></i></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" id="modal-body"></div>
|
||||||
|
<div class="modal-footer" id="modal-footer">
|
||||||
|
<button class="btn btn-secondary" onclick="closeModal()">Cancelar</button>
|
||||||
|
<button class="btn btn-danger" id="modal-confirm-btn">Confirmar</button>
|
||||||
|
</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;border-bottom:1px solid var(--border);">
|
||||||
|
<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 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);">Catálogo</div>
|
||||||
|
<div style="font-size:12px;color:var(--text-muted);">Mostrar menú de Catálogo de productos</div>
|
||||||
|
</div>
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" id="mod-catalog">
|
||||||
|
<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 -->
|
||||||
|
<div id="toast-container"></div>
|
||||||
|
|
||||||
|
<script src="/static/js/manager.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
4
manager/wsgi.py
Normal file
4
manager/wsgi.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
"""WSGI entry point for Nexus Instance Manager."""
|
||||||
|
from app import create_app
|
||||||
|
|
||||||
|
application = create_app()
|
||||||
@@ -19,5 +19,8 @@
|
|||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.59.1"
|
"@playwright/test": "^1.59.1"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "^1.60.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
20
pos/Dockerfile.whatsapp-bridge
Normal file
20
pos/Dockerfile.whatsapp-bridge
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install git and build tools (needed for some npm deps)
|
||||||
|
RUN apk add --no-cache git python3 make g++
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
COPY whatsapp-bridge-package.json package.json
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
# Copy bridge server
|
||||||
|
COPY whatsapp-bridge-server.js .
|
||||||
|
|
||||||
|
# Create auth directory
|
||||||
|
RUN mkdir -p /app/auth
|
||||||
|
|
||||||
|
EXPOSE 21465
|
||||||
|
|
||||||
|
CMD ["node", "whatsapp-bridge-server.js"]
|
||||||
28
pos/app.py
28
pos/app.py
@@ -59,6 +59,12 @@ def create_app():
|
|||||||
from blueprints.marketplace_bp import marketplace_bp
|
from blueprints.marketplace_bp import marketplace_bp
|
||||||
app.register_blueprint(marketplace_bp)
|
app.register_blueprint(marketplace_bp)
|
||||||
|
|
||||||
|
from blueprints.marketplace_external_bp import marketplace_ext_bp
|
||||||
|
app.register_blueprint(marketplace_ext_bp)
|
||||||
|
|
||||||
|
from blueprints.dropshipping_bp import dropship_bp
|
||||||
|
app.register_blueprint(dropship_bp)
|
||||||
|
|
||||||
from blueprints.peer_bp import peer_bp
|
from blueprints.peer_bp import peer_bp
|
||||||
app.register_blueprint(peer_bp)
|
app.register_blueprint(peer_bp)
|
||||||
|
|
||||||
@@ -107,6 +113,12 @@ def create_app():
|
|||||||
from blueprints.supplier_portal_bp import supplier_portal_bp
|
from blueprints.supplier_portal_bp import supplier_portal_bp
|
||||||
app.register_blueprint(supplier_portal_bp)
|
app.register_blueprint(supplier_portal_bp)
|
||||||
|
|
||||||
|
from blueprints.supplier_catalog_bp import supplier_catalog_bp
|
||||||
|
app.register_blueprint(supplier_catalog_bp)
|
||||||
|
|
||||||
|
from blueprints.internal_bp import internal_bp
|
||||||
|
app.register_blueprint(internal_bp)
|
||||||
|
|
||||||
# Health check
|
# Health check
|
||||||
@app.route('/pos/health')
|
@app.route('/pos/health')
|
||||||
def health():
|
def health():
|
||||||
@@ -125,6 +137,10 @@ def create_app():
|
|||||||
tenant_name=getattr(g, 'tenant_name', None),
|
tenant_name=getattr(g, 'tenant_name', None),
|
||||||
tenant_subdomain=getattr(g, 'tenant_subdomain', None))
|
tenant_subdomain=getattr(g, 'tenant_subdomain', None))
|
||||||
|
|
||||||
|
@app.route('/pos/supplier-catalog')
|
||||||
|
def supplier_catalog_page():
|
||||||
|
return render_template('supplier_catalog.html')
|
||||||
|
|
||||||
@app.route('/pos/catalog')
|
@app.route('/pos/catalog')
|
||||||
def pos_catalog():
|
def pos_catalog():
|
||||||
return render_template('catalog.html')
|
return render_template('catalog.html')
|
||||||
@@ -177,6 +193,18 @@ def create_app():
|
|||||||
def pos_marketplace():
|
def pos_marketplace():
|
||||||
return render_template('marketplace.html')
|
return render_template('marketplace.html')
|
||||||
|
|
||||||
|
@app.route('/pos/marketplace-external')
|
||||||
|
def pos_marketplace_external():
|
||||||
|
return render_template('marketplace_external.html')
|
||||||
|
|
||||||
|
@app.route('/pos/marketplace-external/callback')
|
||||||
|
def pos_marketplace_external_callback():
|
||||||
|
return render_template('marketplace_external.html')
|
||||||
|
|
||||||
|
@app.route('/pos/historical-sales')
|
||||||
|
def pos_historical_sales():
|
||||||
|
return render_template('historical_sales.html')
|
||||||
|
|
||||||
@app.route('/pos/static/<path:filename>')
|
@app.route('/pos/static/<path:filename>')
|
||||||
def pos_static(filename):
|
def pos_static(filename):
|
||||||
return send_from_directory('static', filename)
|
return send_from_directory('static', filename)
|
||||||
|
|||||||
@@ -741,3 +741,45 @@ def close_period():
|
|||||||
cur.close()
|
cur.close()
|
||||||
conn.close()
|
conn.close()
|
||||||
return jsonify({'error': str(e)}), 500
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@accounting_bp.route('/stats', methods=['GET'])
|
||||||
|
@require_auth('accounting.read')
|
||||||
|
def api_accounting_stats():
|
||||||
|
"""Return counts for tab badges: receivables (asset accounts with balance) and payables (liability accounts with balance)."""
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
# Count asset accounts with positive balance (cuentas por cobrar)
|
||||||
|
cur.execute("""
|
||||||
|
SELECT COUNT(*) FROM (
|
||||||
|
SELECT a.id
|
||||||
|
FROM accounts a
|
||||||
|
LEFT JOIN journal_entry_lines l ON l.account_id = a.id
|
||||||
|
WHERE a.type = 'activo' AND a.is_active = true
|
||||||
|
GROUP BY a.id
|
||||||
|
HAVING COALESCE(SUM(l.debit), 0) - COALESCE(SUM(l.credit), 0) > 0
|
||||||
|
) x
|
||||||
|
""")
|
||||||
|
cxc = cur.fetchone()[0] or 0
|
||||||
|
|
||||||
|
# Count liability accounts with positive balance (cuentas por pagar)
|
||||||
|
cur.execute("""
|
||||||
|
SELECT COUNT(*) FROM (
|
||||||
|
SELECT a.id
|
||||||
|
FROM accounts a
|
||||||
|
LEFT JOIN journal_entry_lines l ON l.account_id = a.id
|
||||||
|
WHERE a.type = 'pasivo' AND a.is_active = true
|
||||||
|
GROUP BY a.id
|
||||||
|
HAVING COALESCE(SUM(l.credit), 0) - COALESCE(SUM(l.debit), 0) > 0
|
||||||
|
) x
|
||||||
|
""")
|
||||||
|
cxp = cur.fetchone()[0] or 0
|
||||||
|
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'cuentas_cobrar': cxc,
|
||||||
|
'cuentas_pagar': cxp,
|
||||||
|
})
|
||||||
|
|||||||
@@ -35,6 +35,25 @@ def _oem_blocked():
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_allowed_brands(tenant_conn):
|
||||||
|
"""Read allowed part brands from tenant_config. Returns list or None."""
|
||||||
|
import json
|
||||||
|
cur = tenant_conn.cursor()
|
||||||
|
try:
|
||||||
|
cur.execute("SELECT value FROM tenant_config WHERE key = 'allowed_part_brands'")
|
||||||
|
row = cur.fetchone()
|
||||||
|
if row and row[0]:
|
||||||
|
try:
|
||||||
|
brands = json.loads(row[0])
|
||||||
|
if isinstance(brands, list) and brands:
|
||||||
|
return brands
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _with_conns(fn):
|
def _with_conns(fn):
|
||||||
"""Helper: open master + tenant connections, call fn, close both.
|
"""Helper: open master + tenant connections, call fn, close both.
|
||||||
fn receives (master_conn, tenant_conn, branch_id).
|
fn receives (master_conn, tenant_conn, branch_id).
|
||||||
@@ -71,6 +90,34 @@ def _master_only(fn):
|
|||||||
except: pass
|
except: pass
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_parts_by_allowed_brands(master_conn, parts_data, allowed_brands):
|
||||||
|
"""Filter a list of part dicts to only include those with aftermarket equivalents
|
||||||
|
from allowed brands. parts_data items must have 'id_part' or 'id' key."""
|
||||||
|
if not allowed_brands or not parts_data:
|
||||||
|
return parts_data
|
||||||
|
part_ids = []
|
||||||
|
for p in parts_data:
|
||||||
|
pid = p.get('id_part') or p.get('id')
|
||||||
|
# Skip local inventory IDs (strings like 'inv:3') — aftermarket filter
|
||||||
|
# only applies to catalog parts with integer OEM part IDs.
|
||||||
|
if pid is not None and isinstance(pid, int):
|
||||||
|
part_ids.append(pid)
|
||||||
|
if not part_ids:
|
||||||
|
return parts_data
|
||||||
|
cur = master_conn.cursor()
|
||||||
|
try:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT DISTINCT ap.oem_part_id
|
||||||
|
FROM aftermarket_parts ap
|
||||||
|
JOIN manufacturers m ON m.id_manufacture = ap.manufacturer_id
|
||||||
|
WHERE ap.oem_part_id = ANY(%s) AND UPPER(m.name_manufacture) = ANY(%s)
|
||||||
|
""", (part_ids, allowed_brands))
|
||||||
|
allowed_ids = {r[0] for r in cur.fetchall()}
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
return [p for p in parts_data if (p.get('id_part') or p.get('id')) in allowed_ids]
|
||||||
|
|
||||||
|
|
||||||
# ─── Hierarchy navigation (master DB only) ───
|
# ─── Hierarchy navigation (master DB only) ───
|
||||||
|
|
||||||
@catalog_bp.route('/brands', methods=['GET'])
|
@catalog_bp.route('/brands', methods=['GET'])
|
||||||
@@ -79,10 +126,11 @@ def brands():
|
|||||||
from services.catalog_modes import normalize_mode
|
from services.catalog_modes import normalize_mode
|
||||||
year_id = request.args.get('year_id', type=int)
|
year_id = request.args.get('year_id', type=int)
|
||||||
mode = normalize_mode(request.args.get('mode'))
|
mode = normalize_mode(request.args.get('mode'))
|
||||||
def _do(master):
|
def _do(master, tenant, branch_id):
|
||||||
data = catalog_service.get_brands(master, year_id=year_id, mode=mode)
|
mye_ids = catalog_service._get_mye_ids_with_parts(tenant, tenant_id=g.tenant_id, master_conn=master) if tenant else None
|
||||||
|
data = catalog_service.get_brands(master, year_id=year_id, mode=mode, mye_ids=mye_ids)
|
||||||
return jsonify({'data': data, 'mode': mode})
|
return jsonify({'data': data, 'mode': mode})
|
||||||
return _master_only(_do)
|
return _with_conns(_do)
|
||||||
|
|
||||||
|
|
||||||
@catalog_bp.route('/models', methods=['GET'])
|
@catalog_bp.route('/models', methods=['GET'])
|
||||||
@@ -92,10 +140,11 @@ def models():
|
|||||||
year_id = request.args.get('year_id', type=int)
|
year_id = request.args.get('year_id', type=int)
|
||||||
if not brand_id:
|
if not brand_id:
|
||||||
return jsonify({'error': 'brand_id required'}), 400
|
return jsonify({'error': 'brand_id required'}), 400
|
||||||
def _do(master):
|
def _do(master, tenant, branch_id):
|
||||||
data = catalog_service.get_models(master, brand_id, year_id=year_id)
|
mye_ids = catalog_service._get_mye_ids_with_parts(tenant, tenant_id=g.tenant_id, master_conn=master) if tenant else None
|
||||||
|
data = catalog_service.get_models(master, brand_id, year_id=year_id, mye_ids=mye_ids)
|
||||||
return jsonify({'data': data})
|
return jsonify({'data': data})
|
||||||
return _master_only(_do)
|
return _with_conns(_do)
|
||||||
|
|
||||||
|
|
||||||
@catalog_bp.route('/years', methods=['GET'])
|
@catalog_bp.route('/years', methods=['GET'])
|
||||||
@@ -104,10 +153,11 @@ def years():
|
|||||||
model_id = request.args.get('model_id', type=int)
|
model_id = request.args.get('model_id', type=int)
|
||||||
if not model_id:
|
if not model_id:
|
||||||
return jsonify({'error': 'model_id required'}), 400
|
return jsonify({'error': 'model_id required'}), 400
|
||||||
def _do(master):
|
def _do(master, tenant, branch_id):
|
||||||
data = catalog_service.get_years(master, model_id)
|
mye_ids = catalog_service._get_mye_ids_with_parts(tenant, tenant_id=g.tenant_id, master_conn=master) if tenant else None
|
||||||
|
data = catalog_service.get_years(master, model_id, mye_ids=mye_ids)
|
||||||
return jsonify({'data': data})
|
return jsonify({'data': data})
|
||||||
return _master_only(_do)
|
return _with_conns(_do)
|
||||||
|
|
||||||
|
|
||||||
@catalog_bp.route('/years-all', methods=['GET'])
|
@catalog_bp.route('/years-all', methods=['GET'])
|
||||||
@@ -130,10 +180,11 @@ def engines():
|
|||||||
year_id = request.args.get('year_id', type=int)
|
year_id = request.args.get('year_id', type=int)
|
||||||
if not model_id or not year_id:
|
if not model_id or not year_id:
|
||||||
return jsonify({'error': 'model_id and year_id required'}), 400
|
return jsonify({'error': 'model_id and year_id required'}), 400
|
||||||
def _do(master):
|
def _do(master, tenant, branch_id):
|
||||||
data = catalog_service.get_engines(master, model_id, year_id)
|
mye_ids = catalog_service._get_mye_ids_with_parts(tenant, tenant_id=g.tenant_id, master_conn=master) if tenant else None
|
||||||
|
data = catalog_service.get_engines(master, model_id, year_id, mye_ids=mye_ids)
|
||||||
return jsonify({'data': data})
|
return jsonify({'data': data})
|
||||||
return _master_only(_do)
|
return _with_conns(_do)
|
||||||
|
|
||||||
|
|
||||||
@catalog_bp.route('/categories', methods=['GET'])
|
@catalog_bp.route('/categories', methods=['GET'])
|
||||||
@@ -150,13 +201,14 @@ def categories():
|
|||||||
mode = normalize_mode(request.args.get('mode'))
|
mode = normalize_mode(request.args.get('mode'))
|
||||||
if not mye_id:
|
if not mye_id:
|
||||||
return jsonify({'error': 'mye_id required'}), 400
|
return jsonify({'error': 'mye_id required'}), 400
|
||||||
def _do(master):
|
def _do(master, tenant, branch_id):
|
||||||
|
allowed_brands = _get_allowed_brands(tenant) if tenant else None
|
||||||
if mode == 'local':
|
if mode == 'local':
|
||||||
data = catalog_service.get_nexpart_groups_for_vehicle(master, mye_id)
|
data = catalog_service.get_nexpart_groups_for_vehicle(master, mye_id, tenant)
|
||||||
else:
|
else:
|
||||||
data = catalog_service.get_categories(master, mye_id)
|
data = catalog_service.get_categories(master, mye_id, allowed_brands)
|
||||||
return jsonify({'data': data, 'mode': mode})
|
return jsonify({'data': data, 'mode': mode, 'allowed_brands': allowed_brands or []})
|
||||||
return _master_only(_do)
|
return _with_conns(_do)
|
||||||
|
|
||||||
|
|
||||||
@catalog_bp.route('/groups', methods=['GET'])
|
@catalog_bp.route('/groups', methods=['GET'])
|
||||||
@@ -174,17 +226,17 @@ def groups():
|
|||||||
mode = normalize_mode(request.args.get('mode'))
|
mode = normalize_mode(request.args.get('mode'))
|
||||||
if not mye_id:
|
if not mye_id:
|
||||||
return jsonify({'error': 'mye_id required'}), 400
|
return jsonify({'error': 'mye_id required'}), 400
|
||||||
def _do(master):
|
def _do(master, tenant, branch_id):
|
||||||
if mode == 'local':
|
if mode == 'local':
|
||||||
if not category_slug:
|
if not category_slug:
|
||||||
return jsonify({'error': 'category_slug required for local mode'}), 400
|
return jsonify({'error': 'category_slug required for local mode'}), 400
|
||||||
data = catalog_service.get_nexpart_subgroups_for_vehicle(master, mye_id, category_slug)
|
data = catalog_service.get_nexpart_subgroups_for_vehicle(master, mye_id, category_slug, tenant)
|
||||||
else:
|
else:
|
||||||
if not category_id:
|
if not category_id:
|
||||||
return jsonify({'error': 'category_id required for oem mode'}), 400
|
return jsonify({'error': 'category_id required for oem mode'}), 400
|
||||||
data = catalog_service.get_groups(master, mye_id, category_id)
|
data = catalog_service.get_groups(master, mye_id, category_id)
|
||||||
return jsonify({'data': data, 'mode': mode})
|
return jsonify({'data': data, 'mode': mode})
|
||||||
return _master_only(_do)
|
return _with_conns(_do)
|
||||||
|
|
||||||
|
|
||||||
# ─── Parts with stock enrichment (master + tenant) ───
|
# ─── Parts with stock enrichment (master + tenant) ───
|
||||||
@@ -205,19 +257,19 @@ def part_types():
|
|||||||
mode = normalize_mode(request.args.get('mode'))
|
mode = normalize_mode(request.args.get('mode'))
|
||||||
if not mye_id:
|
if not mye_id:
|
||||||
return jsonify({'error': 'mye_id required'}), 400
|
return jsonify({'error': 'mye_id required'}), 400
|
||||||
def _do(master):
|
def _do(master, tenant, branch_id):
|
||||||
if mode == 'local':
|
if mode == 'local':
|
||||||
if not group_slug or not subgroup_slug:
|
if not group_slug or not subgroup_slug:
|
||||||
return jsonify({'error': 'group_slug and subgroup_slug required for local mode'}), 400
|
return jsonify({'error': 'group_slug and subgroup_slug required for local mode'}), 400
|
||||||
data = catalog_service.get_nexpart_part_types_for_vehicle(
|
data = catalog_service.get_nexpart_part_types_for_vehicle(
|
||||||
master, mye_id, group_slug, subgroup_slug
|
master, mye_id, group_slug, subgroup_slug, tenant
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
if not group_id:
|
if not group_id:
|
||||||
return jsonify({'error': 'group_id required for oem mode'}), 400
|
return jsonify({'error': 'group_id required for oem mode'}), 400
|
||||||
data = catalog_service.get_part_types(master, mye_id, group_id)
|
data = catalog_service.get_part_types(master, mye_id, group_id)
|
||||||
return jsonify({'data': data, 'mode': mode})
|
return jsonify({'data': data, 'mode': mode})
|
||||||
return _master_only(_do)
|
return _with_conns(_do)
|
||||||
|
|
||||||
|
|
||||||
@catalog_bp.route('/shop-supplies/groups', methods=['GET'])
|
@catalog_bp.route('/shop-supplies/groups', methods=['GET'])
|
||||||
@@ -261,8 +313,8 @@ def shop_supplies_parts():
|
|||||||
group_slug = request.args.get('group_slug')
|
group_slug = request.args.get('group_slug')
|
||||||
subgroup_slug = request.args.get('subgroup_slug')
|
subgroup_slug = request.args.get('subgroup_slug')
|
||||||
part_type_slug = request.args.get('part_type_slug')
|
part_type_slug = request.args.get('part_type_slug')
|
||||||
page = request.args.get('page', 1, type=int)
|
page = max(1, request.args.get('page', 1, type=int) or 1)
|
||||||
per_page = request.args.get('per_page', 30, type=int)
|
per_page = max(1, min(request.args.get('per_page', 30, type=int) or 30, 100))
|
||||||
if not group_slug or not subgroup_slug or not part_type_slug:
|
if not group_slug or not subgroup_slug or not part_type_slug:
|
||||||
return jsonify({'error': 'group_slug, subgroup_slug, part_type_slug required'}), 400
|
return jsonify({'error': 'group_slug, subgroup_slug, part_type_slug required'}), 400
|
||||||
def _do(master, tenant, branch_id):
|
def _do(master, tenant, branch_id):
|
||||||
@@ -298,8 +350,8 @@ def parts():
|
|||||||
nexpart_subgroup = request.args.get('nexpart_subgroup')
|
nexpart_subgroup = request.args.get('nexpart_subgroup')
|
||||||
nexpart_part_type = request.args.get('nexpart_part_type')
|
nexpart_part_type = request.args.get('nexpart_part_type')
|
||||||
|
|
||||||
page = request.args.get('page', 1, type=int)
|
page = max(1, request.args.get('page', 1, type=int) or 1)
|
||||||
per_page = request.args.get('per_page', 30, type=int)
|
per_page = max(1, min(request.args.get('per_page', 30, type=int) or 30, 100))
|
||||||
mode = normalize_mode(request.args.get('mode'))
|
mode = normalize_mode(request.args.get('mode'))
|
||||||
|
|
||||||
if not mye_id:
|
if not mye_id:
|
||||||
@@ -317,19 +369,34 @@ def parts():
|
|||||||
return blocked
|
return blocked
|
||||||
|
|
||||||
def _do(master, tenant, branch_id):
|
def _do(master, tenant, branch_id):
|
||||||
|
allowed_brands = _get_allowed_brands(tenant) if tenant else None
|
||||||
|
# For local mode with allowed_brands, fetch everything first so filtering
|
||||||
|
# happens before pagination. OEM mode keeps post-filter for now.
|
||||||
|
fetch_all_for_filter = bool(allowed_brands) and (mode == 'local' or use_nexpart_nav)
|
||||||
|
_page = 1 if fetch_all_for_filter else page
|
||||||
|
_per_page = 9999 if fetch_all_for_filter else per_page
|
||||||
|
|
||||||
if use_nexpart_nav:
|
if use_nexpart_nav:
|
||||||
result = catalog_service.get_parts_for_nexpart_triple(
|
result = catalog_service.get_parts_for_nexpart_triple(
|
||||||
master, mye_id, nexpart_group, nexpart_subgroup, nexpart_part_type,
|
master, mye_id, nexpart_group, nexpart_subgroup, nexpart_part_type,
|
||||||
tenant, branch_id, page, per_page,
|
tenant, branch_id, _page, _per_page, tenant_id=g.tenant_id,
|
||||||
)
|
)
|
||||||
elif mode == 'local':
|
elif mode == 'local':
|
||||||
result = catalog_service.get_parts_local(
|
result = catalog_service.get_parts_local(
|
||||||
master, mye_id, group_id, tenant, branch_id, page, per_page, part_type=part_type,
|
master, mye_id, group_id, tenant, branch_id, _page, _per_page, part_type=part_type,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
result = catalog_service.get_parts(
|
result = catalog_service.get_parts(
|
||||||
master, mye_id, group_id, tenant, branch_id, page, per_page, part_type=part_type,
|
master, mye_id, group_id, tenant, branch_id, page, per_page, part_type=part_type,
|
||||||
)
|
)
|
||||||
|
if allowed_brands:
|
||||||
|
result['data'] = _filter_parts_by_allowed_brands(master, result.get('data', []), allowed_brands)
|
||||||
|
if fetch_all_for_filter:
|
||||||
|
total = len(result['data'])
|
||||||
|
offset = (page - 1) * per_page
|
||||||
|
result['data'] = result['data'][offset:offset + per_page]
|
||||||
|
result['pagination'] = catalog_service._pagination(page, per_page, total)
|
||||||
|
result['allowed_brands'] = allowed_brands or []
|
||||||
return jsonify(result)
|
return jsonify(result)
|
||||||
return _with_conns(_do)
|
return _with_conns(_do)
|
||||||
|
|
||||||
@@ -358,8 +425,11 @@ def search():
|
|||||||
limit = request.args.get('limit', 50, type=int)
|
limit = request.args.get('limit', 50, type=int)
|
||||||
mye_id = request.args.get('mye_id', type=int)
|
mye_id = request.args.get('mye_id', type=int)
|
||||||
def _do(master, tenant, branch_id):
|
def _do(master, tenant, branch_id):
|
||||||
data = catalog_service.smart_search(master, q, tenant, branch_id, limit, mye_id)
|
allowed_brands = _get_allowed_brands(tenant) if tenant else None
|
||||||
return jsonify({'data': data})
|
data = catalog_service.smart_search(master, q, tenant, branch_id, limit, mye_id, tenant_id=g.tenant_id)
|
||||||
|
if allowed_brands:
|
||||||
|
data = _filter_parts_by_allowed_brands(master, data, allowed_brands)
|
||||||
|
return jsonify({'data': data, 'allowed_brands': allowed_brands or []})
|
||||||
return _with_conns(_do)
|
return _with_conns(_do)
|
||||||
|
|
||||||
|
|
||||||
@@ -635,10 +705,20 @@ def brand_categories():
|
|||||||
if not brand:
|
if not brand:
|
||||||
return jsonify({'error': 'brand parameter required'}), 400
|
return jsonify({'error': 'brand parameter required'}), 400
|
||||||
|
|
||||||
def _query(master):
|
def _query(master, tenant, branch_id):
|
||||||
cur = master.cursor()
|
cur = master.cursor()
|
||||||
try:
|
try:
|
||||||
cur.execute("""
|
allowed_brands = _get_allowed_brands(tenant) if tenant else None
|
||||||
|
brand_filter = ""
|
||||||
|
params = [brand]
|
||||||
|
if allowed_brands:
|
||||||
|
brand_filter = """AND EXISTS (
|
||||||
|
SELECT 1 FROM aftermarket_parts ap2
|
||||||
|
JOIN manufacturers m2 ON m2.id_manufacture = ap2.manufacturer_id
|
||||||
|
WHERE ap2.oem_part_id = p.id_part AND UPPER(m2.name_manufacture) = ANY(%s)
|
||||||
|
)"""
|
||||||
|
params.append(allowed_brands)
|
||||||
|
cur.execute(f"""
|
||||||
SELECT pc.id_part_category,
|
SELECT pc.id_part_category,
|
||||||
COALESCE(NULLIF(pc.name_es, ''), pc.name_part_category) as name,
|
COALESCE(NULLIF(pc.name_es, ''), pc.name_part_category) as name,
|
||||||
pc.slug,
|
pc.slug,
|
||||||
@@ -648,20 +728,22 @@ def brand_categories():
|
|||||||
JOIN part_groups pg ON pg.id_part_group = p.group_id
|
JOIN part_groups pg ON pg.id_part_group = p.group_id
|
||||||
JOIN part_categories pc ON pc.id_part_category = pg.category_id
|
JOIN part_categories pc ON pc.id_part_category = pg.category_id
|
||||||
WHERE pvp.name_brand = %s
|
WHERE pvp.name_brand = %s
|
||||||
|
{brand_filter}
|
||||||
GROUP BY pc.id_part_category, pc.name_part_category, pc.name_es, pc.slug
|
GROUP BY pc.id_part_category, pc.name_part_category, pc.name_es, pc.slug
|
||||||
ORDER BY part_count DESC
|
ORDER BY part_count DESC
|
||||||
""", (brand,))
|
""", params)
|
||||||
rows = cur.fetchall()
|
rows = cur.fetchall()
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'brand': brand,
|
'brand': brand,
|
||||||
'categories': [
|
'categories': [
|
||||||
{'id': r[0], 'name': r[1], 'slug': r[2], 'part_count': r[3]}
|
{'id': r[0], 'name': r[1], 'slug': r[2], 'part_count': r[3]}
|
||||||
for r in rows
|
for r in rows
|
||||||
]
|
],
|
||||||
|
'allowed_brands': allowed_brands or []
|
||||||
})
|
})
|
||||||
finally:
|
finally:
|
||||||
cur.close()
|
cur.close()
|
||||||
return _master_only(_query)
|
return _with_conns(_query)
|
||||||
|
|
||||||
|
|
||||||
@catalog_bp.route('/brand-parts', methods=['GET'])
|
@catalog_bp.route('/brand-parts', methods=['GET'])
|
||||||
@@ -680,21 +762,110 @@ def brand_parts():
|
|||||||
def _query(master, tenant, branch_id):
|
def _query(master, tenant, branch_id):
|
||||||
cur = master.cursor()
|
cur = master.cursor()
|
||||||
try:
|
try:
|
||||||
# Build dynamic filters
|
allowed_brands = _get_allowed_brands(tenant) if tenant else None
|
||||||
params = [brand]
|
|
||||||
cat_filter = ""
|
cat_filter = ""
|
||||||
search_filter = ""
|
search_filter = ""
|
||||||
|
params = [brand]
|
||||||
|
|
||||||
if category_id:
|
if category_id:
|
||||||
cat_filter = "AND pc.id_part_category = %s"
|
cat_filter = "AND pc.id_part_category = %s"
|
||||||
params.append(category_id)
|
params.append(category_id)
|
||||||
|
|
||||||
|
# --- Brand-filtered mode: return aftermarket parts directly ---
|
||||||
|
if allowed_brands:
|
||||||
|
am_search = ""
|
||||||
|
am_params = list(params)
|
||||||
|
if search:
|
||||||
|
am_search = "AND (ap.part_number ILIKE %s OR COALESCE(NULLIF(ap.name_aftermarket_parts, ''), p.name_part) ILIKE %s)"
|
||||||
|
like_term = f"%{search}%"
|
||||||
|
am_params.extend([like_term, like_term])
|
||||||
|
|
||||||
|
query_params = list(am_params)
|
||||||
|
cur.execute(f"""
|
||||||
|
SELECT DISTINCT ap.id_aftermarket_parts,
|
||||||
|
ap.part_number,
|
||||||
|
COALESCE(NULLIF(ap.name_aftermarket_parts, ''), p.name_part) as name,
|
||||||
|
m.name_manufacture,
|
||||||
|
ap.price_usd,
|
||||||
|
p.id_part,
|
||||||
|
pg.id_part_group, pg.name_part_group,
|
||||||
|
pc.id_part_category, pc.name_part_category
|
||||||
|
FROM part_vehicle_preview pvp
|
||||||
|
JOIN parts p ON p.id_part = pvp.part_id
|
||||||
|
JOIN aftermarket_parts ap ON ap.oem_part_id = p.id_part
|
||||||
|
JOIN manufacturers m ON m.id_manufacture = ap.manufacturer_id
|
||||||
|
JOIN part_groups pg ON pg.id_part_group = p.group_id
|
||||||
|
JOIN part_categories pc ON pc.id_part_category = pg.category_id
|
||||||
|
WHERE pvp.name_brand = %s
|
||||||
|
{cat_filter}
|
||||||
|
{am_search}
|
||||||
|
AND UPPER(m.name_manufacture) = ANY(%s)
|
||||||
|
ORDER BY m.name_manufacture, ap.part_number
|
||||||
|
LIMIT %s OFFSET %s
|
||||||
|
""", query_params + [allowed_brands, limit, offset])
|
||||||
|
|
||||||
|
part_rows = cur.fetchall()
|
||||||
|
oem_ids = [r[5] for r in part_rows]
|
||||||
|
|
||||||
|
count_params = list(am_params)
|
||||||
|
cur.execute(f"""
|
||||||
|
SELECT COUNT(DISTINCT ap.id_aftermarket_parts)
|
||||||
|
FROM part_vehicle_preview pvp
|
||||||
|
JOIN parts p ON p.id_part = pvp.part_id
|
||||||
|
JOIN aftermarket_parts ap ON ap.oem_part_id = p.id_part
|
||||||
|
JOIN manufacturers m ON m.id_manufacture = ap.manufacturer_id
|
||||||
|
JOIN part_groups pg ON pg.id_part_group = p.group_id
|
||||||
|
JOIN part_categories pc ON pc.id_part_category = pg.category_id
|
||||||
|
WHERE pvp.name_brand = %s
|
||||||
|
{cat_filter}
|
||||||
|
{am_search}
|
||||||
|
AND UPPER(m.name_manufacture) = ANY(%s)
|
||||||
|
""", count_params + [allowed_brands])
|
||||||
|
total = cur.fetchone()[0]
|
||||||
|
|
||||||
|
local_stock = {}
|
||||||
|
if tenant and oem_ids:
|
||||||
|
try:
|
||||||
|
from services.catalog_service import _get_local_stock_bulk
|
||||||
|
local_stock = _get_local_stock_bulk(tenant, branch_id, oem_ids, [])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for r in part_rows:
|
||||||
|
oem_id = r[5]
|
||||||
|
stock_info = local_stock.get(oem_id, {})
|
||||||
|
items.append({
|
||||||
|
'id': r[0],
|
||||||
|
'oem_part_number': r[1],
|
||||||
|
'name': r[2],
|
||||||
|
'manufacturer': r[3],
|
||||||
|
'price_usd': float(r[4]) if r[4] is not None else None,
|
||||||
|
'oem_id': oem_id,
|
||||||
|
'group': {'id': r[6], 'name': r[7]},
|
||||||
|
'category': {'id': r[8], 'name': r[9]},
|
||||||
|
'local_stock': stock_info.get('stock', 0),
|
||||||
|
'local_price': stock_info.get('price', None),
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'brand': brand,
|
||||||
|
'category_id': category_id,
|
||||||
|
'search': search,
|
||||||
|
'items': items,
|
||||||
|
'total': total,
|
||||||
|
'limit': limit,
|
||||||
|
'offset': offset,
|
||||||
|
'allowed_brands': allowed_brands
|
||||||
|
})
|
||||||
|
|
||||||
|
# --- Normal mode: return OEM parts ---
|
||||||
if search:
|
if search:
|
||||||
search_filter = "AND (p.oem_part_number ILIKE %s OR COALESCE(NULLIF(p.name_es, ''), p.name_part) ILIKE %s)"
|
search_filter = "AND (p.oem_part_number ILIKE %s OR COALESCE(NULLIF(p.name_es, ''), p.name_part) ILIKE %s)"
|
||||||
like_term = f"%{search}%"
|
like_term = f"%{search}%"
|
||||||
params.extend([like_term, like_term])
|
params.extend([like_term, like_term])
|
||||||
|
|
||||||
# Get parts from the brand catalog
|
|
||||||
query_params = list(params)
|
query_params = list(params)
|
||||||
cur.execute(f"""
|
cur.execute(f"""
|
||||||
SELECT DISTINCT p.id_part, p.oem_part_number,
|
SELECT DISTINCT p.id_part, p.oem_part_number,
|
||||||
@@ -715,7 +886,7 @@ def brand_parts():
|
|||||||
part_rows = cur.fetchall()
|
part_rows = cur.fetchall()
|
||||||
part_ids = [r[0] for r in part_rows]
|
part_ids = [r[0] for r in part_rows]
|
||||||
|
|
||||||
# Count total
|
count_params = list(params)
|
||||||
cur.execute(f"""
|
cur.execute(f"""
|
||||||
SELECT COUNT(DISTINCT p.id_part)
|
SELECT COUNT(DISTINCT p.id_part)
|
||||||
FROM part_vehicle_preview pvp
|
FROM part_vehicle_preview pvp
|
||||||
@@ -725,15 +896,14 @@ def brand_parts():
|
|||||||
WHERE pvp.name_brand = %s
|
WHERE pvp.name_brand = %s
|
||||||
{cat_filter}
|
{cat_filter}
|
||||||
{search_filter}
|
{search_filter}
|
||||||
""", params)
|
""", count_params)
|
||||||
total = cur.fetchone()[0]
|
total = cur.fetchone()[0]
|
||||||
|
|
||||||
# Enrich with local stock if available
|
|
||||||
local_stock = {}
|
local_stock = {}
|
||||||
if tenant and part_ids:
|
if tenant and part_ids:
|
||||||
try:
|
try:
|
||||||
from services.catalog_service import _get_local_stock_bulk
|
from services.catalog_service import _get_local_stock_bulk
|
||||||
local_stock = _get_local_stock_bulk(tenant, part_ids)
|
local_stock = _get_local_stock_bulk(tenant, branch_id, [], part_ids)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -759,6 +929,203 @@ def brand_parts():
|
|||||||
'total': total,
|
'total': total,
|
||||||
'limit': limit,
|
'limit': limit,
|
||||||
'offset': offset,
|
'offset': offset,
|
||||||
|
'allowed_brands': []
|
||||||
|
})
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
return _with_conns(_query)
|
||||||
|
|
||||||
|
|
||||||
|
@catalog_bp.route('/mye-parts', methods=['GET'])
|
||||||
|
@require_auth('catalog.view')
|
||||||
|
def mye_parts():
|
||||||
|
"""Return parts for a specific MYE + category (brand-catalog flow).
|
||||||
|
|
||||||
|
Skips the group/subgroup level and goes directly from category to parts.
|
||||||
|
"""
|
||||||
|
mye_id = request.args.get('mye_id', type=int)
|
||||||
|
category_id = request.args.get('category_id', type=int)
|
||||||
|
search = request.args.get('search', '').strip()
|
||||||
|
limit = request.args.get('limit', 50, type=int)
|
||||||
|
offset = request.args.get('offset', 0, type=int)
|
||||||
|
|
||||||
|
if not mye_id:
|
||||||
|
return jsonify({'error': 'mye_id required'}), 400
|
||||||
|
|
||||||
|
def _query(master, tenant, branch_id):
|
||||||
|
cur = master.cursor()
|
||||||
|
try:
|
||||||
|
allowed_brands = _get_allowed_brands(tenant) if tenant else None
|
||||||
|
|
||||||
|
cat_filter = ""
|
||||||
|
search_filter = ""
|
||||||
|
params = [mye_id]
|
||||||
|
|
||||||
|
if category_id:
|
||||||
|
cat_filter = "AND pc.id_part_category = %s"
|
||||||
|
params.append(category_id)
|
||||||
|
|
||||||
|
# --- Brand-filtered mode: return aftermarket parts directly ---
|
||||||
|
if allowed_brands:
|
||||||
|
am_search = ""
|
||||||
|
am_params = list(params)
|
||||||
|
if search:
|
||||||
|
am_search = "AND (ap.part_number ILIKE %s OR COALESCE(NULLIF(ap.name_aftermarket_parts, ''), p.name_part) ILIKE %s)"
|
||||||
|
like_term = f"%{search}%"
|
||||||
|
am_params.extend([like_term, like_term])
|
||||||
|
|
||||||
|
# Get aftermarket parts
|
||||||
|
query_params = list(am_params)
|
||||||
|
cur.execute(f"""
|
||||||
|
SELECT DISTINCT ap.id_aftermarket_parts,
|
||||||
|
ap.part_number,
|
||||||
|
COALESCE(NULLIF(ap.name_aftermarket_parts, ''), p.name_part) as name,
|
||||||
|
m.name_manufacture,
|
||||||
|
ap.price_usd,
|
||||||
|
p.id_part,
|
||||||
|
pg.id_part_group, pg.name_part_group,
|
||||||
|
pc.id_part_category, pc.name_part_category
|
||||||
|
FROM vehicle_parts vp
|
||||||
|
JOIN parts p ON p.id_part = vp.part_id
|
||||||
|
JOIN aftermarket_parts ap ON ap.oem_part_id = p.id_part
|
||||||
|
JOIN manufacturers m ON m.id_manufacture = ap.manufacturer_id
|
||||||
|
JOIN part_groups pg ON pg.id_part_group = p.group_id
|
||||||
|
JOIN part_categories pc ON pc.id_part_category = pg.category_id
|
||||||
|
WHERE vp.model_year_engine_id = %s
|
||||||
|
{cat_filter}
|
||||||
|
{am_search}
|
||||||
|
AND UPPER(m.name_manufacture) = ANY(%s)
|
||||||
|
ORDER BY m.name_manufacture, ap.part_number
|
||||||
|
LIMIT %s OFFSET %s
|
||||||
|
""", query_params + [allowed_brands, limit, offset])
|
||||||
|
|
||||||
|
part_rows = cur.fetchall()
|
||||||
|
oem_ids = [r[5] for r in part_rows]
|
||||||
|
|
||||||
|
# Count total
|
||||||
|
count_params = list(am_params)
|
||||||
|
cur.execute(f"""
|
||||||
|
SELECT COUNT(DISTINCT ap.id_aftermarket_parts)
|
||||||
|
FROM vehicle_parts vp
|
||||||
|
JOIN parts p ON p.id_part = vp.part_id
|
||||||
|
JOIN aftermarket_parts ap ON ap.oem_part_id = p.id_part
|
||||||
|
JOIN manufacturers m ON m.id_manufacture = ap.manufacturer_id
|
||||||
|
JOIN part_groups pg ON pg.id_part_group = p.group_id
|
||||||
|
JOIN part_categories pc ON pc.id_part_category = pg.category_id
|
||||||
|
WHERE vp.model_year_engine_id = %s
|
||||||
|
{cat_filter}
|
||||||
|
{am_search}
|
||||||
|
AND UPPER(m.name_manufacture) = ANY(%s)
|
||||||
|
""", count_params + [allowed_brands])
|
||||||
|
total = cur.fetchone()[0]
|
||||||
|
|
||||||
|
# Local stock keyed by OEM part id
|
||||||
|
local_stock = {}
|
||||||
|
if tenant and oem_ids:
|
||||||
|
try:
|
||||||
|
from services.catalog_service import _get_local_stock_bulk
|
||||||
|
local_stock = _get_local_stock_bulk(tenant, branch_id, oem_ids, [])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for r in part_rows:
|
||||||
|
oem_id = r[5]
|
||||||
|
stock_info = local_stock.get(oem_id, {})
|
||||||
|
items.append({
|
||||||
|
'id': r[0],
|
||||||
|
'oem_part_number': r[1],
|
||||||
|
'name': r[2],
|
||||||
|
'manufacturer': r[3],
|
||||||
|
'price_usd': float(r[4]) if r[4] is not None else None,
|
||||||
|
'oem_id': oem_id,
|
||||||
|
'group': {'id': r[6], 'name': r[7]},
|
||||||
|
'category': {'id': r[8], 'name': r[9]},
|
||||||
|
'local_stock': stock_info.get('stock', 0),
|
||||||
|
'local_price': stock_info.get('price', None),
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'mye_id': mye_id,
|
||||||
|
'category_id': category_id,
|
||||||
|
'search': search,
|
||||||
|
'items': items,
|
||||||
|
'total': total,
|
||||||
|
'limit': limit,
|
||||||
|
'offset': offset,
|
||||||
|
'allowed_brands': allowed_brands
|
||||||
|
})
|
||||||
|
|
||||||
|
# --- Normal mode: return OEM parts ---
|
||||||
|
if search:
|
||||||
|
search_filter = "AND (p.oem_part_number ILIKE %s OR COALESCE(NULLIF(p.name_es, ''), p.name_part) ILIKE %s)"
|
||||||
|
like_term = f"%{search}%"
|
||||||
|
params.extend([like_term, like_term])
|
||||||
|
|
||||||
|
query_params = list(params)
|
||||||
|
cur.execute(f"""
|
||||||
|
SELECT DISTINCT p.id_part, p.oem_part_number,
|
||||||
|
COALESCE(NULLIF(p.name_es, ''), p.name_part) as name,
|
||||||
|
pg.id_part_group, pg.name_part_group,
|
||||||
|
pc.id_part_category, pc.name_part_category
|
||||||
|
FROM vehicle_parts vp
|
||||||
|
JOIN parts p ON p.id_part = vp.part_id
|
||||||
|
JOIN part_groups pg ON pg.id_part_group = p.group_id
|
||||||
|
JOIN part_categories pc ON pc.id_part_category = pg.category_id
|
||||||
|
WHERE vp.model_year_engine_id = %s
|
||||||
|
{cat_filter}
|
||||||
|
{search_filter}
|
||||||
|
ORDER BY p.id_part
|
||||||
|
LIMIT %s OFFSET %s
|
||||||
|
""", query_params + [limit, offset])
|
||||||
|
|
||||||
|
part_rows = cur.fetchall()
|
||||||
|
part_ids = [r[0] for r in part_rows]
|
||||||
|
|
||||||
|
count_params = list(params)
|
||||||
|
cur.execute(f"""
|
||||||
|
SELECT COUNT(DISTINCT p.id_part)
|
||||||
|
FROM vehicle_parts vp
|
||||||
|
JOIN parts p ON p.id_part = vp.part_id
|
||||||
|
JOIN part_groups pg ON pg.id_part_group = p.group_id
|
||||||
|
JOIN part_categories pc ON pc.id_part_category = pg.category_id
|
||||||
|
WHERE vp.model_year_engine_id = %s
|
||||||
|
{cat_filter}
|
||||||
|
{search_filter}
|
||||||
|
""", count_params)
|
||||||
|
total = cur.fetchone()[0]
|
||||||
|
|
||||||
|
local_stock = {}
|
||||||
|
if tenant and part_ids:
|
||||||
|
try:
|
||||||
|
from services.catalog_service import _get_local_stock_bulk
|
||||||
|
local_stock = _get_local_stock_bulk(tenant, branch_id, [], part_ids)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for r in part_rows:
|
||||||
|
part_id = r[0]
|
||||||
|
stock_info = local_stock.get(part_id, {})
|
||||||
|
items.append({
|
||||||
|
'id': part_id,
|
||||||
|
'oem_part_number': r[1],
|
||||||
|
'name': r[2],
|
||||||
|
'group': {'id': r[3], 'name': r[4]},
|
||||||
|
'category': {'id': r[5], 'name': r[6]},
|
||||||
|
'local_stock': stock_info.get('stock', 0),
|
||||||
|
'local_price': stock_info.get('price', None),
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'mye_id': mye_id,
|
||||||
|
'category_id': category_id,
|
||||||
|
'search': search,
|
||||||
|
'items': items,
|
||||||
|
'total': total,
|
||||||
|
'limit': limit,
|
||||||
|
'offset': offset,
|
||||||
|
'allowed_brands': []
|
||||||
})
|
})
|
||||||
finally:
|
finally:
|
||||||
cur.close()
|
cur.close()
|
||||||
|
|||||||
@@ -13,15 +13,51 @@ config_bp = Blueprint('config', __name__, url_prefix='/pos/api/config')
|
|||||||
def list_branches():
|
def list_branches():
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
cur.execute("SELECT id, name, address, phone, is_active FROM branches ORDER BY id")
|
cur.execute("""
|
||||||
|
SELECT id, name, address, phone, is_active, is_main,
|
||||||
|
rfc, razon_social, regimen_fiscal, cp,
|
||||||
|
direccion_fiscal, serie_cfdi, folio_inicio, folio_actual, email
|
||||||
|
FROM branches ORDER BY id
|
||||||
|
""")
|
||||||
branches = []
|
branches = []
|
||||||
for r in cur.fetchall():
|
for r in cur.fetchall():
|
||||||
branches.append({'id': r[0], 'name': r[1], 'address': r[2], 'phone': r[3], 'is_active': r[4]})
|
branches.append({
|
||||||
|
'id': r[0], 'name': r[1], 'address': r[2], 'phone': r[3],
|
||||||
|
'is_active': r[4], 'is_main': r[5],
|
||||||
|
'rfc': r[6], 'razon_social': r[7], 'regimen_fiscal': r[8],
|
||||||
|
'cp': r[9], 'direccion_fiscal': r[10], 'serie_cfdi': r[11],
|
||||||
|
'folio_inicio': r[12], 'folio_actual': r[13], 'email': r[14],
|
||||||
|
})
|
||||||
cur.close()
|
cur.close()
|
||||||
conn.close()
|
conn.close()
|
||||||
return jsonify({'data': branches})
|
return jsonify({'data': branches})
|
||||||
|
|
||||||
|
|
||||||
|
@config_bp.route('/branches/<int:branch_id>', methods=['GET'])
|
||||||
|
@require_auth('config.view')
|
||||||
|
def get_branch(branch_id):
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id, name, address, phone, is_active, is_main,
|
||||||
|
rfc, razon_social, regimen_fiscal, cp,
|
||||||
|
direccion_fiscal, serie_cfdi, folio_inicio, folio_actual, email
|
||||||
|
FROM branches WHERE id = %s
|
||||||
|
""", (branch_id,))
|
||||||
|
r = cur.fetchone()
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
if not r:
|
||||||
|
return jsonify({'error': 'Branch not found'}), 404
|
||||||
|
return jsonify({
|
||||||
|
'id': r[0], 'name': r[1], 'address': r[2], 'phone': r[3],
|
||||||
|
'is_active': r[4], 'is_main': r[5],
|
||||||
|
'rfc': r[6], 'razon_social': r[7], 'regimen_fiscal': r[8],
|
||||||
|
'cp': r[9], 'direccion_fiscal': r[10], 'serie_cfdi': r[11],
|
||||||
|
'folio_inicio': r[12], 'folio_actual': r[13], 'email': r[14],
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@config_bp.route('/branches', methods=['POST'])
|
@config_bp.route('/branches', methods=['POST'])
|
||||||
@require_auth('config.edit')
|
@require_auth('config.edit')
|
||||||
def create_branch():
|
def create_branch():
|
||||||
@@ -47,10 +83,23 @@ def create_branch():
|
|||||||
|
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
# If setting as main, clear any existing main
|
||||||
|
if data.get('is_main'):
|
||||||
|
cur.execute("UPDATE branches SET is_main = false WHERE is_main = true")
|
||||||
|
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
INSERT INTO branches (name, address, phone)
|
INSERT INTO branches (
|
||||||
VALUES (%s, %s, %s) RETURNING id
|
name, address, phone, is_main,
|
||||||
""", (data['name'], data.get('address'), data.get('phone')))
|
rfc, razon_social, regimen_fiscal, cp,
|
||||||
|
direccion_fiscal, serie_cfdi, folio_inicio, folio_actual, email
|
||||||
|
)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id
|
||||||
|
""", (
|
||||||
|
data['name'], data.get('address'), data.get('phone'), bool(data.get('is_main')),
|
||||||
|
data.get('rfc'), data.get('razon_social'), data.get('regimen_fiscal'), data.get('cp'),
|
||||||
|
data.get('direccion_fiscal'), data.get('serie_cfdi'), data.get('folio_inicio'), data.get('folio_actual'), data.get('email'),
|
||||||
|
))
|
||||||
branch_id = cur.fetchone()[0]
|
branch_id = cur.fetchone()[0]
|
||||||
conn.commit()
|
conn.commit()
|
||||||
cur.close()
|
cur.close()
|
||||||
@@ -58,6 +107,49 @@ def create_branch():
|
|||||||
return jsonify({'id': branch_id, 'message': 'Branch created'}), 201
|
return jsonify({'id': branch_id, 'message': 'Branch created'}), 201
|
||||||
|
|
||||||
|
|
||||||
|
@config_bp.route('/branches/<int:branch_id>', methods=['PUT'])
|
||||||
|
@require_auth('config.edit')
|
||||||
|
def update_branch(branch_id):
|
||||||
|
data = request.get_json() or {}
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
cur.execute("SELECT id FROM branches WHERE id = %s", (branch_id,))
|
||||||
|
if not cur.fetchone():
|
||||||
|
cur.close(); conn.close()
|
||||||
|
return jsonify({'error': 'Branch not found'}), 404
|
||||||
|
|
||||||
|
# If setting as main, clear any existing main
|
||||||
|
if data.get('is_main'):
|
||||||
|
cur.execute("UPDATE branches SET is_main = false WHERE is_main = true AND id <> %s", (branch_id,))
|
||||||
|
|
||||||
|
updates = []
|
||||||
|
params = []
|
||||||
|
field_map = {
|
||||||
|
'name': 'name', 'address': 'address', 'phone': 'phone',
|
||||||
|
'is_active': 'is_active', 'is_main': 'is_main',
|
||||||
|
'rfc': 'rfc', 'razon_social': 'razon_social',
|
||||||
|
'regimen_fiscal': 'regimen_fiscal', 'cp': 'cp',
|
||||||
|
'direccion_fiscal': 'direccion_fiscal', 'serie_cfdi': 'serie_cfdi',
|
||||||
|
'folio_inicio': 'folio_inicio', 'folio_actual': 'folio_actual', 'email': 'email',
|
||||||
|
}
|
||||||
|
for json_key, col in field_map.items():
|
||||||
|
if json_key in data:
|
||||||
|
updates.append(f"{col} = %s")
|
||||||
|
params.append(data[json_key])
|
||||||
|
|
||||||
|
if not updates:
|
||||||
|
cur.close(); conn.close()
|
||||||
|
return jsonify({'error': 'Nothing to update'}), 400
|
||||||
|
|
||||||
|
params.append(branch_id)
|
||||||
|
cur.execute(f"UPDATE branches SET {', '.join(updates)} WHERE id = %s", params)
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
return jsonify({'ok': True, 'message': 'Branch updated'})
|
||||||
|
|
||||||
|
|
||||||
@config_bp.route('/employees', methods=['GET'])
|
@config_bp.route('/employees', methods=['GET'])
|
||||||
@require_auth('config.view')
|
@require_auth('config.view')
|
||||||
def list_employees():
|
def list_employees():
|
||||||
@@ -451,3 +543,205 @@ def update_vehicle_compat_source():
|
|||||||
cur.close()
|
cur.close()
|
||||||
conn.close()
|
conn.close()
|
||||||
return jsonify({'message': 'Vehicle compatibility source updated', 'source': source})
|
return jsonify({'message': 'Vehicle compatibility source updated', 'source': source})
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Allowed Part Brands ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Whitelist of part manufacturers shown in the allowed-brands selector
|
||||||
|
_ALLOWED_PART_BRANDS = [
|
||||||
|
'Luk', 'Motocraft', 'Euzcadi', 'Gates', 'Injetech', 'Bilstein',
|
||||||
|
'Monroe', 'Yokomitzu', 'Ecom', 'Lth', 'Dynamik', 'Wagner',
|
||||||
|
'Bosch', 'Brembo', 'Champion', 'Dorman', 'Kyb', 'Handkook',
|
||||||
|
'Tomco', 'Mann Filter', 'Total Parts', 'Kanadian', 'Pirelli',
|
||||||
|
'NGK', 'Moresa', 'Fritec', 'Acdelco', 'Dash4', 'Moog', 'SYD',
|
||||||
|
'FRAM', 'AUTOLITE'
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@config_bp.route('/available-brands', methods=['GET'])
|
||||||
|
@require_auth()
|
||||||
|
def get_available_brands():
|
||||||
|
"""Return the whitelisted part manufacturer names.
|
||||||
|
|
||||||
|
The master DB manufacturers/aftermarket_parts tables were removed with
|
||||||
|
TecDoc, so we return the curated whitelist directly.
|
||||||
|
"""
|
||||||
|
brands = sorted({b.strip() for b in _ALLOWED_PART_BRANDS if b and b.strip()})
|
||||||
|
return jsonify({'brands': brands})
|
||||||
|
|
||||||
|
|
||||||
|
@config_bp.route('/allowed-brands', methods=['GET'])
|
||||||
|
@require_auth()
|
||||||
|
def get_allowed_brands():
|
||||||
|
"""Return the tenant's allowed part brands from tenant_config."""
|
||||||
|
import json
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("SELECT value FROM tenant_config WHERE key = 'allowed_part_brands'")
|
||||||
|
row = cur.fetchone()
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
if row and row[0]:
|
||||||
|
try:
|
||||||
|
brands = json.loads(row[0])
|
||||||
|
if isinstance(brands, list):
|
||||||
|
return jsonify({'brands': brands})
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
pass
|
||||||
|
return jsonify({'brands': []})
|
||||||
|
|
||||||
|
|
||||||
|
@config_bp.route('/allowed-brands', methods=['PUT'])
|
||||||
|
@require_auth('config.edit')
|
||||||
|
def update_allowed_brands():
|
||||||
|
"""Save the tenant's allowed part brands to tenant_config."""
|
||||||
|
import json
|
||||||
|
data = request.get_json() or {}
|
||||||
|
brands = data.get('brands', [])
|
||||||
|
if not isinstance(brands, list):
|
||||||
|
return jsonify({'error': 'brands must be an array'}), 400
|
||||||
|
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO tenant_config (key, value) VALUES ('allowed_part_brands', %s)
|
||||||
|
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
||||||
|
""", (json.dumps(brands),))
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
return jsonify({'message': 'Allowed brands updated', 'brands': brands})
|
||||||
|
|
||||||
|
|
||||||
|
# ─── WhatsApp Configuration ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@config_bp.route('/whatsapp', methods=['GET'])
|
||||||
|
@require_auth('config.view')
|
||||||
|
def get_whatsapp_config():
|
||||||
|
"""Get WhatsApp bridge configuration for this tenant."""
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("SELECT key, value FROM tenant_config WHERE key LIKE 'whatsapp_%'")
|
||||||
|
rows = {row[0]: row[1] for row in cur.fetchall()}
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'bridge_url': rows.get('whatsapp_bridge_url', ''),
|
||||||
|
'bridge_key': rows.get('whatsapp_bridge_key', ''),
|
||||||
|
'enabled': rows.get('whatsapp_enabled', 'false').lower() == 'true',
|
||||||
|
'phone_number': rows.get('whatsapp_phone_number', ''),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@config_bp.route('/whatsapp', methods=['PUT'])
|
||||||
|
@require_auth('config.edit')
|
||||||
|
def update_whatsapp_config():
|
||||||
|
"""Update WhatsApp bridge configuration for this tenant."""
|
||||||
|
data = request.get_json() or {}
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
settings = {
|
||||||
|
'whatsapp_bridge_url': data.get('bridge_url', ''),
|
||||||
|
'whatsapp_bridge_key': data.get('bridge_key', ''),
|
||||||
|
'whatsapp_enabled': 'true' if data.get('enabled') else 'false',
|
||||||
|
'whatsapp_phone_number': data.get('phone_number', ''),
|
||||||
|
}
|
||||||
|
|
||||||
|
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': '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'),
|
||||||
|
'catalog': _bool('module_catalog'),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@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',
|
||||||
|
'module_catalog': 'true' if data.get('catalog') 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'])
|
||||||
|
@require_auth('pos.view')
|
||||||
|
def get_onboarding_status():
|
||||||
|
"""Check if tenant onboarding wizard has been completed."""
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("SELECT value FROM tenant_config WHERE key = 'onboarding_completed'")
|
||||||
|
row = cur.fetchone()
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
return jsonify({'completed': row[0] == 'true' if row else False})
|
||||||
|
|
||||||
|
|
||||||
|
@config_bp.route('/onboarding-status', methods=['POST'])
|
||||||
|
@require_auth('pos.view')
|
||||||
|
def set_onboarding_status():
|
||||||
|
"""Mark tenant onboarding wizard as completed."""
|
||||||
|
data = request.get_json() or {}
|
||||||
|
completed = 'true' if data.get('completed') else 'false'
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO tenant_config (key, value) VALUES (%s, %s)
|
||||||
|
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
||||||
|
""", ('onboarding_completed', completed))
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
return jsonify({'completed': completed == 'true'})
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ def list_customers():
|
|||||||
# Fetch
|
# Fetch
|
||||||
cur.execute(f"""
|
cur.execute(f"""
|
||||||
SELECT c.id, c.name, c.rfc, c.razon_social, c.phone, c.email,
|
SELECT c.id, c.name, c.rfc, c.razon_social, c.phone, c.email,
|
||||||
|
c.address, c.cp,
|
||||||
c.price_tier, c.credit_limit, c.credit_balance, c.vehicle_info,
|
c.price_tier, c.credit_limit, c.credit_balance, c.vehicle_info,
|
||||||
c.branch_id
|
c.branch_id
|
||||||
FROM customers c
|
FROM customers c
|
||||||
@@ -64,11 +65,12 @@ def list_customers():
|
|||||||
for r in cur.fetchall():
|
for r in cur.fetchall():
|
||||||
customers.append({
|
customers.append({
|
||||||
'id': r[0], 'name': r[1], 'rfc': r[2], 'razon_social': r[3],
|
'id': r[0], 'name': r[1], 'rfc': r[2], 'razon_social': r[3],
|
||||||
'phone': r[4], 'email': r[5], 'price_tier': r[6],
|
'phone': r[4], 'email': r[5], 'address': r[6], 'cp': r[7],
|
||||||
'credit_limit': float(r[7]) if r[7] else 0,
|
'price_tier': r[8],
|
||||||
'credit_balance': float(r[8]) if r[8] else 0,
|
'credit_limit': float(r[9]) if r[9] else 0,
|
||||||
'vehicle_info': r[9],
|
'credit_balance': float(r[10]) if r[10] else 0,
|
||||||
'branch_id': r[10],
|
'vehicle_info': r[11],
|
||||||
|
'branch_id': r[12],
|
||||||
})
|
})
|
||||||
|
|
||||||
cur.close()
|
cur.close()
|
||||||
@@ -91,7 +93,7 @@ def get_customer(customer_id):
|
|||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT id, branch_id, name, rfc, razon_social, regimen_fiscal, uso_cfdi,
|
SELECT id, branch_id, name, rfc, razon_social, regimen_fiscal, uso_cfdi,
|
||||||
cp, email, phone, address, price_tier, credit_limit, credit_balance,
|
cp, email, phone, address, price_tier, credit_limit, credit_balance,
|
||||||
is_active, vehicle_info, created_at
|
is_active, vehicle_info, created_at, max_discount_pct
|
||||||
FROM customers WHERE id = %s
|
FROM customers WHERE id = %s
|
||||||
""", (customer_id,))
|
""", (customer_id,))
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
@@ -103,7 +105,7 @@ def get_customer(customer_id):
|
|||||||
customer = dict(zip(cols, row))
|
customer = dict(zip(cols, row))
|
||||||
|
|
||||||
# Convert Decimal to float
|
# Convert Decimal to float
|
||||||
for k in ('credit_limit', 'credit_balance'):
|
for k in ('credit_limit', 'credit_balance', 'max_discount_pct'):
|
||||||
if customer.get(k) is not None:
|
if customer.get(k) is not None:
|
||||||
customer[k] = float(customer[k])
|
customer[k] = float(customer[k])
|
||||||
|
|
||||||
@@ -213,7 +215,7 @@ def update_customer(customer_id):
|
|||||||
# Build dynamic update
|
# Build dynamic update
|
||||||
allowed = ['name', 'rfc', 'razon_social', 'regimen_fiscal', 'uso_cfdi',
|
allowed = ['name', 'rfc', 'razon_social', 'regimen_fiscal', 'uso_cfdi',
|
||||||
'cp', 'email', 'phone', 'address', 'price_tier', 'credit_limit',
|
'cp', 'email', 'phone', 'address', 'price_tier', 'credit_limit',
|
||||||
'vehicle_info', 'is_active', 'branch_id']
|
'max_discount_pct', 'vehicle_info', 'is_active', 'branch_id']
|
||||||
sets = []
|
sets = []
|
||||||
vals = []
|
vals = []
|
||||||
for field in allowed:
|
for field in allowed:
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ dashboard_stats_bp = Blueprint('dashboard_stats', __name__, url_prefix='/pos/api
|
|||||||
|
|
||||||
|
|
||||||
from middleware import require_auth
|
from middleware import require_auth
|
||||||
|
from tenant_db import get_tenant_conn
|
||||||
|
|
||||||
|
|
||||||
class DecimalEncoder(json.JSONEncoder):
|
class DecimalEncoder(json.JSONEncoder):
|
||||||
@@ -25,83 +26,95 @@ class DecimalEncoder(json.JSONEncoder):
|
|||||||
@require_auth()
|
@require_auth()
|
||||||
def get_stats():
|
def get_stats():
|
||||||
"""Summary stats for today and this month."""
|
"""Summary stats for today and this month."""
|
||||||
from tenant_db import get_tenant_db
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
db = get_tenant_db()
|
cur = conn.cursor()
|
||||||
today = datetime.utcnow().date()
|
today = datetime.utcnow().date()
|
||||||
month_start = today.replace(day=1)
|
month_start = today.replace(day=1)
|
||||||
|
|
||||||
|
try:
|
||||||
# Sales today
|
# Sales today
|
||||||
today_sales = db.execute(
|
cur.execute(
|
||||||
"""SELECT COUNT(*) as count, COALESCE(SUM(total), 0) as total
|
"""SELECT COUNT(*) as count, COALESCE(SUM(total), 0) as total
|
||||||
FROM sales WHERE DATE(created_at) = %s""", (today,)
|
FROM sales WHERE DATE(created_at) = %s""", (today,)
|
||||||
).fetchone()
|
)
|
||||||
|
today_sales = cur.fetchone()
|
||||||
|
|
||||||
# Sales this month
|
# Sales this month
|
||||||
month_sales = db.execute(
|
cur.execute(
|
||||||
"""SELECT COUNT(*) as count, COALESCE(SUM(total), 0) as total
|
"""SELECT COUNT(*) as count, COALESCE(SUM(total), 0) as total
|
||||||
FROM sales WHERE DATE(created_at) >= %s""", (month_start,)
|
FROM sales WHERE DATE(created_at) >= %s""", (month_start,)
|
||||||
).fetchone()
|
)
|
||||||
|
month_sales = cur.fetchone()
|
||||||
|
|
||||||
# Top 5 products today
|
# Top 5 products today
|
||||||
top_products = db.execute(
|
cur.execute(
|
||||||
"""SELECT p.name, SUM(si.quantity) as qty, SUM(si.total) as revenue
|
"""SELECT si.name, SUM(si.quantity) as qty, SUM(si.subtotal) as revenue
|
||||||
FROM sale_items si
|
FROM sale_items si
|
||||||
JOIN sales s ON si.sale_id = s.id_sale
|
JOIN sales s ON si.sale_id = s.id
|
||||||
JOIN parts p ON si.part_id = p.id_part
|
|
||||||
WHERE DATE(s.created_at) = %s
|
WHERE DATE(s.created_at) = %s
|
||||||
GROUP BY p.name
|
GROUP BY si.name
|
||||||
ORDER BY revenue DESC
|
ORDER BY revenue DESC
|
||||||
LIMIT 5""", (today,)
|
LIMIT 5""", (today,)
|
||||||
).fetchall()
|
)
|
||||||
|
top_products = cur.fetchall()
|
||||||
|
|
||||||
# Hourly sales today (0-23)
|
# Hourly sales today (0-23)
|
||||||
hourly = db.execute(
|
cur.execute(
|
||||||
"""SELECT EXTRACT(HOUR FROM created_at)::int as hour,
|
"""SELECT EXTRACT(HOUR FROM created_at)::int as hour,
|
||||||
COUNT(*) as count, COALESCE(SUM(total), 0) as total
|
COUNT(*) as count, COALESCE(SUM(total), 0) as total
|
||||||
FROM sales WHERE DATE(created_at) = %s
|
FROM sales WHERE DATE(created_at) = %s
|
||||||
GROUP BY hour ORDER BY hour""", (today,)
|
GROUP BY hour ORDER BY hour""", (today,)
|
||||||
).fetchall()
|
)
|
||||||
hourly_map = {row['hour']: {'count': row['count'], 'total': row['total']} for row in hourly}
|
hourly = cur.fetchall()
|
||||||
|
hourly_map = {row[0]: {'count': row[1], 'total': row[2]} for row in hourly}
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'today': {
|
'today': {
|
||||||
'sales_count': today_sales['count'],
|
'sales_count': today_sales[0],
|
||||||
'sales_total': today_sales['total'],
|
'sales_total': float(today_sales[1]) if today_sales[1] is not None else 0,
|
||||||
},
|
},
|
||||||
'month': {
|
'month': {
|
||||||
'sales_count': month_sales['count'],
|
'sales_count': month_sales[0],
|
||||||
'sales_total': month_sales['total'],
|
'sales_total': float(month_sales[1]) if month_sales[1] is not None else 0,
|
||||||
},
|
},
|
||||||
'top_products': [
|
'top_products': [
|
||||||
{'name': row['name'], 'quantity': row['qty'], 'revenue': row['revenue']}
|
{'name': row[0], 'quantity': row[1], 'revenue': float(row[2]) if row[2] is not None else 0}
|
||||||
for row in top_products
|
for row in top_products
|
||||||
],
|
],
|
||||||
'hourly_sales': [
|
'hourly_sales': [
|
||||||
{'hour': h, 'count': hourly_map.get(h, {}).get('count', 0),
|
{'hour': h, 'count': hourly_map.get(h, {}).get('count', 0),
|
||||||
'total': hourly_map.get(h, {}).get('total', 0)}
|
'total': float(hourly_map.get(h, {}).get('total', 0))}
|
||||||
for h in range(24)
|
for h in range(24)
|
||||||
],
|
],
|
||||||
}, cls=DecimalEncoder)
|
})
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
@dashboard_stats_bp.route('/stats/employees', methods=['GET'])
|
@dashboard_stats_bp.route('/stats/employees', methods=['GET'])
|
||||||
@require_auth()
|
@require_auth()
|
||||||
def get_employee_stats():
|
def get_employee_stats():
|
||||||
"""Sales per employee today."""
|
"""Sales per employee today."""
|
||||||
from tenant_db import get_tenant_db
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
db = get_tenant_db()
|
cur = conn.cursor()
|
||||||
today = datetime.utcnow().date()
|
today = datetime.utcnow().date()
|
||||||
rows = db.execute(
|
try:
|
||||||
"""SELECT e.name, COUNT(s.id_sale) as sales, COALESCE(SUM(s.total), 0) as total
|
cur.execute(
|
||||||
|
"""SELECT e.name, COUNT(s.id) as sales, COALESCE(SUM(s.total), 0) as total
|
||||||
FROM sales s
|
FROM sales s
|
||||||
JOIN employees e ON s.employee_id = e.id_employee
|
JOIN employees e ON s.employee_id = e.id
|
||||||
WHERE DATE(s.created_at) = %s
|
WHERE DATE(s.created_at) = %s
|
||||||
GROUP BY e.name
|
GROUP BY e.name
|
||||||
ORDER BY total DESC""", (today,)
|
ORDER BY total DESC""", (today,)
|
||||||
).fetchall()
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'employees': [
|
'employees': [
|
||||||
{'name': row['name'], 'sales': row['sales'], 'total': row['total']}
|
{'name': row[0], 'sales': row[1], 'total': float(row[2]) if row[2] is not None else 0}
|
||||||
for row in rows
|
for row in rows
|
||||||
]
|
]
|
||||||
}, cls=DecimalEncoder)
|
})
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|||||||
128
pos/blueprints/dropshipping_bp.py
Normal file
128
pos/blueprints/dropshipping_bp.py
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
"""Dropshipping API — public read-only inventory endpoints.
|
||||||
|
|
||||||
|
Authentication: X-Dropshipping-Key header (per-tenant).
|
||||||
|
Optional: X-Tenant-Subdomain for faster resolution.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import Blueprint, request, jsonify, g
|
||||||
|
from tenant_db import get_tenant_conn, get_master_conn
|
||||||
|
from services import dropshipping_service as ds_svc
|
||||||
|
from services.webhook_service import dispatch_webhooks_bulk
|
||||||
|
|
||||||
|
dropship_bp = Blueprint("dropship", __name__, url_prefix="/pos/api/dropship")
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_tenant_by_key(api_key: str, subdomain_hint: str = None):
|
||||||
|
"""Return (tenant_conn, tenant_id) for a valid dropshipping API key.
|
||||||
|
|
||||||
|
If subdomain_hint is provided, validate only that tenant.
|
||||||
|
Otherwise scan active tenants (acceptable for small tenant count).
|
||||||
|
"""
|
||||||
|
master = get_master_conn()
|
||||||
|
try:
|
||||||
|
cur = master.cursor()
|
||||||
|
if subdomain_hint:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id, db_name FROM tenants WHERE subdomain = %s AND is_active = true",
|
||||||
|
(subdomain_hint,),
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
else:
|
||||||
|
cur.execute("SELECT id, db_name FROM tenants WHERE is_active = true")
|
||||||
|
rows = cur.fetchall()
|
||||||
|
cur.close()
|
||||||
|
|
||||||
|
for tid, db_name in rows:
|
||||||
|
try:
|
||||||
|
tconn = get_tenant_conn(tid)
|
||||||
|
if ds_svc.validate_api_key(tconn, api_key):
|
||||||
|
return tconn, tid
|
||||||
|
tconn.close()
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return None, None
|
||||||
|
finally:
|
||||||
|
master.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _require_dropship_auth():
|
||||||
|
key = request.headers.get("X-Dropshipping-Key")
|
||||||
|
subdomain = request.headers.get("X-Tenant-Subdomain")
|
||||||
|
if not key:
|
||||||
|
return jsonify({"error": "Missing X-Dropshipping-Key header"}), 401
|
||||||
|
tconn, tid = _resolve_tenant_by_key(key, subdomain_hint=subdomain)
|
||||||
|
if not tconn:
|
||||||
|
return jsonify({"error": "Invalid API key or tenant inactive"}), 401
|
||||||
|
g.tenant_id = tid
|
||||||
|
g.tenant_conn = tconn
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _release_tenant():
|
||||||
|
if hasattr(g, "tenant_conn") and g.tenant_conn:
|
||||||
|
g.tenant_conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@dropship_bp.route("/inventory", methods=["GET"])
|
||||||
|
def list_inventory():
|
||||||
|
err = _require_dropship_auth()
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
try:
|
||||||
|
page = int(request.args.get("page", 1))
|
||||||
|
per_page = min(int(request.args.get("per_page", 50)), 200)
|
||||||
|
search = request.args.get("q")
|
||||||
|
result = ds_svc.get_inventory_list(g.tenant_conn, search=search, page=page, per_page=per_page)
|
||||||
|
return jsonify(result)
|
||||||
|
finally:
|
||||||
|
_release_tenant()
|
||||||
|
|
||||||
|
|
||||||
|
@dropship_bp.route("/inventory/<sku>", methods=["GET"])
|
||||||
|
def get_inventory_item(sku):
|
||||||
|
err = _require_dropship_auth()
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
try:
|
||||||
|
item = ds_svc.get_inventory_by_sku(g.tenant_conn, sku)
|
||||||
|
if not item:
|
||||||
|
return jsonify({"error": "SKU not found"}), 404
|
||||||
|
return jsonify(item)
|
||||||
|
finally:
|
||||||
|
_release_tenant()
|
||||||
|
|
||||||
|
|
||||||
|
@dropship_bp.route("/stock", methods=["GET"])
|
||||||
|
def get_stock():
|
||||||
|
err = _require_dropship_auth()
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
try:
|
||||||
|
skus = request.args.get("skus", "")
|
||||||
|
sku_list = [s.strip() for s in skus.split(",") if s.strip()]
|
||||||
|
if not sku_list:
|
||||||
|
return jsonify({"error": "Provide ?skus=SKU1,SKU2,SKU3"}), 400
|
||||||
|
result = ds_svc.get_stock_by_skus(g.tenant_conn, sku_list)
|
||||||
|
return jsonify({"stock": result})
|
||||||
|
finally:
|
||||||
|
_release_tenant()
|
||||||
|
|
||||||
|
|
||||||
|
@dropship_bp.route("/webhooks/test", methods=["POST"])
|
||||||
|
def test_webhook():
|
||||||
|
"""Test endpoint to trigger a sample webhook to all configured targets."""
|
||||||
|
err = _require_dropship_auth()
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
try:
|
||||||
|
urls = ds_svc.get_webhook_targets(g.tenant_conn, "stock_updated")
|
||||||
|
if not urls:
|
||||||
|
return jsonify({"error": "No webhook targets configured"}), 400
|
||||||
|
results = dispatch_webhooks_bulk(
|
||||||
|
urls,
|
||||||
|
"test",
|
||||||
|
{"message": "Webhook test from Nexus POS", "tenant_id": g.tenant_id},
|
||||||
|
)
|
||||||
|
return jsonify({"dispatched": len(results), "results": results})
|
||||||
|
finally:
|
||||||
|
_release_tenant()
|
||||||
152
pos/blueprints/internal_bp.py
Normal file
152
pos/blueprints/internal_bp.py
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
"""Internal API endpoints for infrastructure orchestration.
|
||||||
|
|
||||||
|
These endpoints are meant to be called by the Nexus Manager or other
|
||||||
|
internal services. They require INTERNAL_API_KEY.
|
||||||
|
"""
|
||||||
|
import subprocess
|
||||||
|
import socket
|
||||||
|
from flask import Blueprint, request, jsonify
|
||||||
|
from config import INTERNAL_API_KEY
|
||||||
|
from tenant_db import get_master_conn, get_tenant_conn
|
||||||
|
|
||||||
|
internal_bp = Blueprint('internal', __name__, url_prefix='/pos/api/internal')
|
||||||
|
|
||||||
|
|
||||||
|
def _check_internal_key():
|
||||||
|
key = request.headers.get('X-Internal-Key', '')
|
||||||
|
if not INTERNAL_API_KEY:
|
||||||
|
return jsonify({'error': 'INTERNAL_API_KEY not configured on server'}), 500
|
||||||
|
if key != INTERNAL_API_KEY:
|
||||||
|
return jsonify({'error': 'Unauthorized'}), 401
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _find_free_port(start=21465, end=21565):
|
||||||
|
"""Find first free TCP port in range."""
|
||||||
|
for port in range(start, end + 1):
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||||
|
if s.connect_ex(('127.0.0.1', port)) != 0:
|
||||||
|
return port
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@internal_bp.route('/whatsapp-bridge', methods=['POST'])
|
||||||
|
def provision_whatsapp_bridge():
|
||||||
|
"""Provision a new WhatsApp Bridge Docker container for a tenant."""
|
||||||
|
auth_error = _check_internal_key()
|
||||||
|
if auth_error:
|
||||||
|
return auth_error
|
||||||
|
|
||||||
|
data = request.get_json() or {}
|
||||||
|
tenant_id = data.get('tenant_id')
|
||||||
|
subdomain = data.get('subdomain', f'tenant-{tenant_id}')
|
||||||
|
|
||||||
|
if not tenant_id:
|
||||||
|
return jsonify({'error': 'tenant_id required'}), 400
|
||||||
|
|
||||||
|
# Check if container already exists
|
||||||
|
container_name = f"wpp-{subdomain}"
|
||||||
|
check = subprocess.run(
|
||||||
|
['docker', 'ps', '-a', '-q', '-f', f'name={container_name}'],
|
||||||
|
capture_output=True, text=True
|
||||||
|
)
|
||||||
|
if check.stdout.strip():
|
||||||
|
return jsonify({'error': f'Container {container_name} already exists'}), 409
|
||||||
|
|
||||||
|
# Find free port
|
||||||
|
port = _find_free_port()
|
||||||
|
if not port:
|
||||||
|
return jsonify({'error': 'No free ports available in range 21465-21565'}), 503
|
||||||
|
|
||||||
|
# Build image if not exists
|
||||||
|
image_check = subprocess.run(
|
||||||
|
['docker', 'images', '-q', 'nexus-whatsapp-bridge'],
|
||||||
|
capture_output=True, text=True
|
||||||
|
)
|
||||||
|
if not image_check.stdout.strip():
|
||||||
|
build = subprocess.run(
|
||||||
|
['docker', 'build', '-f', '/home/Autopartes/pos/Dockerfile.whatsapp-bridge',
|
||||||
|
'-t', 'nexus-whatsapp-bridge', '/home/Autopartes/pos'],
|
||||||
|
capture_output=True, text=True
|
||||||
|
)
|
||||||
|
if build.returncode != 0:
|
||||||
|
return jsonify({'error': 'Failed to build bridge image', 'details': build.stderr}), 500
|
||||||
|
|
||||||
|
# Run container
|
||||||
|
bridge_url = f"http://127.0.0.1:{port}"
|
||||||
|
run = subprocess.run([
|
||||||
|
'docker', 'run', '-d',
|
||||||
|
'--name', container_name,
|
||||||
|
'--restart', 'unless-stopped',
|
||||||
|
'-p', f'{port}:21465',
|
||||||
|
'-e', f'PORT=21465',
|
||||||
|
'-e', f'TENANT_ID={tenant_id}',
|
||||||
|
'-e', f'WEBHOOK_BASE=http://127.0.0.1:5001/pos/api/whatsapp/webhook',
|
||||||
|
'-e', f'API_KEY=nexus-wpp-secret-2026',
|
||||||
|
'-e', f'LOG_LEVEL=info',
|
||||||
|
'-v', f'wpp-{subdomain}:/app/auth',
|
||||||
|
'nexus-whatsapp-bridge'
|
||||||
|
], capture_output=True, text=True)
|
||||||
|
|
||||||
|
if run.returncode != 0:
|
||||||
|
return jsonify({'error': 'Failed to start container', 'details': run.stderr}), 500
|
||||||
|
|
||||||
|
container_id = run.stdout.strip()
|
||||||
|
|
||||||
|
# Save config to tenant_config
|
||||||
|
conn = get_tenant_conn_by_dbname(data.get('db_name'))
|
||||||
|
if not conn:
|
||||||
|
# Fallback: get db_name from master
|
||||||
|
mconn = get_master_conn()
|
||||||
|
mcur = mconn.cursor()
|
||||||
|
mcur.execute("SELECT db_name FROM tenants WHERE id = %s", (tenant_id,))
|
||||||
|
row = mcur.fetchone()
|
||||||
|
mcur.close()
|
||||||
|
mconn.close()
|
||||||
|
if row:
|
||||||
|
conn = get_tenant_conn_by_dbname(row[0])
|
||||||
|
|
||||||
|
if conn:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO tenant_config (key, value) VALUES
|
||||||
|
('whatsapp_bridge_url', %s),
|
||||||
|
('whatsapp_enabled', 'true')
|
||||||
|
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
||||||
|
""", (bridge_url,))
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'tenant_id': tenant_id,
|
||||||
|
'container_id': container_id,
|
||||||
|
'container_name': container_name,
|
||||||
|
'port': port,
|
||||||
|
'bridge_url': bridge_url
|
||||||
|
}), 201
|
||||||
|
|
||||||
|
|
||||||
|
@internal_bp.route('/whatsapp-bridge', methods=['DELETE'])
|
||||||
|
def destroy_whatsapp_bridge():
|
||||||
|
"""Destroy a tenant's WhatsApp Bridge container."""
|
||||||
|
auth_error = _check_internal_key()
|
||||||
|
if auth_error:
|
||||||
|
return auth_error
|
||||||
|
|
||||||
|
data = request.get_json() or {}
|
||||||
|
subdomain = data.get('subdomain')
|
||||||
|
if not subdomain:
|
||||||
|
return jsonify({'error': 'subdomain required'}), 400
|
||||||
|
|
||||||
|
container_name = f"wpp-{subdomain}"
|
||||||
|
|
||||||
|
# Stop and remove container
|
||||||
|
subprocess.run(['docker', 'stop', container_name], capture_output=True)
|
||||||
|
subprocess.run(['docker', 'rm', container_name], capture_output=True)
|
||||||
|
|
||||||
|
# Remove volume
|
||||||
|
subprocess.run(['docker', 'volume', 'rm', f'wpp-{subdomain}'], capture_output=True)
|
||||||
|
|
||||||
|
return jsonify({'success': True, 'message': f'Bridge {container_name} destroyed'})
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -6,39 +6,61 @@ This blueprint is the HTTP layer that validates input and returns JSON.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
from datetime import datetime
|
||||||
from flask import Blueprint, request, jsonify, g
|
from flask import Blueprint, request, jsonify, g
|
||||||
from middleware import require_auth
|
from middleware import require_auth
|
||||||
from tenant_db import get_tenant_conn
|
from tenant_db import get_tenant_conn
|
||||||
from services.cfdi_builder import build_ingreso_xml, build_egreso_xml, build_pago_xml
|
from services.cfdi_facturapi_builder import (
|
||||||
|
build_ingreso_payload, build_egreso_payload, build_pago_payload,
|
||||||
|
)
|
||||||
from services.cfdi_queue import (
|
from services.cfdi_queue import (
|
||||||
enqueue_cfdi, process_queue, retry_failed,
|
enqueue_cfdi, process_queue, retry_failed,
|
||||||
cancel_cfdi, get_queue_status,
|
cancel_cfdi, get_queue_status,
|
||||||
)
|
)
|
||||||
|
from services import facturapi_service
|
||||||
from services.audit import log_action
|
from services.audit import log_action
|
||||||
|
|
||||||
invoicing_bp = Blueprint('invoicing', __name__, url_prefix='/pos/api/invoicing')
|
invoicing_bp = Blueprint('invoicing', __name__, url_prefix='/pos/api/invoicing')
|
||||||
|
|
||||||
|
|
||||||
def _get_tenant_config(cur):
|
def _get_issuer_config(cur, branch_id=None):
|
||||||
"""Load tenant CFDI configuration from tenant_config table.
|
"""Load CFDI issuer configuration.
|
||||||
|
|
||||||
Falls back to sensible defaults if config is incomplete.
|
If branch_id is provided and the branch has fiscal data, use it.
|
||||||
|
Otherwise fall back to tenant-level config.
|
||||||
"""
|
"""
|
||||||
|
# Tenant-level defaults
|
||||||
config = {}
|
config = {}
|
||||||
cur.execute("SELECT key, value FROM tenant_config WHERE key LIKE 'cfdi_%' OR key LIKE 'tenant_%'")
|
cur.execute("SELECT key, value FROM tenant_config WHERE key LIKE 'cfdi_%' OR key LIKE 'tenant_%'")
|
||||||
for row in cur.fetchall():
|
for row in cur.fetchall():
|
||||||
config[row[0]] = row[1]
|
config[row[0]] = row[1]
|
||||||
|
|
||||||
return {
|
result = {
|
||||||
'rfc': config.get('tenant_rfc', ''),
|
'rfc': config.get('tenant_rfc', ''),
|
||||||
'razon_social': config.get('tenant_razon_social', ''),
|
'razon_social': config.get('tenant_razon_social', ''),
|
||||||
'regimen_fiscal': config.get('cfdi_regimen_fiscal', '601'),
|
'regimen_fiscal': config.get('cfdi_regimen_fiscal', '601'),
|
||||||
'cp': config.get('tenant_cp', '00000'),
|
'cp': config.get('tenant_cp', '00000'),
|
||||||
'serie': config.get('cfdi_serie', 'A'),
|
'serie': config.get('cfdi_serie', 'A'),
|
||||||
'horux_api_url': config.get('cfdi_horux_api_url', ''),
|
'facturapi_key': config.get('cfdi_facturapi_key', ''),
|
||||||
'horux_api_key': config.get('cfdi_horux_api_key', ''),
|
'facturapi_org_id': config.get('cfdi_facturapi_org_id', ''),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Branch-level override
|
||||||
|
if branch_id:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT rfc, razon_social, regimen_fiscal, codigo_postal, serie_cfdi
|
||||||
|
FROM branches WHERE id = %s
|
||||||
|
""", (branch_id,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
if row and row[0]:
|
||||||
|
result['rfc'] = row[0] or result['rfc']
|
||||||
|
result['razon_social'] = row[1] or result['razon_social']
|
||||||
|
result['regimen_fiscal'] = row[2] or result['regimen_fiscal']
|
||||||
|
result['cp'] = row[3] or result['cp']
|
||||||
|
result['serie'] = row[4] or result['serie']
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _get_sale_with_items(cur, sale_id):
|
def _get_sale_with_items(cur, sale_id):
|
||||||
"""Load a sale with its items for CFDI generation."""
|
"""Load a sale with its items for CFDI generation."""
|
||||||
@@ -134,14 +156,14 @@ def generate_invoice():
|
|||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
tenant_config = _get_tenant_config(cur)
|
|
||||||
if not tenant_config['rfc']:
|
|
||||||
return jsonify({'error': 'Tenant RFC not configured. Set tenant_rfc in config.'}), 400
|
|
||||||
|
|
||||||
sale = _get_sale_with_items(cur, sale_id)
|
sale = _get_sale_with_items(cur, sale_id)
|
||||||
if not sale:
|
if not sale:
|
||||||
return jsonify({'error': 'Sale not found'}), 404
|
return jsonify({'error': 'Sale not found'}), 404
|
||||||
|
|
||||||
|
tenant_config = _get_issuer_config(cur, sale.get('branch_id'))
|
||||||
|
if not tenant_config['rfc']:
|
||||||
|
return jsonify({'error': 'Tenant RFC not configured. Set tenant_rfc in config.'}), 400
|
||||||
|
|
||||||
if sale['status'] == 'cancelled':
|
if sale['status'] == 'cancelled':
|
||||||
return jsonify({'error': 'Cannot invoice a cancelled sale'}), 400
|
return jsonify({'error': 'Cannot invoice a cancelled sale'}), 400
|
||||||
|
|
||||||
@@ -158,19 +180,19 @@ def generate_invoice():
|
|||||||
'error': f'Sale #{sale_id} already has a {cfdi_type} CFDI (queue #{existing[0]}, status: {existing[1]})'
|
'error': f'Sale #{sale_id} already has a {cfdi_type} CFDI (queue #{existing[0]}, status: {existing[1]})'
|
||||||
}), 409
|
}), 409
|
||||||
|
|
||||||
# Build XML
|
# Build Facturapi payload
|
||||||
if cfdi_type == 'ingreso':
|
if cfdi_type == 'ingreso':
|
||||||
xml = build_ingreso_xml(sale, tenant_config, customer)
|
payload = build_ingreso_payload(sale, tenant_config, customer)
|
||||||
elif cfdi_type == 'egreso':
|
elif cfdi_type == 'egreso':
|
||||||
original_uuid = data.get('original_uuid')
|
original_uuid = data.get('original_uuid')
|
||||||
if not original_uuid:
|
if not original_uuid:
|
||||||
return jsonify({'error': 'original_uuid required for egreso'}), 400
|
return jsonify({'error': 'original_uuid required for egreso'}), 400
|
||||||
xml = build_egreso_xml(sale, tenant_config, customer, original_uuid)
|
payload = build_egreso_payload(sale, tenant_config, customer, original_uuid)
|
||||||
else:
|
else:
|
||||||
return jsonify({'error': f'Invalid CFDI type: {cfdi_type}'}), 400
|
return jsonify({'error': f'Invalid CFDI type: {cfdi_type}'}), 400
|
||||||
|
|
||||||
# Enqueue
|
# Enqueue
|
||||||
result = enqueue_cfdi(conn, sale_id, cfdi_type, xml)
|
result = enqueue_cfdi(conn, sale_id, cfdi_type, payload)
|
||||||
|
|
||||||
log_action(conn, 'CFDI_GENERATED', 'cfdi_queue', result['id'],
|
log_action(conn, 'CFDI_GENERATED', 'cfdi_queue', result['id'],
|
||||||
new_value={'sale_id': sale_id, 'type': cfdi_type,
|
new_value={'sale_id': sale_id, 'type': cfdi_type,
|
||||||
@@ -225,10 +247,10 @@ def get_queue_item(cfdi_id):
|
|||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
|
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT q.id, q.sale_id, q.type, q.xml_unsigned, q.xml_signed,
|
SELECT q.id, q.sale_id, q.type, q.payload_unsigned, q.xml_signed,
|
||||||
q.uuid_fiscal, q.status, q.retry_count, q.provisional_folio,
|
q.uuid_fiscal, q.status, q.retry_count, q.provisional_folio,
|
||||||
q.error_message, q.cancel_motive, q.cancel_replacement_uuid,
|
q.error_message, q.cancel_motive, q.cancel_replacement_uuid,
|
||||||
q.created_at, q.stamped_at
|
q.created_at, q.stamped_at, q.external_id
|
||||||
FROM cfdi_queue q WHERE q.id = %s
|
FROM cfdi_queue q WHERE q.id = %s
|
||||||
""", (cfdi_id,))
|
""", (cfdi_id,))
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
@@ -239,13 +261,14 @@ def get_queue_item(cfdi_id):
|
|||||||
|
|
||||||
item = {
|
item = {
|
||||||
'id': row[0], 'sale_id': row[1], 'type': row[2],
|
'id': row[0], 'sale_id': row[1], 'type': row[2],
|
||||||
'xml_unsigned': row[3], 'xml_signed': row[4],
|
'payload_unsigned': row[3], 'xml_signed': row[4],
|
||||||
'uuid_fiscal': row[5], 'status': row[6],
|
'uuid_fiscal': row[5], 'status': row[6],
|
||||||
'retry_count': row[7], 'provisional_folio': row[8],
|
'retry_count': row[7], 'provisional_folio': row[8],
|
||||||
'error_message': row[9], 'cancel_motive': row[10],
|
'error_message': row[9], 'cancel_motive': row[10],
|
||||||
'cancel_replacement_uuid': row[11],
|
'cancel_replacement_uuid': row[11],
|
||||||
'created_at': str(row[12]) if row[12] else None,
|
'created_at': str(row[12]) if row[12] else None,
|
||||||
'stamped_at': str(row[13]) if row[13] else None,
|
'stamped_at': str(row[13]) if row[13] else None,
|
||||||
|
'external_id': row[14],
|
||||||
}
|
}
|
||||||
|
|
||||||
cur.close()
|
cur.close()
|
||||||
@@ -261,20 +284,17 @@ def trigger_process_queue():
|
|||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
tenant_config = _get_tenant_config(cur)
|
tenant_config = _get_issuer_config(cur)
|
||||||
horux_url = tenant_config.get('horux_api_url')
|
if not tenant_config.get('facturapi_key'):
|
||||||
horux_key = tenant_config.get('horux_api_key')
|
|
||||||
|
|
||||||
if not horux_url or not horux_key:
|
|
||||||
cur.close()
|
cur.close()
|
||||||
conn.close()
|
conn.close()
|
||||||
return jsonify({'error': 'Horux API not configured'}), 400
|
return jsonify({'error': 'Facturapi key not configured'}), 400
|
||||||
|
|
||||||
# Reset eligible failed items first
|
# Reset eligible failed items first
|
||||||
reset_count = retry_failed(conn)
|
reset_count = retry_failed(conn)
|
||||||
|
|
||||||
# Process the queue
|
# Process the queue
|
||||||
result = process_queue(conn, horux_url, horux_key)
|
result = process_queue(conn, tenant_config)
|
||||||
result['retries_reset'] = reset_count
|
result['retries_reset'] = reset_count
|
||||||
|
|
||||||
cur.close()
|
cur.close()
|
||||||
@@ -316,11 +336,10 @@ def cancel_invoice(cfdi_id):
|
|||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
tenant_config = _get_tenant_config(cur)
|
tenant_config = _get_issuer_config(cur)
|
||||||
result = cancel_cfdi(
|
result = cancel_cfdi(
|
||||||
conn, cfdi_id, motive, replacement_uuid,
|
conn, cfdi_id, motive, replacement_uuid,
|
||||||
tenant_config.get('horux_api_url'),
|
tenant_config=tenant_config,
|
||||||
tenant_config.get('horux_api_key'),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
log_action(conn, 'CFDI_CANCELLED', 'cfdi_queue', cfdi_id,
|
log_action(conn, 'CFDI_CANCELLED', 'cfdi_queue', cfdi_id,
|
||||||
@@ -362,7 +381,7 @@ def get_sale_pdf(sale_id):
|
|||||||
cur.close(); conn.close()
|
cur.close(); conn.close()
|
||||||
return jsonify({'error': 'Sale not found'}), 404
|
return jsonify({'error': 'Sale not found'}), 404
|
||||||
|
|
||||||
tenant_config = _get_tenant_config(cur)
|
tenant_config = _get_issuer_config(cur, sale.get('branch_id'))
|
||||||
customer = _get_customer(cur, sale.get('customer_id'))
|
customer = _get_customer(cur, sale.get('customer_id'))
|
||||||
|
|
||||||
# Check if there's a stamped CFDI
|
# Check if there's a stamped CFDI
|
||||||
@@ -397,3 +416,249 @@ def get_sale_pdf(sale_id):
|
|||||||
'customer': customer,
|
'customer': customer,
|
||||||
'cfdi': cfdi_info,
|
'cfdi': cfdi_info,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@invoicing_bp.route('/stats', methods=['GET'])
|
||||||
|
@require_auth('invoicing.read')
|
||||||
|
def api_invoicing_stats():
|
||||||
|
"""Return counts for tab badges: invoices, credit notes, payment complements, cancellations."""
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
SELECT
|
||||||
|
COUNT(*) FILTER (WHERE type = 'ingreso' AND status IN ('pending', 'stamped', 'retry')) as facturas,
|
||||||
|
COUNT(*) FILTER (WHERE type = 'egreso' AND status IN ('pending', 'stamped', 'retry')) as notas_credito,
|
||||||
|
COUNT(*) FILTER (WHERE type = 'pago' AND status IN ('pending', 'stamped', 'retry')) as complementos,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'cancelled') as cancelaciones
|
||||||
|
FROM cfdi_queue
|
||||||
|
""")
|
||||||
|
row = cur.fetchone()
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'facturas': row[0] or 0,
|
||||||
|
'notas_credito': row[1] or 0,
|
||||||
|
'complementos': row[2] or 0,
|
||||||
|
'cancelaciones': row[3] or 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@invoicing_bp.route('/global-invoice', methods=['POST'])
|
||||||
|
@require_auth('invoicing.create')
|
||||||
|
def generate_global_invoice():
|
||||||
|
"""Generate a monthly global invoice for cash sales.
|
||||||
|
|
||||||
|
Body: {
|
||||||
|
year: int (default current year),
|
||||||
|
month: int (default current month),
|
||||||
|
branch_id: int (optional)
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
data = request.get_json() or {}
|
||||||
|
now = datetime.now()
|
||||||
|
year = data.get('year', now.year)
|
||||||
|
month = data.get('month', now.month)
|
||||||
|
branch_id = data.get('branch_id')
|
||||||
|
|
||||||
|
try:
|
||||||
|
year = int(year)
|
||||||
|
month = int(month)
|
||||||
|
if month < 1 or month > 12:
|
||||||
|
return jsonify({'error': 'month must be 1-12'}), 400
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return jsonify({'error': 'year and month must be integers'}), 400
|
||||||
|
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
tenant_config = _get_issuer_config(cur, branch_id)
|
||||||
|
if not tenant_config['rfc']:
|
||||||
|
cur.close(); conn.close()
|
||||||
|
return jsonify({'error': 'Tenant RFC not configured'}), 400
|
||||||
|
|
||||||
|
from services.global_invoice import generate_global_invoice
|
||||||
|
result = generate_global_invoice(
|
||||||
|
conn, tenant_config, year, month,
|
||||||
|
branch_id=branch_id,
|
||||||
|
employee_id=getattr(g, 'employee_id', None)
|
||||||
|
)
|
||||||
|
|
||||||
|
if 'error' in result:
|
||||||
|
cur.close(); conn.close()
|
||||||
|
return jsonify(result), 400
|
||||||
|
|
||||||
|
log_action(conn, 'GLOBAL_INVOICE_CREATE', 'cfdi_queue', result['id'],
|
||||||
|
new_value={'year': year, 'month': month, 'sales_count': result['sales_count']})
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return jsonify(result), 201
|
||||||
|
|
||||||
|
|
||||||
|
@invoicing_bp.route('/global-invoice/<int:cfdi_id>', methods=['GET'])
|
||||||
|
@require_auth('invoicing.view')
|
||||||
|
def get_global_invoice(cfdi_id):
|
||||||
|
"""Get status and linked sales of a global invoice."""
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
from services.global_invoice import get_global_invoice_status
|
||||||
|
result = get_global_invoice_status(conn, cfdi_id)
|
||||||
|
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if not result:
|
||||||
|
return jsonify({'error': 'Global invoice not found'}), 404
|
||||||
|
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
|
@invoicing_bp.route('/global-invoice/eligible-sales', methods=['GET'])
|
||||||
|
@require_auth('invoicing.view')
|
||||||
|
def get_eligible_sales_for_global():
|
||||||
|
"""Preview sales that would be included in a global invoice.
|
||||||
|
|
||||||
|
Query params: year, month, branch_id
|
||||||
|
"""
|
||||||
|
now = datetime.now()
|
||||||
|
year = request.args.get('year', now.year, type=int)
|
||||||
|
month = request.args.get('month', now.month, type=int)
|
||||||
|
branch_id = request.args.get('branch_id', type=int)
|
||||||
|
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
|
||||||
|
from services.global_invoice import get_eligible_sales
|
||||||
|
sales = get_eligible_sales(conn, year, month, branch_id)
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'year': year, 'month': month,
|
||||||
|
'count': len(sales),
|
||||||
|
'total': sum(s['total'] for s in sales),
|
||||||
|
'sales': [{'id': s['id'], 'total': s['total'], 'created_at': s['created_at']} for s in sales],
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Facturapi extras ───────────────────────────────
|
||||||
|
|
||||||
|
@invoicing_bp.route('/facturapi/status', methods=['GET'])
|
||||||
|
@require_auth('invoicing.view')
|
||||||
|
def facturapi_status():
|
||||||
|
"""Return Facturapi organization status for the tenant."""
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
cur = conn.cursor()
|
||||||
|
tenant_config = _get_issuer_config(cur)
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
status = facturapi_service.get_org_status(tenant_config)
|
||||||
|
return jsonify(status)
|
||||||
|
|
||||||
|
|
||||||
|
@invoicing_bp.route('/facturapi/setup', methods=['POST'])
|
||||||
|
@require_auth('invoicing.create')
|
||||||
|
def facturapi_setup():
|
||||||
|
"""Create or link a Facturapi organization for this tenant.
|
||||||
|
|
||||||
|
Requires FACTURAPI_USER_KEY environment variable.
|
||||||
|
Stores cfdi_facturapi_org_id and cfdi_facturapi_key in tenant_config.
|
||||||
|
"""
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
tenant_config = _get_issuer_config(cur)
|
||||||
|
if not tenant_config.get('rfc'):
|
||||||
|
return jsonify({'error': 'Tenant RFC not configured'}), 400
|
||||||
|
|
||||||
|
result = facturapi_service.create_organization(tenant_config)
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO tenant_config (key, value)
|
||||||
|
VALUES ('cfdi_facturapi_org_id', %s)
|
||||||
|
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
||||||
|
""", (result['org_id'],))
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO tenant_config (key, value)
|
||||||
|
VALUES ('cfdi_facturapi_key', %s)
|
||||||
|
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
||||||
|
""", (result['api_key'],))
|
||||||
|
|
||||||
|
log_action(conn, 'FACTURAPI_SETUP', 'tenant_config', None,
|
||||||
|
new_value={'org_id': result['org_id']})
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'org_id': result['org_id'],
|
||||||
|
'message': 'Facturapi organization created. Complete pending steps in Facturapi dashboard.',
|
||||||
|
})
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
conn.rollback()
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
return jsonify({'error': str(e)}), 400
|
||||||
|
except Exception as e:
|
||||||
|
conn.rollback()
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@invoicing_bp.route('/facturapi/download/<int:cfdi_id>/<doc_type>', methods=['GET'])
|
||||||
|
@require_auth('invoicing.view')
|
||||||
|
def facturapi_download(cfdi_id, doc_type):
|
||||||
|
"""Download PDF or XML for a stamped CFDI from Facturapi.
|
||||||
|
|
||||||
|
doc_type: 'pdf' | 'xml'
|
||||||
|
"""
|
||||||
|
if doc_type not in ('pdf', 'xml'):
|
||||||
|
return jsonify({'error': "doc_type must be 'pdf' or 'xml'"}), 400
|
||||||
|
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
SELECT external_id, uuid_fiscal, status FROM cfdi_queue WHERE id = %s
|
||||||
|
""", (cfdi_id,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
cur.close(); conn.close()
|
||||||
|
return jsonify({'error': 'CFDI not found'}), 404
|
||||||
|
|
||||||
|
external_id, uuid_fiscal, status = row
|
||||||
|
if status != 'stamped' or not external_id:
|
||||||
|
cur.close(); conn.close()
|
||||||
|
return jsonify({'error': 'CFDI is not stamped or has no external id'}), 400
|
||||||
|
|
||||||
|
tenant_config = _get_issuer_config(cur)
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
try:
|
||||||
|
if doc_type == 'pdf':
|
||||||
|
content = facturapi_service.download_pdf(tenant_config, external_id)
|
||||||
|
mime = 'application/pdf'
|
||||||
|
filename = f'cfdi_{uuid_fiscal or external_id}.pdf'
|
||||||
|
else:
|
||||||
|
content = facturapi_service.download_xml(tenant_config, external_id)
|
||||||
|
mime = 'application/xml'
|
||||||
|
filename = f'cfdi_{uuid_fiscal or external_id}.xml'
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
from flask import Response
|
||||||
|
return Response(
|
||||||
|
content,
|
||||||
|
mimetype=mime,
|
||||||
|
headers={'Content-Disposition': f'attachment; filename="{filename}"'},
|
||||||
|
)
|
||||||
|
|||||||
@@ -190,6 +190,16 @@ def bodegas_with_part(part_id):
|
|||||||
return _with_master(_do)
|
return _with_master(_do)
|
||||||
|
|
||||||
|
|
||||||
|
@marketplace_bp.route('/inventory/listing/<int:wi_id>', methods=['GET'])
|
||||||
|
@require_auth()
|
||||||
|
def bodegas_with_listing(wi_id):
|
||||||
|
"""Return bodegas stocking a specific seller listing (wi_id)."""
|
||||||
|
def _do(master):
|
||||||
|
data = mkt.get_bodegas_with_listing(master, wi_id)
|
||||||
|
return jsonify({'data': data, 'count': len(data)})
|
||||||
|
return _with_master(_do)
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
# PURCHASE ORDERS
|
# PURCHASE ORDERS
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
703
pos/blueprints/marketplace_external_bp.py
Normal file
703
pos/blueprints/marketplace_external_bp.py
Normal file
@@ -0,0 +1,703 @@
|
|||||||
|
"""MercadoLibre external marketplace REST endpoints.
|
||||||
|
|
||||||
|
Routes:
|
||||||
|
Config
|
||||||
|
GET /pos/api/marketplace-ext/config
|
||||||
|
POST /pos/api/marketplace-ext/connect
|
||||||
|
DELETE /pos/api/marketplace-ext/connect
|
||||||
|
GET /pos/api/marketplace-ext/categories
|
||||||
|
|
||||||
|
Listings
|
||||||
|
GET /pos/api/marketplace-ext/listings
|
||||||
|
POST /pos/api/marketplace-ext/listings
|
||||||
|
POST /pos/api/marketplace-ext/listings/<id>/sync
|
||||||
|
POST /pos/api/marketplace-ext/listings/<id>/pause
|
||||||
|
POST /pos/api/marketplace-ext/listings/<id>/activate
|
||||||
|
DELETE /pos/api/marketplace-ext/listings/<id>
|
||||||
|
|
||||||
|
Orders
|
||||||
|
GET /pos/api/marketplace-ext/orders
|
||||||
|
GET /pos/api/marketplace-ext/orders/<id>
|
||||||
|
POST /pos/api/marketplace-ext/orders/<id>/convert
|
||||||
|
|
||||||
|
Webhook (public)
|
||||||
|
POST /pos/api/marketplace-ext/webhook/meli
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import Blueprint, request, jsonify, g
|
||||||
|
from middleware import require_auth, has_permission
|
||||||
|
from tenant_db import get_tenant_conn, get_master_conn
|
||||||
|
from services import marketplace_external_service as meli_svc
|
||||||
|
|
||||||
|
|
||||||
|
def _get_public_base_url() -> str:
|
||||||
|
"""Build the tenant's public base URL from request headers (handles reverse proxy)."""
|
||||||
|
proto = request.headers.get("X-Forwarded-Proto", request.scheme)
|
||||||
|
host = request.headers.get("X-Forwarded-Host", request.host)
|
||||||
|
|
||||||
|
# Cloudflare specific header
|
||||||
|
cf_visitor = request.headers.get("CF-Visitor")
|
||||||
|
if cf_visitor and '"scheme":"https"' in cf_visitor:
|
||||||
|
proto = "https"
|
||||||
|
|
||||||
|
# Force https for production domain if we detect http behind a TLS terminator
|
||||||
|
if proto == "http" and ("nexusautoparts.com.mx" in host or request.headers.get("X-Forwarded-Ssl") == "on"):
|
||||||
|
proto = "https"
|
||||||
|
|
||||||
|
return f"{proto}://{host}/"
|
||||||
|
from services.meli_service import MeliService, MeliAuthError
|
||||||
|
|
||||||
|
marketplace_ext_bp = Blueprint(
|
||||||
|
"marketplace_ext", __name__, url_prefix="/pos/api/marketplace-ext"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _require_meli_manage():
|
||||||
|
if not has_permission("marketplace.manage"):
|
||||||
|
return jsonify({"error": "Missing permission: marketplace.manage"}), 403
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# CONFIG
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
@marketplace_ext_bp.route("/config", methods=["GET"])
|
||||||
|
@require_auth()
|
||||||
|
def get_config():
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
try:
|
||||||
|
cfg = meli_svc.get_meli_config(conn)
|
||||||
|
# Never return tokens to frontend
|
||||||
|
safe = {
|
||||||
|
k: v for k, v in cfg.items()
|
||||||
|
if k not in ("meli_access_token", "meli_refresh_token", "meli_client_secret")
|
||||||
|
}
|
||||||
|
safe["connected"] = bool(cfg.get("meli_access_token"))
|
||||||
|
return jsonify(safe)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@marketplace_ext_bp.route("/connect", methods=["POST"])
|
||||||
|
@require_auth()
|
||||||
|
def connect_meli():
|
||||||
|
err = _require_meli_manage()
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
|
||||||
|
data = request.get_json() or {}
|
||||||
|
code = data.get("code")
|
||||||
|
client_id = data.get("client_id")
|
||||||
|
client_secret = data.get("client_secret")
|
||||||
|
redirect_uri = data.get("redirect_uri", "")
|
||||||
|
|
||||||
|
if not code or not client_id or not client_secret:
|
||||||
|
return jsonify({"error": "code, client_id and client_secret required"}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
token_data = MeliService.exchange_code(code, client_id, client_secret, redirect_uri)
|
||||||
|
except MeliAuthError as e:
|
||||||
|
return jsonify({"error": str(e)}), 400
|
||||||
|
|
||||||
|
access_token = token_data.get("access_token")
|
||||||
|
refresh_token = token_data.get("refresh_token")
|
||||||
|
user_id = token_data.get("user_id")
|
||||||
|
|
||||||
|
# Validate token by fetching user
|
||||||
|
svc = MeliService(access_token)
|
||||||
|
try:
|
||||||
|
user = svc.get_user()
|
||||||
|
except MeliAuthError as e:
|
||||||
|
return jsonify({"error": f"Invalid token: {e}"}), 400
|
||||||
|
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
try:
|
||||||
|
meli_svc.save_meli_config(conn, {
|
||||||
|
"meli_access_token": access_token,
|
||||||
|
"meli_refresh_token": refresh_token,
|
||||||
|
"meli_user_id": str(user_id or user.get("id")),
|
||||||
|
"meli_site_id": user.get("site_id", "MLM"),
|
||||||
|
"meli_enabled": "true",
|
||||||
|
"meli_client_id": client_id,
|
||||||
|
"meli_client_secret": client_secret,
|
||||||
|
})
|
||||||
|
return jsonify({
|
||||||
|
"ok": True,
|
||||||
|
"user_id": user_id or user.get("id"),
|
||||||
|
"nickname": user.get("nickname"),
|
||||||
|
"site_id": user.get("site_id"),
|
||||||
|
})
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@marketplace_ext_bp.route("/connect", methods=["DELETE"])
|
||||||
|
@require_auth()
|
||||||
|
def disconnect_meli():
|
||||||
|
err = _require_meli_manage()
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
try:
|
||||||
|
meli_svc.delete_meli_config(conn)
|
||||||
|
return jsonify({"ok": True})
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@marketplace_ext_bp.route("/categories", methods=["GET"])
|
||||||
|
@require_auth()
|
||||||
|
def search_categories():
|
||||||
|
q = request.args.get("q", "")
|
||||||
|
site_id = request.args.get("site_id", "MLM")
|
||||||
|
if not q or len(q) < 2:
|
||||||
|
return jsonify({"categories": []})
|
||||||
|
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
try:
|
||||||
|
cfg = meli_svc.get_meli_config(conn)
|
||||||
|
svc = meli_svc._get_meli_service(cfg)
|
||||||
|
if not svc:
|
||||||
|
return jsonify({"error": "MercadoLibre not connected"}), 400
|
||||||
|
result = svc.search_categories(site_id, q)
|
||||||
|
return jsonify({"categories": result})
|
||||||
|
except MeliAuthError:
|
||||||
|
return jsonify({"error": "MercadoLibre token expired. Please reconnect."}), 401
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# LISTINGS
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
@marketplace_ext_bp.route("/listings", methods=["GET"])
|
||||||
|
@require_auth()
|
||||||
|
def list_listings():
|
||||||
|
page = int(request.args.get("page", 1))
|
||||||
|
per_page = min(int(request.args.get("per_page", 50)), 200)
|
||||||
|
status = request.args.get("status")
|
||||||
|
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
try:
|
||||||
|
result = meli_svc.get_listings(conn, page=page, per_page=per_page, status=status)
|
||||||
|
return jsonify(result)
|
||||||
|
except MeliAuthError:
|
||||||
|
return jsonify({"error": "MercadoLibre token expired. Please reconnect."}), 401
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@marketplace_ext_bp.route("/listings", methods=["POST"])
|
||||||
|
@require_auth()
|
||||||
|
def create_listings():
|
||||||
|
err = _require_meli_manage()
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
|
||||||
|
data = request.get_json() or {}
|
||||||
|
inventory_ids = data.get("inventory_ids", [])
|
||||||
|
category_id = data.get("category_id")
|
||||||
|
listing_type = data.get("listing_type", "gold_special")
|
||||||
|
shipping_mode = data.get("shipping_mode", "me2")
|
||||||
|
custom_data = data.get("custom_data", {})
|
||||||
|
|
||||||
|
if not inventory_ids:
|
||||||
|
return jsonify({"error": "inventory_ids required"}), 400
|
||||||
|
if not category_id:
|
||||||
|
return jsonify({"error": "category_id required"}), 400
|
||||||
|
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
try:
|
||||||
|
result = meli_svc.publish_items(
|
||||||
|
conn,
|
||||||
|
inventory_ids=inventory_ids,
|
||||||
|
meli_category_id=category_id,
|
||||||
|
listing_type_id=listing_type,
|
||||||
|
shipping_mode=shipping_mode,
|
||||||
|
custom_data=custom_data,
|
||||||
|
base_url=_get_public_base_url(),
|
||||||
|
)
|
||||||
|
return jsonify(result), 201
|
||||||
|
except ValueError as e:
|
||||||
|
return jsonify({"error": str(e)}), 400
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@marketplace_ext_bp.route("/listings/import-existing", methods=["POST"])
|
||||||
|
@require_auth()
|
||||||
|
def import_existing_listings():
|
||||||
|
"""Import all existing MercadoLibre listings for the connected seller."""
|
||||||
|
err = _require_meli_manage()
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
try:
|
||||||
|
result = meli_svc.import_existing_listings(conn)
|
||||||
|
return jsonify(result), 200
|
||||||
|
except ValueError as e:
|
||||||
|
return jsonify({"error": str(e)}), 400
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@marketplace_ext_bp.route("/inventory-check", methods=["POST"])
|
||||||
|
@require_auth()
|
||||||
|
def inventory_check():
|
||||||
|
"""Check local pre-flight status for ML publishing (duplicates, stock, price, image)."""
|
||||||
|
data = request.get_json() or {}
|
||||||
|
inventory_ids = data.get("inventory_ids", [])
|
||||||
|
if not inventory_ids:
|
||||||
|
return jsonify({"error": "inventory_ids required"}), 400
|
||||||
|
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
try:
|
||||||
|
result = meli_svc.check_inventory_ml_status(conn, inventory_ids, base_url=_get_public_base_url())
|
||||||
|
return jsonify(result)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@marketplace_ext_bp.route("/sync-stock", methods=["POST"])
|
||||||
|
@require_auth()
|
||||||
|
def sync_stock_to_meli():
|
||||||
|
"""Process pending stock updates to MercadoLibre."""
|
||||||
|
err = _require_meli_manage()
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
try:
|
||||||
|
result = meli_svc.process_meli_sync_queue(conn)
|
||||||
|
return jsonify(result)
|
||||||
|
except ValueError as e:
|
||||||
|
return jsonify({"error": str(e)}), 400
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@marketplace_ext_bp.route("/categories/<category_id>/attributes", methods=["GET"])
|
||||||
|
@require_auth()
|
||||||
|
def category_attributes(category_id):
|
||||||
|
"""Get required attributes for a MercadoLibre category."""
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
try:
|
||||||
|
cfg = meli_svc.get_meli_config(conn)
|
||||||
|
svc = meli_svc._get_meli_service(cfg)
|
||||||
|
if not svc:
|
||||||
|
return jsonify({"error": "MercadoLibre not connected"}), 400
|
||||||
|
attrs = svc.get_category_attributes(category_id)
|
||||||
|
# Filter to required attributes only for the UI
|
||||||
|
required = [a for a in attrs if a.get("tags", {}).get("required")]
|
||||||
|
return jsonify({"attributes": required, "all": attrs})
|
||||||
|
except MeliAuthError:
|
||||||
|
return jsonify({"error": "MercadoLibre token expired. Please reconnect."}), 401
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@marketplace_ext_bp.route("/listings/validate", methods=["POST"])
|
||||||
|
@require_auth()
|
||||||
|
def validate_listings():
|
||||||
|
"""Validate items payload against ML /items/validate without creating them."""
|
||||||
|
err = _require_meli_manage()
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
|
||||||
|
data = request.get_json() or {}
|
||||||
|
inventory_ids = data.get("inventory_ids", [])
|
||||||
|
category_id = data.get("category_id")
|
||||||
|
listing_type = data.get("listing_type", "gold_special")
|
||||||
|
shipping_mode = data.get("shipping_mode", "me2")
|
||||||
|
custom_data = data.get("custom_data", {})
|
||||||
|
|
||||||
|
if not inventory_ids:
|
||||||
|
return jsonify({"error": "inventory_ids required"}), 400
|
||||||
|
if not category_id:
|
||||||
|
return jsonify({"error": "category_id required"}), 400
|
||||||
|
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
try:
|
||||||
|
result = meli_svc.validate_items(
|
||||||
|
conn,
|
||||||
|
inventory_ids=inventory_ids,
|
||||||
|
meli_category_id=category_id,
|
||||||
|
listing_type_id=listing_type,
|
||||||
|
shipping_mode=shipping_mode,
|
||||||
|
custom_data=custom_data,
|
||||||
|
base_url=_get_public_base_url(),
|
||||||
|
)
|
||||||
|
return jsonify(result)
|
||||||
|
except ValueError as e:
|
||||||
|
return jsonify({"error": str(e)}), 400
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@marketplace_ext_bp.route("/listings/async", methods=["POST"])
|
||||||
|
@require_auth()
|
||||||
|
def create_listings_async():
|
||||||
|
"""Enqueue ML publishing as a Celery background task."""
|
||||||
|
err = _require_meli_manage()
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
|
||||||
|
data = request.get_json() or {}
|
||||||
|
inventory_ids = data.get("inventory_ids", [])
|
||||||
|
category_id = data.get("category_id")
|
||||||
|
listing_type = data.get("listing_type", "gold_special")
|
||||||
|
shipping_mode = data.get("shipping_mode", "me2")
|
||||||
|
custom_data = data.get("custom_data", {})
|
||||||
|
|
||||||
|
if not inventory_ids:
|
||||||
|
return jsonify({"error": "inventory_ids required"}), 400
|
||||||
|
if not category_id:
|
||||||
|
return jsonify({"error": "category_id required"}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
from tasks import publish_meli_items_task
|
||||||
|
task = publish_meli_items_task.delay(
|
||||||
|
g.tenant_id,
|
||||||
|
inventory_ids=inventory_ids,
|
||||||
|
category_id=category_id,
|
||||||
|
listing_type=listing_type,
|
||||||
|
shipping_mode=shipping_mode,
|
||||||
|
custom_data=custom_data,
|
||||||
|
base_url=_get_public_base_url(),
|
||||||
|
)
|
||||||
|
return jsonify({"task_id": task.id, "status": "queued"}), 202
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@marketplace_ext_bp.route("/listings/async/<task_id>", methods=["GET"])
|
||||||
|
@require_auth()
|
||||||
|
def get_async_listing_status(task_id):
|
||||||
|
"""Get status of an async ML publishing task."""
|
||||||
|
try:
|
||||||
|
from celery.result import AsyncResult
|
||||||
|
from app import celery as celery_app
|
||||||
|
result = AsyncResult(task_id, app=celery_app)
|
||||||
|
if result.ready():
|
||||||
|
return jsonify({"status": "done", "result": result.result or {}})
|
||||||
|
return jsonify({"status": "pending"})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@marketplace_ext_bp.route("/listings/<int:listing_id>/sync", methods=["POST"])
|
||||||
|
@require_auth()
|
||||||
|
def sync_listing(listing_id):
|
||||||
|
err = _require_meli_manage()
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
try:
|
||||||
|
result = meli_svc.sync_listing(conn, listing_id)
|
||||||
|
return jsonify(result)
|
||||||
|
except ValueError as e:
|
||||||
|
return jsonify({"error": str(e)}), 400
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@marketplace_ext_bp.route("/listings/<int:listing_id>/pause", methods=["POST"])
|
||||||
|
@require_auth()
|
||||||
|
def pause_listing(listing_id):
|
||||||
|
err = _require_meli_manage()
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
try:
|
||||||
|
result = meli_svc.pause_listing(conn, listing_id)
|
||||||
|
return jsonify(result)
|
||||||
|
except ValueError as e:
|
||||||
|
return jsonify({"error": str(e)}), 400
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@marketplace_ext_bp.route("/listings/<int:listing_id>/activate", methods=["POST"])
|
||||||
|
@require_auth()
|
||||||
|
def activate_listing(listing_id):
|
||||||
|
err = _require_meli_manage()
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
try:
|
||||||
|
result = meli_svc.activate_listing(conn, listing_id)
|
||||||
|
return jsonify(result)
|
||||||
|
except ValueError as e:
|
||||||
|
return jsonify({"error": str(e)}), 400
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@marketplace_ext_bp.route("/listings/<int:listing_id>", methods=["DELETE"])
|
||||||
|
@require_auth()
|
||||||
|
def delete_listing(listing_id):
|
||||||
|
err = _require_meli_manage()
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
try:
|
||||||
|
result = meli_svc.close_listing(conn, listing_id)
|
||||||
|
return jsonify(result)
|
||||||
|
except ValueError as e:
|
||||||
|
return jsonify({"error": str(e)}), 400
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@marketplace_ext_bp.route("/listings/<int:listing_id>/permanent", methods=["DELETE"])
|
||||||
|
@require_auth()
|
||||||
|
def delete_listing_permanent(listing_id):
|
||||||
|
"""Hard-delete a closed listing from the local DB."""
|
||||||
|
err = _require_meli_manage()
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
try:
|
||||||
|
result = meli_svc.delete_listing_permanently(conn, listing_id)
|
||||||
|
return jsonify(result)
|
||||||
|
except ValueError as e:
|
||||||
|
return jsonify({"error": str(e)}), 400
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# QUESTIONS & ANSWERS
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
@marketplace_ext_bp.route("/questions", methods=["GET"])
|
||||||
|
@require_auth()
|
||||||
|
def list_questions():
|
||||||
|
"""List questions from local DB. Query param: ?status=unanswered"""
|
||||||
|
status = request.args.get("status")
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
try:
|
||||||
|
items = meli_svc.list_local_questions(conn, status=status)
|
||||||
|
return jsonify({"items": items})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@marketplace_ext_bp.route("/questions/sync", methods=["POST"])
|
||||||
|
@require_auth()
|
||||||
|
def sync_questions():
|
||||||
|
"""Force sync questions from ML for all active listings."""
|
||||||
|
err = _require_meli_manage()
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
try:
|
||||||
|
result = meli_svc.sync_questions(conn)
|
||||||
|
return jsonify(result)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@marketplace_ext_bp.route("/questions/<int:question_id>/answer", methods=["POST"])
|
||||||
|
@require_auth()
|
||||||
|
def answer_question(question_id):
|
||||||
|
"""Answer a buyer question via ML API."""
|
||||||
|
err = _require_meli_manage()
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
data = request.get_json() or {}
|
||||||
|
text = data.get("text", "").strip()
|
||||||
|
if not text:
|
||||||
|
return jsonify({"error": "Answer text is required"}), 400
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
try:
|
||||||
|
result = meli_svc.answer_question(conn, question_id, text)
|
||||||
|
return jsonify(result)
|
||||||
|
except ValueError as e:
|
||||||
|
return jsonify({"error": str(e)}), 400
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# ORDERS
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
@marketplace_ext_bp.route("/orders/sync", methods=["POST"])
|
||||||
|
@require_auth()
|
||||||
|
def sync_orders():
|
||||||
|
"""Manually trigger sync of MercadoLibre orders."""
|
||||||
|
err = _require_meli_manage()
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
try:
|
||||||
|
result = meli_svc.fetch_and_save_orders(conn)
|
||||||
|
return jsonify(result)
|
||||||
|
except ValueError as e:
|
||||||
|
return jsonify({"error": str(e)}), 400
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@marketplace_ext_bp.route("/orders", methods=["GET"])
|
||||||
|
@require_auth()
|
||||||
|
def list_orders():
|
||||||
|
page = int(request.args.get("page", 1))
|
||||||
|
per_page = min(int(request.args.get("per_page", 50)), 200)
|
||||||
|
status = request.args.get("status")
|
||||||
|
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
try:
|
||||||
|
result = meli_svc.get_orders(conn, page=page, per_page=per_page, status=status)
|
||||||
|
return jsonify(result)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@marketplace_ext_bp.route("/orders/<int:order_id>", methods=["GET"])
|
||||||
|
@require_auth()
|
||||||
|
def get_order(order_id):
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
try:
|
||||||
|
result = meli_svc.get_order_detail(conn, order_id)
|
||||||
|
return jsonify(result)
|
||||||
|
except ValueError as e:
|
||||||
|
return jsonify({"error": str(e)}), 404
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@marketplace_ext_bp.route("/orders/<int:order_id>/convert", methods=["POST"])
|
||||||
|
@require_auth("pos.sell")
|
||||||
|
def convert_order(order_id):
|
||||||
|
data = request.get_json() or {}
|
||||||
|
register_id = data.get("register_id")
|
||||||
|
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
try:
|
||||||
|
result = meli_svc.convert_order_to_sale(
|
||||||
|
conn, order_id, employee_id=g.employee_id, register_id=register_id
|
||||||
|
)
|
||||||
|
return jsonify(result)
|
||||||
|
except ValueError as e:
|
||||||
|
return jsonify({"error": str(e)}), 400
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@marketplace_ext_bp.route("/orders/<int:order_id>/status", methods=["POST"])
|
||||||
|
@require_auth()
|
||||||
|
def update_order_status_route(order_id):
|
||||||
|
data = request.get_json() or {}
|
||||||
|
new_status = data.get("status")
|
||||||
|
if not new_status:
|
||||||
|
return jsonify({"error": "status required"}), 400
|
||||||
|
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
try:
|
||||||
|
result = meli_svc.update_order_status(conn, order_id, new_status)
|
||||||
|
return jsonify(result)
|
||||||
|
except ValueError as e:
|
||||||
|
return jsonify({"error": str(e)}), 400
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# WEBHOOK (public — no auth)
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
@marketplace_ext_bp.route("/webhook/meli", methods=["POST"])
|
||||||
|
def meli_webhook():
|
||||||
|
"""Receive MercadoLibre notifications.
|
||||||
|
|
||||||
|
ML sends a lightweight payload with topic + resource URL.
|
||||||
|
We ack immediately and enqueue Celery for async processing.
|
||||||
|
"""
|
||||||
|
data = request.get_json(force=True, silent=True) or {}
|
||||||
|
topic = data.get("topic", "")
|
||||||
|
resource = data.get("resource", "")
|
||||||
|
user_id = data.get("user_id")
|
||||||
|
|
||||||
|
# Resolve tenant by meli_user_id
|
||||||
|
tenant_id = None
|
||||||
|
if user_id:
|
||||||
|
try:
|
||||||
|
mconn = get_master_conn()
|
||||||
|
mcur = mconn.cursor()
|
||||||
|
mcur.execute(
|
||||||
|
"""
|
||||||
|
SELECT t.id FROM tenants t
|
||||||
|
JOIN tenant_config c ON c.key = 'meli_user_id' AND c.value = %s
|
||||||
|
WHERE t.is_active = true
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(str(user_id),),
|
||||||
|
)
|
||||||
|
row = mcur.fetchone()
|
||||||
|
if row:
|
||||||
|
tenant_id = row[0]
|
||||||
|
mcur.close()
|
||||||
|
mconn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if tenant_id and topic:
|
||||||
|
try:
|
||||||
|
from tasks import process_meli_webhook_task
|
||||||
|
process_meli_webhook_task.delay(tenant_id, topic, resource)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ML Webhook] Failed to enqueue task: {e}")
|
||||||
|
|
||||||
|
return jsonify({"ok": True})
|
||||||
@@ -15,6 +15,7 @@ from services.pos_engine import (
|
|||||||
process_sale, cancel_sale, calculate_totals,
|
process_sale, cancel_sale, calculate_totals,
|
||||||
get_price_for_customer, get_margin_info
|
get_price_for_customer, get_margin_info
|
||||||
)
|
)
|
||||||
|
from services.inventory_engine import get_stock
|
||||||
from services.audit import log_action
|
from services.audit import log_action
|
||||||
from config import JWT_SECRET
|
from config import JWT_SECRET
|
||||||
|
|
||||||
@@ -34,7 +35,7 @@ def _enrich_items(cur, items, customer_id=None):
|
|||||||
# Batch fetch all inventory items in one query
|
# Batch fetch all inventory items in one query
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT id, part_number, name, cost, price_1, price_2, price_3,
|
SELECT id, part_number, name, cost, price_1, price_2, price_3,
|
||||||
tax_rate, branch_id
|
tax_rate
|
||||||
FROM inventory WHERE id = ANY(%s) AND is_active = true
|
FROM inventory WHERE id = ANY(%s) AND is_active = true
|
||||||
""", (inv_ids,))
|
""", (inv_ids,))
|
||||||
inv_map = {r[0]: r for r in cur.fetchall()}
|
inv_map = {r[0]: r for r in cur.fetchall()}
|
||||||
@@ -75,7 +76,6 @@ def _enrich_items(cur, items, customer_id=None):
|
|||||||
'unit_cost': float(inv[3]) if inv[3] else 0,
|
'unit_cost': float(inv[3]) if inv[3] else 0,
|
||||||
'discount_pct': discount_pct,
|
'discount_pct': discount_pct,
|
||||||
'tax_rate': tax_rate,
|
'tax_rate': tax_rate,
|
||||||
'branch_id': inv[8],
|
|
||||||
})
|
})
|
||||||
return enriched
|
return enriched
|
||||||
|
|
||||||
@@ -103,6 +103,19 @@ def create_sale():
|
|||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
|
||||||
|
# Verify stock availability per item for the active branch
|
||||||
|
branch_id = data.get('branch_id', g.branch_id)
|
||||||
|
for item in data.get('items', []):
|
||||||
|
inv_id = item.get('inventory_id')
|
||||||
|
qty = int(item.get('quantity', 1))
|
||||||
|
if inv_id:
|
||||||
|
available = get_stock(conn, inv_id, branch_id)
|
||||||
|
if available < qty:
|
||||||
|
conn.close()
|
||||||
|
return jsonify({
|
||||||
|
'error': f'Insufficient stock for item {inv_id}. Available: {available}, requested: {qty}'
|
||||||
|
}), 400
|
||||||
|
|
||||||
try:
|
try:
|
||||||
sale = process_sale(conn, data)
|
sale = process_sale(conn, data)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
@@ -219,6 +232,83 @@ def list_sales():
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@pos_bp.route('/historical-sales', methods=['GET'])
|
||||||
|
@require_auth('pos.view')
|
||||||
|
def list_historical_sales():
|
||||||
|
"""List imported historical sales (read-only reference).
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
date_from: YYYY-MM-DD
|
||||||
|
date_to: YYYY-MM-DD
|
||||||
|
customer: partial customer name
|
||||||
|
page: int (default 1)
|
||||||
|
per_page: int (default 50, max 200)
|
||||||
|
"""
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
page = int(request.args.get('page', 1))
|
||||||
|
per_page = min(int(request.args.get('per_page', 50)), 200)
|
||||||
|
|
||||||
|
where_clauses = ["1=1"]
|
||||||
|
params = []
|
||||||
|
|
||||||
|
date_from = request.args.get('date_from')
|
||||||
|
date_to = request.args.get('date_to')
|
||||||
|
customer = request.args.get('customer')
|
||||||
|
|
||||||
|
if date_from:
|
||||||
|
where_clauses.append("sale_date >= %s")
|
||||||
|
params.append(date_from)
|
||||||
|
if date_to:
|
||||||
|
where_clauses.append("sale_date <= %s")
|
||||||
|
params.append(date_to)
|
||||||
|
if customer:
|
||||||
|
where_clauses.append("customer_name ILIKE %s")
|
||||||
|
params.append(f"%{customer}%")
|
||||||
|
|
||||||
|
where = " AND ".join(where_clauses)
|
||||||
|
|
||||||
|
cur.execute(f"SELECT count(*) FROM historical_sales WHERE {where}", params)
|
||||||
|
total = cur.fetchone()[0]
|
||||||
|
|
||||||
|
cur.execute(f"""
|
||||||
|
SELECT id, external_document_id, document_no, sale_date, customer_name,
|
||||||
|
total, subtotal, amount_paid, payment_method, discount, balance,
|
||||||
|
raw_payment_code
|
||||||
|
FROM historical_sales
|
||||||
|
WHERE {where}
|
||||||
|
ORDER BY sale_date DESC, id DESC
|
||||||
|
LIMIT %s OFFSET %s
|
||||||
|
""", params + [per_page, (page - 1) * per_page])
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for r in cur.fetchall():
|
||||||
|
rows.append({
|
||||||
|
'id': r[0],
|
||||||
|
'external_document_id': r[1],
|
||||||
|
'document_no': r[2],
|
||||||
|
'sale_date': str(r[3]) if r[3] else None,
|
||||||
|
'customer_name': r[4],
|
||||||
|
'total': float(r[5]) if r[5] else 0,
|
||||||
|
'subtotal': float(r[6]) if r[6] else 0,
|
||||||
|
'amount_paid': float(r[7]) if r[7] else 0,
|
||||||
|
'payment_method': r[8],
|
||||||
|
'discount': float(r[9]) if r[9] else 0,
|
||||||
|
'balance': float(r[10]) if r[10] else 0,
|
||||||
|
'raw_payment_code': r[11],
|
||||||
|
})
|
||||||
|
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
total_pages = (total + per_page - 1) // per_page
|
||||||
|
return jsonify({
|
||||||
|
'data': rows,
|
||||||
|
'pagination': {'page': page, 'per_page': per_page, 'total': total, 'total_pages': total_pages}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@pos_bp.route('/sales/<int:sale_id>', methods=['GET'])
|
@pos_bp.route('/sales/<int:sale_id>', methods=['GET'])
|
||||||
@require_auth('pos.view')
|
@require_auth('pos.view')
|
||||||
def get_sale(sale_id):
|
def get_sale(sale_id):
|
||||||
@@ -1864,6 +1954,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
|
||||||
|
|
||||||
|
|||||||
538
pos/blueprints/supplier_catalog_bp.py
Normal file
538
pos/blueprints/supplier_catalog_bp.py
Normal file
@@ -0,0 +1,538 @@
|
|||||||
|
"""Supplier Catalog Blueprint — parts from suppliers with vehicle compatibility.
|
||||||
|
|
||||||
|
Independent from inventory. Supports:
|
||||||
|
- Browse by supplier/category
|
||||||
|
- Search by text or vehicle (MYE or make/model/year)
|
||||||
|
- Part detail with compatibilities and interchanges
|
||||||
|
- Bulk import via Excel
|
||||||
|
"""
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
from datetime import date
|
||||||
|
from flask import Blueprint, request, jsonify, g, render_template
|
||||||
|
from psycopg2.extras import RealDictCursor
|
||||||
|
|
||||||
|
from tenant_db import get_master_conn
|
||||||
|
from middleware import require_auth
|
||||||
|
|
||||||
|
supplier_catalog_bp = Blueprint('supplier_catalog', __name__, url_prefix='/pos/api/supplier-catalog')
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _get_master_conn():
|
||||||
|
return get_master_conn()
|
||||||
|
|
||||||
|
|
||||||
|
def _json_response(data, status=200):
|
||||||
|
return jsonify(data), status
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Brands ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@supplier_catalog_bp.route('/brands', methods=['GET'])
|
||||||
|
@require_auth('catalog.view')
|
||||||
|
def list_brands():
|
||||||
|
"""Return distinct makes (vehicle brands) present in the supplier catalog."""
|
||||||
|
conn = _get_master_conn()
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
SELECT DISTINCT make, COUNT(*) as cnt
|
||||||
|
FROM supplier_catalog_compat
|
||||||
|
WHERE make IS NOT NULL AND make != ''
|
||||||
|
GROUP BY make
|
||||||
|
ORDER BY make ASC
|
||||||
|
""")
|
||||||
|
rows = cur.fetchall()
|
||||||
|
cur.close(); conn.close()
|
||||||
|
return jsonify({'brands': [{'name': r[0], 'count': r[1]} for r in rows]})
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Search ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@supplier_catalog_bp.route('/search', methods=['GET'])
|
||||||
|
@require_auth('catalog.view')
|
||||||
|
def search_items():
|
||||||
|
"""Search supplier catalog by text and/or vehicle."""
|
||||||
|
q = (request.args.get('q') or '').strip()
|
||||||
|
mye_id = request.args.get('mye_id', type=int)
|
||||||
|
make = (request.args.get('make') or '').strip()
|
||||||
|
model = (request.args.get('model') or '').strip()
|
||||||
|
year = request.args.get('year', type=int)
|
||||||
|
supplier = (request.args.get('supplier') or '').strip()
|
||||||
|
category = (request.args.get('category') or '').strip()
|
||||||
|
page = max(1, request.args.get('page', 1, type=int))
|
||||||
|
per_page = min(100, request.args.get('per_page', 30, type=int))
|
||||||
|
offset = (page - 1) * per_page
|
||||||
|
|
||||||
|
conn = _get_master_conn()
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
# Build query dynamically
|
||||||
|
where_parts = ["sc.is_active = true"]
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if supplier:
|
||||||
|
where_parts.append("sc.supplier_name = %s")
|
||||||
|
params.append(supplier)
|
||||||
|
if category:
|
||||||
|
where_parts.append("sc.category = %s")
|
||||||
|
params.append(category)
|
||||||
|
|
||||||
|
# Text search on SKU, name, or interchange part_number
|
||||||
|
if q:
|
||||||
|
where_parts.append("""
|
||||||
|
(sc.sku ILIKE %s OR sc.name ILIKE %s
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM supplier_catalog_interchange sci2
|
||||||
|
WHERE sci2.catalog_id = sc.id AND sci2.part_number ILIKE %s
|
||||||
|
))
|
||||||
|
""")
|
||||||
|
like_q = f'%{q}%'
|
||||||
|
params.extend([like_q, like_q, like_q])
|
||||||
|
|
||||||
|
# Vehicle filter
|
||||||
|
vehicle_join = ""
|
||||||
|
if mye_id:
|
||||||
|
vehicle_join = "JOIN supplier_catalog_compat scc ON scc.catalog_id = sc.id"
|
||||||
|
where_parts.append("scc.model_year_engine_id = %s")
|
||||||
|
params.append(mye_id)
|
||||||
|
elif make or model or year:
|
||||||
|
vehicle_join = "JOIN supplier_catalog_compat scc ON scc.catalog_id = sc.id"
|
||||||
|
if make:
|
||||||
|
where_parts.append("scc.make ILIKE %s")
|
||||||
|
params.append(f'%{make}%')
|
||||||
|
if model:
|
||||||
|
where_parts.append("scc.model ILIKE %s")
|
||||||
|
params.append(f'%{model}%')
|
||||||
|
if year:
|
||||||
|
where_parts.append("scc.year = %s")
|
||||||
|
params.append(year)
|
||||||
|
|
||||||
|
where_sql = " AND ".join(where_parts)
|
||||||
|
|
||||||
|
# Count total
|
||||||
|
count_sql = f"""
|
||||||
|
SELECT COUNT(DISTINCT sc.id)
|
||||||
|
FROM supplier_catalog sc
|
||||||
|
{vehicle_join}
|
||||||
|
WHERE {where_sql}
|
||||||
|
"""
|
||||||
|
cur.execute(count_sql, params)
|
||||||
|
total = cur.fetchone()[0]
|
||||||
|
|
||||||
|
# Fetch page
|
||||||
|
fetch_sql = f"""
|
||||||
|
SELECT DISTINCT
|
||||||
|
sc.id, sc.supplier_name, sc.sku, sc.name,
|
||||||
|
sc.category, sc.description, sc.image_url
|
||||||
|
FROM supplier_catalog sc
|
||||||
|
{vehicle_join}
|
||||||
|
WHERE {where_sql}
|
||||||
|
ORDER BY sc.name ASC
|
||||||
|
LIMIT %s OFFSET %s
|
||||||
|
"""
|
||||||
|
cur.execute(fetch_sql, params + [per_page, offset])
|
||||||
|
rows = cur.fetchall()
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for r in rows:
|
||||||
|
items.append({
|
||||||
|
'id': r[0],
|
||||||
|
'supplier_name': r[1],
|
||||||
|
'sku': r[2],
|
||||||
|
'name': r[3],
|
||||||
|
'category': r[4],
|
||||||
|
'description': r[5],
|
||||||
|
'image_url': r[6],
|
||||||
|
})
|
||||||
|
|
||||||
|
cur.close(); conn.close()
|
||||||
|
return jsonify({
|
||||||
|
'data': items,
|
||||||
|
'pagination': {
|
||||||
|
'page': page,
|
||||||
|
'per_page': per_page,
|
||||||
|
'total': total,
|
||||||
|
'total_pages': (total + per_page - 1) // per_page,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Item Detail ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@supplier_catalog_bp.route('/items/<int:item_id>', methods=['GET'])
|
||||||
|
@require_auth('catalog.view')
|
||||||
|
def get_item_detail(item_id):
|
||||||
|
"""Return full detail for a supplier catalog item including compat + interchanges."""
|
||||||
|
conn = _get_master_conn()
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id, supplier_name, sku, name, category, description, image_url, created_at
|
||||||
|
FROM supplier_catalog WHERE id = %s AND is_active = true
|
||||||
|
""", (item_id,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
cur.close(); conn.close()
|
||||||
|
return jsonify({'error': 'Item not found'}), 404
|
||||||
|
|
||||||
|
item = {
|
||||||
|
'id': row[0],
|
||||||
|
'supplier_name': row[1],
|
||||||
|
'sku': row[2],
|
||||||
|
'name': row[3],
|
||||||
|
'category': row[4],
|
||||||
|
'description': row[5],
|
||||||
|
'image_url': row[6],
|
||||||
|
'created_at': str(row[7]) if row[7] else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Compatibilities — deduplicate by (make, model, year, engine) because
|
||||||
|
# the same vehicle may map to multiple MYE ids (especially when engine
|
||||||
|
# text is empty from the supplier catalog).
|
||||||
|
cur.execute("""
|
||||||
|
SELECT make, model, year, engine, model_year_engine_id, source
|
||||||
|
FROM supplier_catalog_compat
|
||||||
|
WHERE catalog_id = %s
|
||||||
|
ORDER BY make, model, year, engine
|
||||||
|
""", (item_id,))
|
||||||
|
seen_compat = set()
|
||||||
|
compatibilities = []
|
||||||
|
for r in cur.fetchall():
|
||||||
|
key = (r[0], r[1], r[2], r[3])
|
||||||
|
if key in seen_compat:
|
||||||
|
continue
|
||||||
|
seen_compat.add(key)
|
||||||
|
compatibilities.append({
|
||||||
|
'make': r[0], 'model': r[1], 'year': r[2], 'engine': r[3],
|
||||||
|
'model_year_engine_id': r[4], 'source': r[5]
|
||||||
|
})
|
||||||
|
item['compatibilities'] = compatibilities
|
||||||
|
|
||||||
|
# Interchanges
|
||||||
|
cur.execute("""
|
||||||
|
SELECT brand, part_number
|
||||||
|
FROM supplier_catalog_interchange
|
||||||
|
WHERE catalog_id = %s
|
||||||
|
ORDER BY brand, part_number
|
||||||
|
""", (item_id,))
|
||||||
|
item['interchanges'] = [
|
||||||
|
{'brand': r[0], 'part_number': r[1]}
|
||||||
|
for r in cur.fetchall()
|
||||||
|
]
|
||||||
|
|
||||||
|
cur.close(); conn.close()
|
||||||
|
return jsonify(item)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Categories ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@supplier_catalog_bp.route('/categories', methods=['GET'])
|
||||||
|
@require_auth('catalog.view')
|
||||||
|
def list_categories():
|
||||||
|
"""Return distinct categories with counts."""
|
||||||
|
conn = _get_master_conn()
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
SELECT category, COUNT(*) as cnt
|
||||||
|
FROM supplier_catalog
|
||||||
|
WHERE is_active = true
|
||||||
|
GROUP BY category
|
||||||
|
ORDER BY cnt DESC
|
||||||
|
""")
|
||||||
|
rows = cur.fetchall()
|
||||||
|
cur.close(); conn.close()
|
||||||
|
return jsonify({'categories': [{'name': r[0], 'count': r[1]} for r in rows]})
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Suppliers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@supplier_catalog_bp.route('/suppliers', methods=['GET'])
|
||||||
|
@require_auth('catalog.view')
|
||||||
|
def list_suppliers():
|
||||||
|
"""Return distinct suppliers with counts."""
|
||||||
|
conn = _get_master_conn()
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
SELECT supplier_name, COUNT(*) as cnt
|
||||||
|
FROM supplier_catalog
|
||||||
|
WHERE is_active = true
|
||||||
|
GROUP BY supplier_name
|
||||||
|
ORDER BY supplier_name ASC
|
||||||
|
""")
|
||||||
|
rows = cur.fetchall()
|
||||||
|
cur.close(); conn.close()
|
||||||
|
return jsonify({'suppliers': [{'name': r[0], 'count': r[1]} for r in rows]})
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Delete ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@supplier_catalog_bp.route('/items/<int:item_id>', methods=['DELETE'])
|
||||||
|
@require_auth('inventory.edit')
|
||||||
|
def delete_item(item_id):
|
||||||
|
"""Soft-delete a supplier catalog item."""
|
||||||
|
conn = _get_master_conn()
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("UPDATE supplier_catalog SET is_active = false WHERE id = %s", (item_id,))
|
||||||
|
conn.commit()
|
||||||
|
cur.close(); conn.close()
|
||||||
|
return jsonify({'success': True})
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Prices ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _get_latest_prices(master_conn, tenant_id, catalog_ids):
|
||||||
|
"""Return a dict catalog_id -> price row for the latest active price per item."""
|
||||||
|
if not catalog_ids:
|
||||||
|
return {}
|
||||||
|
cur = master_conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
SELECT DISTINCT ON (catalog_id)
|
||||||
|
catalog_id, price, currency, effective_from, effective_to
|
||||||
|
FROM supplier_catalog_prices
|
||||||
|
WHERE tenant_id = %s AND catalog_id = ANY(%s) AND is_active = true
|
||||||
|
AND (effective_to IS NULL OR effective_to >= CURRENT_DATE)
|
||||||
|
ORDER BY catalog_id, effective_from DESC
|
||||||
|
""", (tenant_id, list(catalog_ids)))
|
||||||
|
prices = {}
|
||||||
|
for r in cur.fetchall():
|
||||||
|
prices[r[0]] = {
|
||||||
|
'price': float(r[1]) if r[1] is not None else None,
|
||||||
|
'currency': r[2] or 'MXN',
|
||||||
|
'effective_from': str(r[3]) if r[3] else None,
|
||||||
|
'effective_to': str(r[4]) if r[4] else None,
|
||||||
|
}
|
||||||
|
cur.close()
|
||||||
|
return prices
|
||||||
|
|
||||||
|
|
||||||
|
@supplier_catalog_bp.route('/prices', methods=['GET'])
|
||||||
|
@require_auth('catalog.view')
|
||||||
|
def list_prices():
|
||||||
|
"""List active supplier prices for the current tenant."""
|
||||||
|
supplier = (request.args.get('supplier') or '').strip()
|
||||||
|
q = (request.args.get('q') or '').strip()
|
||||||
|
page = max(1, request.args.get('page', 1, type=int))
|
||||||
|
per_page = min(200, request.args.get('per_page', 50, type=int))
|
||||||
|
offset = (page - 1) * per_page
|
||||||
|
|
||||||
|
conn = _get_master_conn()
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
where_parts = ["sc.is_active = true", "scp.tenant_id = %s"]
|
||||||
|
params = [g.tenant_id]
|
||||||
|
|
||||||
|
if supplier:
|
||||||
|
where_parts.append("sc.supplier_name = %s")
|
||||||
|
params.append(supplier)
|
||||||
|
if q:
|
||||||
|
where_parts.append("(sc.sku ILIKE %s OR sc.name ILIKE %s)")
|
||||||
|
like_q = f'%{q}%'
|
||||||
|
params.extend([like_q, like_q])
|
||||||
|
|
||||||
|
where_sql = " AND ".join(where_parts)
|
||||||
|
|
||||||
|
cur.execute(f"""
|
||||||
|
SELECT COUNT(DISTINCT sc.id)
|
||||||
|
FROM supplier_catalog sc
|
||||||
|
JOIN supplier_catalog_prices scp ON scp.catalog_id = sc.id
|
||||||
|
WHERE {where_sql}
|
||||||
|
AND scp.is_active = true
|
||||||
|
AND (scp.effective_to IS NULL OR scp.effective_to >= CURRENT_DATE)
|
||||||
|
""", params)
|
||||||
|
total = cur.fetchone()[0]
|
||||||
|
|
||||||
|
cur.execute(f"""
|
||||||
|
SELECT DISTINCT ON (sc.id)
|
||||||
|
sc.id, sc.supplier_name, sc.sku, sc.name, sc.category,
|
||||||
|
scp.price, scp.currency, scp.effective_from, scp.effective_to
|
||||||
|
FROM supplier_catalog sc
|
||||||
|
JOIN supplier_catalog_prices scp ON scp.catalog_id = sc.id
|
||||||
|
WHERE {where_sql}
|
||||||
|
AND scp.is_active = true
|
||||||
|
AND (scp.effective_to IS NULL OR scp.effective_to >= CURRENT_DATE)
|
||||||
|
ORDER BY sc.id, scp.effective_from DESC
|
||||||
|
LIMIT %s OFFSET %s
|
||||||
|
""", params + [per_page, offset])
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for r in cur.fetchall():
|
||||||
|
items.append({
|
||||||
|
'catalog_id': r[0],
|
||||||
|
'supplier_name': r[1],
|
||||||
|
'sku': r[2],
|
||||||
|
'name': r[3],
|
||||||
|
'category': r[4],
|
||||||
|
'price': float(r[5]) if r[5] is not None else None,
|
||||||
|
'currency': r[6] or 'MXN',
|
||||||
|
'effective_from': str(r[7]) if r[7] else None,
|
||||||
|
'effective_to': str(r[8]) if r[8] else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
cur.close(); conn.close()
|
||||||
|
return jsonify({
|
||||||
|
'data': items,
|
||||||
|
'pagination': {'page': page, 'per_page': per_page, 'total': total,
|
||||||
|
'total_pages': (total + per_page - 1) // per_page}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@supplier_catalog_bp.route('/prices/template', methods=['GET'])
|
||||||
|
@require_auth('catalog.view')
|
||||||
|
def download_price_template():
|
||||||
|
"""Return a CSV template for uploading supplier prices."""
|
||||||
|
output = io.StringIO()
|
||||||
|
writer = csv.writer(output)
|
||||||
|
writer.writerow(['supplier_name', 'sku', 'price', 'currency', 'effective_from'])
|
||||||
|
writer.writerow(['YOKOMITSU', 'DENK070A', '1250.00', 'MXN', '2026-01-01'])
|
||||||
|
output.seek(0)
|
||||||
|
return (output.getvalue(), 200, {
|
||||||
|
'Content-Type': 'text/csv; charset=utf-8',
|
||||||
|
'Content-Disposition': 'attachment; filename="supplier_prices_template.csv"'
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _read_upload_file(file_storage):
|
||||||
|
"""Read CSV or Excel upload and return list of dict rows."""
|
||||||
|
filename = (file_storage.filename or '').lower()
|
||||||
|
content = file_storage.read()
|
||||||
|
if filename.endswith('.csv'):
|
||||||
|
text = content.decode('utf-8-sig')
|
||||||
|
reader = csv.DictReader(io.StringIO(text))
|
||||||
|
return [row for row in reader]
|
||||||
|
if filename.endswith(('.xlsx', '.xls')):
|
||||||
|
try:
|
||||||
|
import openpyxl
|
||||||
|
except ImportError as e:
|
||||||
|
raise RuntimeError('openpyxl no instalado; sube CSV o instala openpyxl') from e
|
||||||
|
wb = openpyxl.load_workbook(io.BytesIO(content), data_only=True)
|
||||||
|
ws = wb.active
|
||||||
|
rows = list(ws.iter_rows(values_only=True))
|
||||||
|
if not rows:
|
||||||
|
return []
|
||||||
|
headers = [str(c).strip().lower() if c else '' for c in rows[0]]
|
||||||
|
return [
|
||||||
|
dict(zip(headers, row))
|
||||||
|
for row in rows[1:] if any(cell is not None and str(cell).strip() for cell in row)
|
||||||
|
]
|
||||||
|
raise ValueError('Formato no soportado. Usa CSV o Excel (.xlsx)')
|
||||||
|
|
||||||
|
|
||||||
|
@supplier_catalog_bp.route('/prices/upload', methods=['POST'])
|
||||||
|
@require_auth('inventory.edit')
|
||||||
|
def upload_prices():
|
||||||
|
"""Bulk upload/upsert supplier prices for the current tenant.
|
||||||
|
|
||||||
|
Expected columns: supplier_name, sku, price, [currency], [effective_from]
|
||||||
|
"""
|
||||||
|
if 'file' not in request.files:
|
||||||
|
return jsonify({'error': 'Archivo requerido'}), 400
|
||||||
|
file_storage = request.files['file']
|
||||||
|
if not file_storage or not file_storage.filename:
|
||||||
|
return jsonify({'error': 'Archivo requerido'}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
rows = _read_upload_file(file_storage)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': str(e)}), 400
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
return jsonify({'error': 'El archivo esta vacio o no tiene filas validas'}), 400
|
||||||
|
|
||||||
|
conn = _get_master_conn()
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
# Build a lookup of supplier+sku -> catalog_id
|
||||||
|
# We expect all rows to refer to existing catalog items.
|
||||||
|
normalized_rows = []
|
||||||
|
errors = []
|
||||||
|
for idx, row in enumerate(rows, start=2):
|
||||||
|
supplier = str(row.get('supplier_name') or '').strip()
|
||||||
|
sku = str(row.get('sku') or '').strip()
|
||||||
|
price_raw = row.get('price')
|
||||||
|
currency = str(row.get('currency') or 'MXN').strip().upper() or 'MXN'
|
||||||
|
eff_from_raw = row.get('effective_from')
|
||||||
|
|
||||||
|
if not supplier or not sku:
|
||||||
|
errors.append(f'Fila {idx}: supplier_name y sku son requeridos')
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
price = float(str(price_raw).replace(',', '').strip())
|
||||||
|
except Exception:
|
||||||
|
errors.append(f'Fila {idx}: precio invalido para {supplier}/{sku}')
|
||||||
|
continue
|
||||||
|
|
||||||
|
eff_from = date.today()
|
||||||
|
if eff_from_raw:
|
||||||
|
try:
|
||||||
|
eff_from = date.fromisoformat(str(eff_from_raw).strip())
|
||||||
|
except Exception:
|
||||||
|
errors.append(f'Fila {idx}: effective_from invalido (use YYYY-MM-DD)')
|
||||||
|
continue
|
||||||
|
|
||||||
|
normalized_rows.append((supplier, sku, price, currency, eff_from))
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
cur.close(); conn.close()
|
||||||
|
return jsonify({'error': 'Errores de validacion', 'details': errors}), 400
|
||||||
|
|
||||||
|
# Bulk lookup catalog IDs
|
||||||
|
catalog_lookup = {}
|
||||||
|
for supplier, sku, *_ in normalized_rows:
|
||||||
|
catalog_lookup[(supplier, sku)] = None
|
||||||
|
|
||||||
|
if catalog_lookup:
|
||||||
|
keys = list(catalog_lookup.keys())
|
||||||
|
# Batch query using unnest
|
||||||
|
cur.execute("""
|
||||||
|
SELECT supplier_name, sku, id
|
||||||
|
FROM supplier_catalog
|
||||||
|
WHERE is_active = true
|
||||||
|
AND (supplier_name, sku) = ANY(%s)
|
||||||
|
""", (keys,))
|
||||||
|
for r in cur.fetchall():
|
||||||
|
catalog_lookup[(r[0], r[1])] = r[2]
|
||||||
|
|
||||||
|
upserts = []
|
||||||
|
for idx, (supplier, sku, price, currency, eff_from) in enumerate(normalized_rows, start=2):
|
||||||
|
catalog_id = catalog_lookup.get((supplier, sku))
|
||||||
|
if not catalog_id:
|
||||||
|
errors.append(f'Fila {idx}: SKU {supplier}/{sku} no existe en el catalogo')
|
||||||
|
continue
|
||||||
|
upserts.append((g.tenant_id, catalog_id, price, currency, eff_from))
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
cur.close(); conn.close()
|
||||||
|
return jsonify({'error': 'Errores de validacion', 'details': errors}), 400
|
||||||
|
|
||||||
|
inserted = 0
|
||||||
|
updated = 0
|
||||||
|
for tenant_id, catalog_id, price, currency, eff_from in upserts:
|
||||||
|
# Try update existing row with same (tenant_id, catalog_id, effective_from)
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE supplier_catalog_prices
|
||||||
|
SET price = %s, currency = %s, is_active = true, updated_at = NOW()
|
||||||
|
WHERE tenant_id = %s AND catalog_id = %s AND effective_from = %s
|
||||||
|
RETURNING id
|
||||||
|
""", (price, currency, tenant_id, catalog_id, eff_from))
|
||||||
|
if cur.fetchone():
|
||||||
|
updated += 1
|
||||||
|
else:
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO supplier_catalog_prices
|
||||||
|
(tenant_id, catalog_id, price, currency, effective_from, is_active)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, true)
|
||||||
|
""", (tenant_id, catalog_id, price, currency, eff_from))
|
||||||
|
inserted += 1
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
cur.close(); conn.close()
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'processed': len(upserts),
|
||||||
|
'inserted': inserted,
|
||||||
|
'updated': updated,
|
||||||
|
})
|
||||||
@@ -12,6 +12,7 @@ supplier_portal_bp = Blueprint('supplier_portal', __name__, url_prefix='/pos/api
|
|||||||
|
|
||||||
|
|
||||||
from middleware import require_auth
|
from middleware import require_auth
|
||||||
|
from tenant_db import get_tenant_conn
|
||||||
|
|
||||||
|
|
||||||
class DecimalEncoder(json.JSONEncoder):
|
class DecimalEncoder(json.JSONEncoder):
|
||||||
@@ -26,48 +27,47 @@ class DecimalEncoder(json.JSONEncoder):
|
|||||||
def get_demand():
|
def get_demand():
|
||||||
"""Aggregated demand by zone, part group, and time range."""
|
"""Aggregated demand by zone, part group, and time range."""
|
||||||
days = request.args.get('days', 30, type=int)
|
days = request.args.get('days', 30, type=int)
|
||||||
group_id = request.args.get('group_id', type=int)
|
|
||||||
branch_id = request.args.get('branch_id', type=int)
|
branch_id = request.args.get('branch_id', type=int)
|
||||||
|
|
||||||
from tenant_db import get_tenant_db
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
db = get_tenant_db()
|
cur = conn.cursor()
|
||||||
since = datetime.utcnow() - timedelta(days=days)
|
since = datetime.utcnow() - timedelta(days=days)
|
||||||
|
|
||||||
|
try:
|
||||||
params = [since]
|
params = [since]
|
||||||
filters = "s.created_at >= %s"
|
filters = "s.created_at >= %s"
|
||||||
if group_id:
|
|
||||||
filters += " AND p.group_id = %s"
|
|
||||||
params.append(group_id)
|
|
||||||
if branch_id:
|
if branch_id:
|
||||||
filters += " AND s.branch_id = %s"
|
filters += " AND s.branch_id = %s"
|
||||||
params.append(branch_id)
|
params.append(branch_id)
|
||||||
|
|
||||||
rows = db.execute(
|
cur.execute(
|
||||||
f"""SELECT g.name as group_name, b.name as branch_name,
|
f"""SELECT b.name as branch_name,
|
||||||
COUNT(DISTINCT s.id_sale) as orders,
|
COUNT(DISTINCT s.id) as orders,
|
||||||
SUM(si.quantity) as qty_requested,
|
SUM(si.quantity) as qty_requested,
|
||||||
COALESCE(SUM(si.total), 0) as revenue
|
COALESCE(SUM(si.subtotal), 0) as revenue
|
||||||
FROM sale_items si
|
FROM sale_items si
|
||||||
JOIN sales s ON si.sale_id = s.id_sale
|
JOIN sales s ON si.sale_id = s.id
|
||||||
JOIN parts p ON si.part_id = p.id_part
|
LEFT JOIN branches b ON s.branch_id = b.id
|
||||||
JOIN part_groups g ON p.group_id = g.id_group
|
|
||||||
LEFT JOIN branches b ON s.branch_id = b.id_branch
|
|
||||||
WHERE {filters}
|
WHERE {filters}
|
||||||
GROUP BY g.name, b.name
|
GROUP BY b.name
|
||||||
ORDER BY revenue DESC
|
ORDER BY revenue DESC
|
||||||
LIMIT 100""", tuple(params)
|
LIMIT 100""", tuple(params)
|
||||||
).fetchall()
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'since': since.isoformat(),
|
'since': since.isoformat(),
|
||||||
'days': days,
|
'days': days,
|
||||||
'demand': [
|
'demand': [
|
||||||
{'group': row['group_name'], 'branch': row['branch_name'],
|
{'branch': row[0] or 'Sin sucursal',
|
||||||
'orders': row['orders'], 'quantity': row['qty_requested'],
|
'orders': row[1], 'quantity': row[2],
|
||||||
'revenue': row['revenue']}
|
'revenue': float(row[3]) if row[3] is not None else 0}
|
||||||
for row in rows
|
for row in rows
|
||||||
]
|
]
|
||||||
}, cls=DecimalEncoder)
|
})
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
@supplier_portal_bp.route('/top-parts', methods=['GET'])
|
@supplier_portal_bp.route('/top-parts', methods=['GET'])
|
||||||
@@ -75,31 +75,31 @@ def get_demand():
|
|||||||
def get_top_parts():
|
def get_top_parts():
|
||||||
"""Top moving parts for suppliers to restock."""
|
"""Top moving parts for suppliers to restock."""
|
||||||
days = request.args.get('days', 30, type=int)
|
days = request.args.get('days', 30, type=int)
|
||||||
from tenant_db import get_tenant_db
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
db = get_tenant_db()
|
cur = conn.cursor()
|
||||||
since = datetime.utcnow() - timedelta(days=days)
|
since = datetime.utcnow() - timedelta(days=days)
|
||||||
|
|
||||||
rows = db.execute(
|
try:
|
||||||
"""SELECT p.oem_part_number, p.name, g.name as group_name,
|
cur.execute(
|
||||||
SUM(si.quantity) as sold, COALESCE(SUM(si.total), 0) as revenue,
|
"""SELECT si.part_number, si.name,
|
||||||
COALESCE(SUM(wi.stock_quantity), 0) as current_stock
|
SUM(si.quantity) as sold, COALESCE(SUM(si.subtotal), 0) as revenue
|
||||||
FROM sale_items si
|
FROM sale_items si
|
||||||
JOIN sales s ON si.sale_id = s.id_sale
|
JOIN sales s ON si.sale_id = s.id
|
||||||
JOIN parts p ON si.part_id = p.id_part
|
|
||||||
JOIN part_groups g ON p.group_id = g.id_group
|
|
||||||
LEFT JOIN warehouse_inventory wi ON p.id_part = wi.part_id
|
|
||||||
WHERE s.created_at >= %s
|
WHERE s.created_at >= %s
|
||||||
GROUP BY p.oem_part_number, p.name, g.name
|
GROUP BY si.part_number, si.name
|
||||||
ORDER BY sold DESC
|
ORDER BY sold DESC
|
||||||
LIMIT 50""", (since,)
|
LIMIT 50""", (since,)
|
||||||
).fetchall()
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'since': since.isoformat(),
|
'since': since.isoformat(),
|
||||||
'parts': [
|
'parts': [
|
||||||
{'oem': row['oem_part_number'], 'name': row['name'],
|
{'part_number': row[0], 'name': row[1],
|
||||||
'group': row['group_name'], 'sold': row['sold'],
|
'sold': row[2], 'revenue': float(row[3]) if row[3] is not None else 0}
|
||||||
'revenue': row['revenue'], 'stock': row['current_stock']}
|
|
||||||
for row in rows
|
for row in rows
|
||||||
]
|
]
|
||||||
}, cls=DecimalEncoder)
|
})
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|||||||
@@ -15,10 +15,63 @@ from flask import Blueprint, request, jsonify, g
|
|||||||
from middleware import require_auth
|
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 datetime import datetime
|
||||||
|
|
||||||
whatsapp_bp = Blueprint('whatsapp', __name__, url_prefix='/pos/api/whatsapp')
|
whatsapp_bp = Blueprint('whatsapp', __name__, url_prefix='/pos/api/whatsapp')
|
||||||
|
|
||||||
|
|
||||||
|
def _get_whatsapp_config(conn):
|
||||||
|
"""Read WhatsApp bridge configuration from tenant_config.
|
||||||
|
Falls back to global server config (config.py / env vars) when tenant
|
||||||
|
has no explicit WhatsApp settings. This allows the shared bridge to work
|
||||||
|
out of the box for all tenants.
|
||||||
|
"""
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("SELECT key, value FROM tenant_config WHERE key LIKE 'whatsapp_%'")
|
||||||
|
config = {row[0]: row[1] for row in cur.fetchall()}
|
||||||
|
cur.close()
|
||||||
|
|
||||||
|
bridge_url = config.get('whatsapp_bridge_url', '') or WHATSAPP_BRIDGE_URL or ''
|
||||||
|
bridge_key = config.get('whatsapp_bridge_key', '') or WHATSAPP_BRIDGE_KEY or ''
|
||||||
|
enabled_raw = config.get('whatsapp_enabled', '').lower()
|
||||||
|
if enabled_raw == 'true':
|
||||||
|
enabled = True
|
||||||
|
elif enabled_raw == 'false':
|
||||||
|
enabled = False
|
||||||
|
else:
|
||||||
|
# No explicit tenant setting: auto-enable if a bridge URL is configured
|
||||||
|
enabled = bool(bridge_url)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'bridge_url': bridge_url,
|
||||||
|
'bridge_key': bridge_key,
|
||||||
|
'enabled': enabled,
|
||||||
|
'phone_number': config.get('whatsapp_phone_number', ''),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
@@ -179,27 +232,9 @@ def _enrich_wa_reply_with_part(search_query, vehicle, tenant_conn, master_conn=N
|
|||||||
fallback_rows = _do_search(use_compat=False)
|
fallback_rows = _do_search(use_compat=False)
|
||||||
|
|
||||||
if not rows and not fallback_rows:
|
if not rows and not fallback_rows:
|
||||||
# Truly nothing found — return a conversational message that doesn't kill the chat
|
# Nothing found in local inventory — let the AI's original response stand.
|
||||||
v_str = ""
|
# The webhook will append a soft note instead of replacing the message.
|
||||||
if vehicle and vehicle.get('brand'):
|
return None, None
|
||||||
v_str = f"{vehicle.get('brand','')} {vehicle.get('model','')} {vehicle.get('year','')}".strip()
|
|
||||||
|
|
||||||
msg_parts = [
|
|
||||||
"🔍 Revisé nuestro inventario y no encontré esas partes en este momento."
|
|
||||||
]
|
|
||||||
if v_str:
|
|
||||||
msg_parts.append(f"Para tu {v_str}, puedo:")
|
|
||||||
else:
|
|
||||||
msg_parts.append("Te puedo ayudar de estas formas:")
|
|
||||||
msg_parts.extend([
|
|
||||||
"",
|
|
||||||
"• *Pedirlas por encargo* — te doy tiempo y precio estimado",
|
|
||||||
"• *Buscar alternativas* — equivalentes de otra marca que sí tengamos",
|
|
||||||
"• *Sugerir refaccionarias cercanas* — si es urgente",
|
|
||||||
"",
|
|
||||||
"¿Qué prefieres? O dime si quieres buscar otra parte."
|
|
||||||
])
|
|
||||||
return '\n'.join(msg_parts), None
|
|
||||||
|
|
||||||
# Use fallback rows if primary search returned nothing
|
# Use fallback rows if primary search returned nothing
|
||||||
using_fallback = False
|
using_fallback = False
|
||||||
@@ -271,36 +306,52 @@ def _enrich_wa_reply_with_part(search_query, vehicle, tenant_conn, master_conn=N
|
|||||||
@whatsapp_bp.route('/status', methods=['GET'])
|
@whatsapp_bp.route('/status', methods=['GET'])
|
||||||
@require_auth()
|
@require_auth()
|
||||||
def status():
|
def status():
|
||||||
return jsonify(whatsapp_service.get_status())
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
cfg = _get_whatsapp_config(conn)
|
||||||
|
conn.close()
|
||||||
|
if not cfg['enabled'] or not cfg['bridge_url']:
|
||||||
|
return jsonify({'state': 'disabled', 'message': 'WhatsApp not configured for this tenant'})
|
||||||
|
return jsonify(whatsapp_service.get_status(bridge_url=cfg['bridge_url']))
|
||||||
|
|
||||||
|
|
||||||
@whatsapp_bp.route('/qr', methods=['GET'])
|
@whatsapp_bp.route('/qr', methods=['GET'])
|
||||||
@require_auth()
|
@require_auth()
|
||||||
def qr():
|
def qr():
|
||||||
return jsonify(whatsapp_service.get_qr())
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
cfg = _get_whatsapp_config(conn)
|
||||||
|
conn.close()
|
||||||
|
if not cfg['enabled'] or not cfg['bridge_url']:
|
||||||
|
return jsonify({'state': 'disabled', 'message': 'WhatsApp not configured for this tenant'})
|
||||||
|
return jsonify(whatsapp_service.get_qr(bridge_url=cfg['bridge_url']))
|
||||||
|
|
||||||
|
|
||||||
@whatsapp_bp.route('/connect', methods=['POST'])
|
@whatsapp_bp.route('/connect', methods=['POST'])
|
||||||
@require_auth()
|
@require_auth()
|
||||||
def connect():
|
def connect():
|
||||||
return jsonify(whatsapp_service.connect())
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
cfg = _get_whatsapp_config(conn)
|
||||||
|
conn.close()
|
||||||
|
if not cfg['enabled'] or not cfg['bridge_url']:
|
||||||
|
return jsonify({'state': 'error', 'error': 'WhatsApp not configured for this tenant'}), 400
|
||||||
|
return jsonify(whatsapp_service.connect(bridge_url=cfg['bridge_url']))
|
||||||
|
|
||||||
|
|
||||||
@whatsapp_bp.route('/logout', methods=['POST'])
|
@whatsapp_bp.route('/logout', methods=['POST'])
|
||||||
@require_auth()
|
@require_auth()
|
||||||
def logout():
|
def logout():
|
||||||
return jsonify(whatsapp_service.logout())
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
cfg = _get_whatsapp_config(conn)
|
||||||
|
conn.close()
|
||||||
|
if not cfg['enabled'] or not cfg['bridge_url']:
|
||||||
|
return jsonify({'state': 'error', 'error': 'WhatsApp not configured for this tenant'}), 400
|
||||||
|
return jsonify(whatsapp_service.logout(bridge_url=cfg['bridge_url']))
|
||||||
|
|
||||||
|
|
||||||
@whatsapp_bp.route('/webhook', methods=['POST'])
|
@whatsapp_bp.route('/webhook', methods=['POST'])
|
||||||
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 {}
|
||||||
|
|
||||||
@@ -311,270 +362,223 @@ 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})
|
||||||
|
|
||||||
# Reuse one tenant connection for the whole webhook path — we need it
|
phone = msg['phone']
|
||||||
# for persistence AND for the inventory-context lookup.
|
reply_to = msg.get('sender_pn') or msg.get('jid') or phone
|
||||||
# TODO: resolve tenant from phone number when multi-tenant WhatsApp arrives.
|
text = msg.get('text', '')
|
||||||
tenant_id = 11
|
|
||||||
tenant_conn = None
|
|
||||||
master_conn = None
|
|
||||||
inventory_context = None
|
|
||||||
try:
|
|
||||||
tenant_conn = get_tenant_conn(tenant_id)
|
|
||||||
master_conn = get_master_conn()
|
|
||||||
|
|
||||||
# 1. Log the incoming message (with contact display name)
|
|
||||||
cur = tenant_conn.cursor()
|
|
||||||
cur.execute("""
|
|
||||||
INSERT INTO whatsapp_messages (phone, direction, message_text, wa_message_id, push_name)
|
|
||||||
VALUES (%s, 'incoming', %s, %s, %s)
|
|
||||||
ON CONFLICT DO NOTHING
|
|
||||||
""", (msg['phone'], msg['text'], msg['message_id'], msg.get('push_name') or None))
|
|
||||||
tenant_conn.commit()
|
|
||||||
cur.close()
|
|
||||||
|
|
||||||
# 2. Build inventory context once per webhook call so the chatbot
|
|
||||||
# can say things like "tengo 5 Bosch BP-123 por $450".
|
|
||||||
try:
|
|
||||||
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
|
|
||||||
|
|
||||||
# 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(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}")
|
|
||||||
|
|
||||||
# 3. Dispatch by media kind + quotation commands
|
|
||||||
reply = None
|
|
||||||
reply_to = msg.get('jid') or msg['phone']
|
|
||||||
media_kind = msg.get('media_kind', 'text')
|
media_kind = msg.get('media_kind', 'text')
|
||||||
clean_phone = msg.get('phone', '')
|
|
||||||
|
|
||||||
# ── Check for quotation commands FIRST (before AI) ──
|
# Audio transcription (voice notes)
|
||||||
if media_kind == 'text' and msg.get('text'):
|
if media_kind == 'audio' and msg.get('media_base64'):
|
||||||
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,
|
|
||||||
)
|
|
||||||
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':
|
|
||||||
last_part = get_last_shown_part(clean_phone)
|
|
||||||
if not last_part:
|
|
||||||
reply = '⚠️ Primero pregunta por una parte y luego escribe "cotizar" para agregarla.'
|
|
||||||
elif tenant_conn:
|
|
||||||
qid = get_open_quotation(tenant_conn, clean_phone)
|
|
||||||
if not qid:
|
|
||||||
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._'
|
|
||||||
)
|
|
||||||
|
|
||||||
elif intent == 'send':
|
|
||||||
if tenant_conn:
|
|
||||||
qid = get_open_quotation(tenant_conn, clean_phone)
|
|
||||||
if qid:
|
|
||||||
detail = get_quotation_detail(tenant_conn, qid)
|
|
||||||
reply = format_quotation_wa(detail)
|
|
||||||
if not reply:
|
|
||||||
reply = '⚠️ Tu cotización está vacía. Pregunta por partes y escribe "cotizar" para agregarlas.'
|
|
||||||
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)
|
|
||||||
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)
|
|
||||||
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=2)
|
|
||||||
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'):
|
|
||||||
# Voice note handling — transcribe first, then chat().
|
|
||||||
# See services.whisper_local for the transcriber.
|
|
||||||
try:
|
try:
|
||||||
from services.whisper_local import transcribe_audio_base64
|
from services.whisper_local import transcribe_audio_base64
|
||||||
transcript = transcribe_audio_base64(
|
transcript = transcribe_audio_base64(
|
||||||
msg['media_base64'],
|
msg['media_base64'],
|
||||||
mimetype=msg.get('media_mimetype') or 'audio/ogg',
|
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:
|
if transcript:
|
||||||
print(f"[WA-AI] Voice note transcribed: {transcript[:100]}")
|
text = transcript
|
||||||
from services.ai_chat import chat
|
print(f"[WA-SM] Voice note transcribed: {transcript[:100]}")
|
||||||
ai_resp = chat(transcript, conversation_history=conversation_history, inventory_context=inventory_context)
|
except ImportError:
|
||||||
reply = ai_resp.get('message', '') or ''
|
pass
|
||||||
# Prefix the reply so the sender knows we understood the voice note
|
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)
|
||||||
|
if not tenant_id:
|
||||||
|
try:
|
||||||
|
mconn = get_master_conn()
|
||||||
|
mcur = mconn.cursor()
|
||||||
|
mcur.execute("""
|
||||||
|
SELECT id, db_name FROM tenants
|
||||||
|
WHERE is_active = true
|
||||||
|
ORDER BY id
|
||||||
|
""")
|
||||||
|
tenants = mcur.fetchall()
|
||||||
|
mcur.close()
|
||||||
|
mconn.close()
|
||||||
|
# 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:
|
||||||
|
tenant_id = None
|
||||||
|
|
||||||
|
tenant_conn = None
|
||||||
|
master_conn = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
tenant_conn = get_tenant_conn(tenant_id)
|
||||||
|
master_conn = get_master_conn()
|
||||||
|
wa_config = _get_whatsapp_config(tenant_conn)
|
||||||
|
|
||||||
|
# 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.execute("""
|
||||||
|
INSERT INTO whatsapp_messages (phone, direction, message_text, wa_message_id, push_name)
|
||||||
|
VALUES (%s, 'incoming', %s, %s, %s)
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
""", (phone, text, wa_message_id, msg.get('push_name')))
|
||||||
|
tenant_conn.commit()
|
||||||
|
cur.close()
|
||||||
|
|
||||||
|
# 2. Load session state
|
||||||
|
from services.wa_state_machine import get_session, save_session, process_message, StateContext
|
||||||
|
session = get_session(tenant_conn, phone)
|
||||||
|
|
||||||
|
# 3. Check session expiry (30 minutes)
|
||||||
|
current_state = session.get('state', 'idle')
|
||||||
|
state_data = session.get('state_data', {})
|
||||||
|
last_updated = session.get('updated_at')
|
||||||
|
|
||||||
|
if last_updated and hasattr(last_updated, 'strftime'):
|
||||||
|
# PostgreSQL returns datetime objects (often timezone-aware)
|
||||||
|
from datetime import timezone
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
if last_updated.tzinfo is None:
|
||||||
|
now = now.replace(tzinfo=None)
|
||||||
|
elapsed = (now - last_updated).total_seconds()
|
||||||
|
if elapsed > 1800:
|
||||||
|
current_state = 'idle'
|
||||||
|
state_data = {'customer_id': state_data.get('customer_id')}
|
||||||
|
elif last_updated and isinstance(last_updated, str):
|
||||||
|
from datetime import datetime as dt
|
||||||
|
try:
|
||||||
|
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:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Global reset commands work from any state
|
||||||
|
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
|
||||||
|
try:
|
||||||
|
from services.part_kits import should_send_followup
|
||||||
|
followup = should_send_followup(phone, tenant_conn)
|
||||||
|
if followup:
|
||||||
|
whatsapp_service.send_message(reply_to, followup, bridge_url=wa_config.get('bridge_url'))
|
||||||
|
cur_fu = tenant_conn.cursor()
|
||||||
|
cur_fu.execute(
|
||||||
|
"INSERT INTO whatsapp_messages (phone, direction, message_text) VALUES (%s, 'outgoing', %s)",
|
||||||
|
(phone, followup)
|
||||||
|
)
|
||||||
|
tenant_conn.commit()
|
||||||
|
cur_fu.close()
|
||||||
|
except Exception as fu_err:
|
||||||
|
print(f"[WA-SM] Follow-up send failed: {fu_err}")
|
||||||
|
|
||||||
|
# 4. Build context
|
||||||
|
context = StateContext(
|
||||||
|
tenant_conn=tenant_conn,
|
||||||
|
master_conn=master_conn,
|
||||||
|
wa_config=wa_config,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
phone=phone,
|
||||||
|
media_kind=media_kind,
|
||||||
|
media_base64=msg.get('media_base64'),
|
||||||
|
push_name=msg.get('push_name'),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 5. Process through state machine
|
||||||
|
reply, next_state, next_state_data = process_message(
|
||||||
|
phone=phone,
|
||||||
|
text=text,
|
||||||
|
current_state=current_state,
|
||||||
|
state_data=state_data,
|
||||||
|
context=context,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 5b. Si el estado transicionó sin mensaje, procesar el siguiente inmediatamente
|
||||||
|
# (algunos estados solo hacen transiciones y delegan el mensaje al siguiente estado)
|
||||||
|
loop_guard = 0
|
||||||
|
while reply is None and loop_guard < 5:
|
||||||
|
loop_guard += 1
|
||||||
|
reply, next_state, next_state_data = process_message(
|
||||||
|
phone=phone,
|
||||||
|
text=text,
|
||||||
|
current_state=next_state,
|
||||||
|
state_data=next_state_data,
|
||||||
|
context=context,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 6. Save new state
|
||||||
|
save_session(tenant_conn, phone, next_state, next_state_data)
|
||||||
|
|
||||||
|
# 7. Send reply
|
||||||
if reply:
|
if reply:
|
||||||
reply = f'🎙️ Entendi: "{transcript}"\n\n{reply}'
|
result = whatsapp_service.send_message(reply_to, reply, bridge_url=wa_config.get('bridge_url'))
|
||||||
else:
|
print(f"[WA-SM] Replied to {phone}: {reply[:80]}... result={result}")
|
||||||
reply = ('Recibi tu nota de voz pero no pude transcribirla. '
|
|
||||||
'Puedes escribirme el mensaje?')
|
|
||||||
|
|
||||||
elif msg.get('text'):
|
# Log outgoing
|
||||||
# Plain text message — standard chatbot flow
|
cur = tenant_conn.cursor()
|
||||||
from services.ai_chat import chat
|
cur.execute("""
|
||||||
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(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
|
|
||||||
# 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(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:
|
|
||||||
result = whatsapp_service.send_message(reply_to, reply)
|
|
||||||
print(f"[WA-AI] Replied to {reply_to} ({media_kind}): {reply[:80]}... result={result}")
|
|
||||||
|
|
||||||
# Save the bot's reply to DB so it shows in the WhatsApp UI
|
|
||||||
if tenant_conn:
|
|
||||||
try:
|
|
||||||
cur2 = tenant_conn.cursor()
|
|
||||||
cur2.execute("""
|
|
||||||
INSERT INTO whatsapp_messages (phone, direction, message_text)
|
INSERT INTO whatsapp_messages (phone, direction, message_text)
|
||||||
VALUES (%s, 'outgoing', %s)
|
VALUES (%s, 'outgoing', %s)
|
||||||
""", (msg['phone'], reply))
|
""", (phone, reply))
|
||||||
tenant_conn.commit()
|
tenant_conn.commit()
|
||||||
cur2.close()
|
cur.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:
|
||||||
@@ -592,11 +596,17 @@ def send():
|
|||||||
if not phone or not message:
|
if not phone or not message:
|
||||||
return jsonify({'error': 'phone and message required'}), 400
|
return jsonify({'error': 'phone and message required'}), 400
|
||||||
|
|
||||||
result = whatsapp_service.send_message(phone, message)
|
# Load tenant WhatsApp config
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
cfg = _get_whatsapp_config(conn)
|
||||||
|
if not cfg['enabled'] or not cfg['bridge_url']:
|
||||||
|
conn.close()
|
||||||
|
return jsonify({'error': 'WhatsApp not configured for this tenant'}), 400
|
||||||
|
|
||||||
|
result = whatsapp_service.send_message(phone, message, bridge_url=cfg['bridge_url'])
|
||||||
|
|
||||||
# Save outgoing message
|
# Save outgoing message
|
||||||
try:
|
try:
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
INSERT INTO whatsapp_messages (phone, direction, message_text)
|
INSERT INTO whatsapp_messages (phone, direction, message_text)
|
||||||
@@ -604,9 +614,10 @@ def send():
|
|||||||
""", (phone, message))
|
""", (phone, message))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
cur.close()
|
cur.close()
|
||||||
conn.close()
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
return jsonify(result)
|
return jsonify(result)
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ bind = "0.0.0.0:5001"
|
|||||||
# gthread workers handle multiple concurrent requests per worker via threads.
|
# gthread workers handle multiple concurrent requests per worker via threads.
|
||||||
# Ideal for I/O-bound Flask apps with DB queries.
|
# Ideal for I/O-bound Flask apps with DB queries.
|
||||||
# 4 workers × 4 threads = 16 concurrent requests.
|
# 4 workers × 4 threads = 16 concurrent requests.
|
||||||
workers = 4
|
workers = 8
|
||||||
threads = 4
|
threads = 4
|
||||||
worker_class = "gthread"
|
worker_class = "gthread"
|
||||||
worker_connections = 1000
|
worker_connections = 1000
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ def require_auth(*required_permissions):
|
|||||||
except jwt.InvalidTokenError:
|
except jwt.InvalidTokenError:
|
||||||
return jsonify({'error': 'Invalid token'}), 401
|
return jsonify({'error': 'Invalid token'}), 401
|
||||||
|
|
||||||
if payload.get('type') != 'pos_access':
|
if payload.get('type') not in ('pos_access', 'access'):
|
||||||
return jsonify({'error': 'Invalid token type'}), 401
|
return jsonify({'error': 'Invalid token type'}), 401
|
||||||
|
|
||||||
g.tenant_id = payload['tenant_id']
|
g.tenant_id = payload['tenant_id']
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ def _lookup_tenant_by_subdomain(subdomain):
|
|||||||
conn = get_master_conn()
|
conn = get_master_conn()
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"SELECT id, name FROM tenants WHERE subdomain = %s AND is_active = true",
|
"SELECT id, name FROM tenants WHERE LOWER(subdomain) = %s AND is_active = true",
|
||||||
(subdomain,)
|
(subdomain,)
|
||||||
)
|
)
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
|
|||||||
@@ -14,9 +14,11 @@ MIGRATIONS_DIR = os.path.dirname(os.path.abspath(__file__))
|
|||||||
MIGRATIONS = {
|
MIGRATIONS = {
|
||||||
'v1.0': 'v1.0_initial.sql',
|
'v1.0': 'v1.0_initial.sql',
|
||||||
'v1.1': 'v1.1_pos_tables.sql',
|
'v1.1': 'v1.1_pos_tables.sql',
|
||||||
|
'v1.2': 'v1.2_subdomain.sql',
|
||||||
'v1.3': 'v1.3_fleet.sql',
|
'v1.3': 'v1.3_fleet.sql',
|
||||||
'v1.4': 'v1.4_whatsapp.sql',
|
'v1.4': 'v1.4_whatsapp.sql',
|
||||||
'v1.5': 'v1.5_returns.sql',
|
'v1.5': 'v1.5_returns.sql',
|
||||||
|
'v1.6': 'v1.6_marketplace.sql',
|
||||||
'v1.7': 'v1.7_plates.sql',
|
'v1.7': 'v1.7_plates.sql',
|
||||||
'v1.8': 'v1.8_performance_indexes.sql',
|
'v1.8': 'v1.8_performance_indexes.sql',
|
||||||
'v1.9': 'v1.9_redis_cache.sql',
|
'v1.9': 'v1.9_redis_cache.sql',
|
||||||
@@ -33,6 +35,20 @@ MIGRATIONS = {
|
|||||||
'v3.0': 'v3.0_public_api.sql',
|
'v3.0': 'v3.0_public_api.sql',
|
||||||
'v3.1': 'v3.1_inventory_vehicle_compat.sql',
|
'v3.1': 'v3.1_inventory_vehicle_compat.sql',
|
||||||
'v3.2': 'v3.2_db_performance.sql',
|
'v3.2': 'v3.2_db_performance.sql',
|
||||||
|
'v3.2.1': 'v3.2_qwen_vehicle_compat.sql',
|
||||||
|
'v3.3': 'v3.3_marketplace_any_part.sql',
|
||||||
|
'v3.3.1': 'v3.3_materialized_view.sql',
|
||||||
|
'v3.4': 'v3.4_meli_integration.sql',
|
||||||
|
'v3.5': 'v3.5_meli_questions.sql',
|
||||||
|
'v3.5.1': 'v3.5_whatsapp_state_machine.sql',
|
||||||
|
'v3.6': 'v3.6_dropshipping.sql',
|
||||||
|
'v3.7': 'v3.7_sku_aliases.sql',
|
||||||
|
'v3.8': 'v3.8_supplier_catalog.sql',
|
||||||
|
'v3.9': 'v3.9_supplier_catalog_prices.sql',
|
||||||
|
'v4.0': 'v4.0_multi_branch.sql',
|
||||||
|
'v4.1': 'v4.1_global_invoice.sql',
|
||||||
|
'v4.2': 'v4.2_meli_sync_queue.sql',
|
||||||
|
'v4.3': 'v4.3_facturapi.sql',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -61,11 +77,19 @@ def apply_migration(db_name, version):
|
|||||||
print(f" ERROR: Migration file not found: {filepath}")
|
print(f" ERROR: Migration file not found: {filepath}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
with open(filepath) as f:
|
||||||
|
sql = f.read()
|
||||||
|
|
||||||
|
# Skip migrations marked for manual/non-tenant execution
|
||||||
|
first_line = sql.splitlines()[0].strip() if sql.strip() else ''
|
||||||
|
if first_line.startswith(': SKIP') or first_line.startswith('-- : SKIP'):
|
||||||
|
print(f" SKIP (manual/non-tenant migration)")
|
||||||
|
return True
|
||||||
|
|
||||||
conn = get_tenant_conn_by_dbname(db_name)
|
conn = get_tenant_conn_by_dbname(db_name)
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
try:
|
try:
|
||||||
with open(filepath) as f:
|
cur.execute(sql)
|
||||||
cur.execute(f.read())
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -386,3 +386,4 @@ CREATE TABLE IF NOT EXISTS tenant_config (
|
|||||||
|
|
||||||
-- Barcode sequence
|
-- Barcode sequence
|
||||||
CREATE SEQUENCE IF NOT EXISTS barcode_seq START 1;
|
CREATE SEQUENCE IF NOT EXISTS barcode_seq START 1;
|
||||||
|
|
||||||
|
|||||||
53
pos/migrations/v3.2_qwen_vehicle_compat.sql
Normal file
53
pos/migrations/v3.2_qwen_vehicle_compat.sql
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
-- v3.2 QWEN Vehicle Compatibility — store unmatched AI vehicles as text
|
||||||
|
-- Allows saving QWEN fitment results even when the vehicle is not in TecDoc.
|
||||||
|
|
||||||
|
-- 1. Allow NULL model_year_engine_id for QWEN vehicles not in master DB
|
||||||
|
ALTER TABLE inventory_vehicle_compat
|
||||||
|
ALTER COLUMN model_year_engine_id DROP NOT NULL;
|
||||||
|
|
||||||
|
-- 2. Add text columns for QWEN vehicle details
|
||||||
|
ALTER TABLE inventory_vehicle_compat
|
||||||
|
ADD COLUMN IF NOT EXISTS make VARCHAR(100),
|
||||||
|
ADD COLUMN IF NOT EXISTS model VARCHAR(100),
|
||||||
|
ADD COLUMN IF NOT EXISTS year INTEGER,
|
||||||
|
ADD COLUMN IF NOT EXISTS engine VARCHAR(100),
|
||||||
|
ADD COLUMN IF NOT EXISTS engine_code VARCHAR(50);
|
||||||
|
|
||||||
|
-- 3. Drop old unique constraint and recreate to handle NULL mye_id
|
||||||
|
-- (PostgreSQL allows multiple NULLs in a UNIQUE constraint)
|
||||||
|
ALTER TABLE inventory_vehicle_compat
|
||||||
|
DROP CONSTRAINT IF EXISTS inventory_vehicle_compat_inventory_id_model_year_engine_id_key;
|
||||||
|
|
||||||
|
ALTER TABLE inventory_vehicle_compat
|
||||||
|
ADD CONSTRAINT inventory_vehicle_compat_unique_match
|
||||||
|
UNIQUE (inventory_id, model_year_engine_id, make, model, year);
|
||||||
|
|
||||||
|
-- 4. Index for fast filtering by inventory + text vehicles
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ivc_text_vehicle
|
||||||
|
ON inventory_vehicle_compat(inventory_id, make, model, year)
|
||||||
|
WHERE model_year_engine_id IS NULL;
|
||||||
|
|
||||||
|
-- 5. Update view to include new columns
|
||||||
|
DROP VIEW IF EXISTS v_inventory_vehicle_compat;
|
||||||
|
CREATE VIEW v_inventory_vehicle_compat AS
|
||||||
|
SELECT
|
||||||
|
ivc.id,
|
||||||
|
ivc.inventory_id,
|
||||||
|
ivc.model_year_engine_id,
|
||||||
|
ivc.make,
|
||||||
|
ivc.model,
|
||||||
|
ivc.year,
|
||||||
|
ivc.engine,
|
||||||
|
ivc.engine_code,
|
||||||
|
ivc.source,
|
||||||
|
ivc.confidence,
|
||||||
|
ivc.created_at,
|
||||||
|
i.part_number,
|
||||||
|
i.name as item_name,
|
||||||
|
i.brand as item_brand,
|
||||||
|
i.price_1,
|
||||||
|
i.price_2,
|
||||||
|
i.price_3,
|
||||||
|
i.image_url
|
||||||
|
FROM inventory_vehicle_compat ivc
|
||||||
|
JOIN inventory i ON i.id = ivc.inventory_id;
|
||||||
93
pos/migrations/v3.3_marketplace_any_part.sql
Normal file
93
pos/migrations/v3.3_marketplace_any_part.sql
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- v3.3 — Marketplace accepts any part number (seller listings)
|
||||||
|
-- Target: nexus_autoparts (master DB) / tenants with warehouse_inventory
|
||||||
|
-- Date: 2026-05-17
|
||||||
|
--
|
||||||
|
-- Makes warehouse_inventory part_id nullable and adds seller-defined
|
||||||
|
-- fields so any seller can list parts that don't exist in the OEM catalog.
|
||||||
|
-- Existing OEM-matched listings are untouched.
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'warehouse_inventory') THEN
|
||||||
|
-- ─── 1. WAREHOUSE_INVENTORY — add seller listing columns ─────────────
|
||||||
|
ALTER TABLE warehouse_inventory
|
||||||
|
ADD COLUMN IF NOT EXISTS seller_part_number VARCHAR(100),
|
||||||
|
ADD COLUMN IF NOT EXISTS seller_part_name VARCHAR(300),
|
||||||
|
ADD COLUMN IF NOT EXISTS seller_category VARCHAR(100),
|
||||||
|
ADD COLUMN IF NOT EXISTS tenant_inventory_id INTEGER;
|
||||||
|
|
||||||
|
-- Make part_id nullable so seller listings (without catalog match) can exist
|
||||||
|
ALTER TABLE warehouse_inventory ALTER COLUMN part_id DROP NOT NULL;
|
||||||
|
|
||||||
|
-- ─── 2. WAREHOUSE_INVENTORY — drop old unique, add partial uniques ───
|
||||||
|
ALTER TABLE warehouse_inventory
|
||||||
|
DROP CONSTRAINT IF EXISTS warehouse_inventory_user_id_part_id_warehouse_location_key;
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS idx_wi_unique_composite;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_wi_unique_oem
|
||||||
|
ON warehouse_inventory(bodega_id, part_id, warehouse_location)
|
||||||
|
WHERE part_id IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_wi_unique_seller
|
||||||
|
ON warehouse_inventory(bodega_id, seller_part_number, warehouse_location)
|
||||||
|
WHERE part_id IS NULL;
|
||||||
|
|
||||||
|
-- Ensure every row has either part_id or seller_part_number
|
||||||
|
ALTER TABLE warehouse_inventory
|
||||||
|
DROP CONSTRAINT IF EXISTS chk_wi_part_or_seller;
|
||||||
|
|
||||||
|
ALTER TABLE warehouse_inventory
|
||||||
|
ADD CONSTRAINT chk_wi_part_or_seller
|
||||||
|
CHECK (
|
||||||
|
(part_id IS NOT NULL AND seller_part_number IS NULL)
|
||||||
|
OR
|
||||||
|
(part_id IS NULL AND seller_part_number IS NOT NULL)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ─── 3. WAREHOUSE_INVENTORY — search indexes ─────────────────────────
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wi_seller_pn
|
||||||
|
ON warehouse_inventory (bodega_id, seller_part_number)
|
||||||
|
WHERE part_id IS NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wi_seller_category
|
||||||
|
ON warehouse_inventory (seller_category)
|
||||||
|
WHERE part_id IS NULL;
|
||||||
|
|
||||||
|
-- GIN index for text search on seller listings
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wi_seller_search
|
||||||
|
ON warehouse_inventory
|
||||||
|
USING gin (to_tsvector('spanish',
|
||||||
|
COALESCE(seller_part_name, '') || ' ' || COALESCE(seller_part_number, '')
|
||||||
|
))
|
||||||
|
WHERE part_id IS NULL;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'purchase_order_items') THEN
|
||||||
|
-- ─── 4. PURCHASE_ORDER_ITEMS — make part_id nullable ─────────────────
|
||||||
|
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'purchase_order_items' AND column_name = 'part_id') THEN
|
||||||
|
ALTER TABLE purchase_order_items
|
||||||
|
ALTER COLUMN part_id DROP NOT NULL;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Add a flag so seller listings can be distinguished in POs
|
||||||
|
ALTER TABLE purchase_order_items
|
||||||
|
ADD COLUMN IF NOT EXISTS is_seller_listing BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- ─── 5. Back-compat: ensure existing rows are valid ──────────────────
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'warehouse_inventory') THEN
|
||||||
|
UPDATE warehouse_inventory
|
||||||
|
SET seller_part_number = NULL
|
||||||
|
WHERE part_id IS NOT NULL AND seller_part_number IS NOT NULL;
|
||||||
|
|
||||||
|
UPDATE warehouse_inventory
|
||||||
|
SET part_id = NULL
|
||||||
|
WHERE part_id IS NULL AND seller_part_number IS NULL;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
|
-- : SKIP
|
||||||
-- Migration v3.3: Materialized view part_vehicle_preview
|
-- Migration v3.3: Materialized view part_vehicle_preview
|
||||||
-- Purpose: Pre-compute the "most recent vehicle" per part to eliminate
|
-- Purpose: Pre-compute the "most recent vehicle" per part to eliminate
|
||||||
-- DISTINCT ON + 4 JOINs over vehicle_parts (254 GB, 2B+ rows) at query time.
|
-- DISTINCT ON + 4 JOINs over vehicle_parts (254 GB, 2B+ rows) at query time.
|
||||||
--
|
--
|
||||||
-- Notes:
|
-- NOTE: This migration targets the vehicle_database, not tenant databases.
|
||||||
-- - CREATE MATERIALIZED VIEW without CONCURRENTLY (first creation).
|
-- The runner skips files marked with ': SKIP' on the first line.
|
||||||
-- - REFRESH MATERIALIZED VIEW CONCURRENTLY is possible after the unique index exists.
|
-- To apply manually on the vehicle database, run:
|
||||||
-- - Run with statement_timeout = 0; this may take hours on first creation.
|
--
|
||||||
|
-- psql <vehicle_db> -f pos/migrations/v3.3_materialized_view.sql
|
||||||
|
--
|
||||||
|
-- (Remove the ': SKIP' line above before manual execution.)
|
||||||
|
|
||||||
SET statement_timeout = 0;
|
SET statement_timeout = 0;
|
||||||
|
|
||||||
@@ -26,6 +30,3 @@ ORDER BY vp.part_id, y.year_car DESC;
|
|||||||
|
|
||||||
CREATE UNIQUE INDEX idx_pvp_part ON part_vehicle_preview(part_id);
|
CREATE UNIQUE INDEX idx_pvp_part ON part_vehicle_preview(part_id);
|
||||||
CREATE INDEX idx_pvp_brand ON part_vehicle_preview(name_brand);
|
CREATE INDEX idx_pvp_brand ON part_vehicle_preview(name_brand);
|
||||||
|
|
||||||
-- Grant select to application roles if needed
|
|
||||||
-- GRANT SELECT ON part_vehicle_preview TO nexus_app;
|
|
||||||
|
|||||||
110
pos/migrations/v3.4_meli_integration.sql
Normal file
110
pos/migrations/v3.4_meli_integration.sql
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- v3.4 MercadoLibre Integration
|
||||||
|
-- ============================================================
|
||||||
|
-- Adds tables for external marketplace listings, orders,
|
||||||
|
-- order items, and a generic sync queue.
|
||||||
|
-- All tables live in the tenant DB.
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- Listings published on MercadoLibre (extensible to Amazon later)
|
||||||
|
CREATE TABLE IF NOT EXISTS marketplace_listings (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
inventory_id INTEGER REFERENCES inventory(id),
|
||||||
|
channel VARCHAR(20) NOT NULL DEFAULT 'mercadolibre',
|
||||||
|
external_item_id VARCHAR(50) NOT NULL,
|
||||||
|
external_status VARCHAR(30) DEFAULT 'active',
|
||||||
|
external_permalink TEXT,
|
||||||
|
title TEXT,
|
||||||
|
meli_category_id VARCHAR(30),
|
||||||
|
publish_price NUMERIC(12,2),
|
||||||
|
last_sync_at TIMESTAMPTZ,
|
||||||
|
sync_errors TEXT,
|
||||||
|
is_active BOOLEAN DEFAULT true,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_marketplace_listings_inventory
|
||||||
|
ON marketplace_listings(inventory_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_marketplace_listings_external
|
||||||
|
ON marketplace_listings(external_item_id);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_marketplace_listings_unique
|
||||||
|
ON marketplace_listings(inventory_id, channel) WHERE is_active = true;
|
||||||
|
|
||||||
|
-- Orders received from MercadoLibre
|
||||||
|
CREATE TABLE IF NOT EXISTS marketplace_orders (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
channel VARCHAR(20) NOT NULL DEFAULT 'mercadolibre',
|
||||||
|
external_order_id VARCHAR(50) NOT NULL UNIQUE,
|
||||||
|
external_status VARCHAR(30) NOT NULL,
|
||||||
|
buyer_name VARCHAR(200),
|
||||||
|
buyer_email VARCHAR(200),
|
||||||
|
buyer_phone VARCHAR(50),
|
||||||
|
buyer_nickname VARCHAR(100),
|
||||||
|
shipping_address JSONB,
|
||||||
|
total_amount NUMERIC(12,2),
|
||||||
|
shipping_cost NUMERIC(12,2),
|
||||||
|
meli_shipping_id VARCHAR(50),
|
||||||
|
nexus_sale_id INTEGER REFERENCES sales(id),
|
||||||
|
status VARCHAR(20) DEFAULT 'pending',
|
||||||
|
notes TEXT,
|
||||||
|
raw_json JSONB,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_marketplace_orders_status
|
||||||
|
ON marketplace_orders(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_marketplace_orders_external
|
||||||
|
ON marketplace_orders(external_order_id);
|
||||||
|
|
||||||
|
-- Items inside a marketplace order
|
||||||
|
CREATE TABLE IF NOT EXISTS marketplace_order_items (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
marketplace_order_id INTEGER REFERENCES marketplace_orders(id) ON DELETE CASCADE,
|
||||||
|
inventory_id INTEGER REFERENCES inventory(id),
|
||||||
|
external_item_id VARCHAR(50),
|
||||||
|
title VARCHAR(300),
|
||||||
|
quantity INTEGER NOT NULL,
|
||||||
|
unit_price NUMERIC(12,2),
|
||||||
|
total_price NUMERIC(12,2),
|
||||||
|
listing_id INTEGER REFERENCES marketplace_listings(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Generic sync queue (reusable for future Amazon integration)
|
||||||
|
CREATE TABLE IF NOT EXISTS marketplace_sync_queue (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
inventory_id INTEGER REFERENCES inventory(id),
|
||||||
|
channel VARCHAR(20) NOT NULL,
|
||||||
|
action VARCHAR(20) NOT NULL,
|
||||||
|
status VARCHAR(20) DEFAULT 'pending',
|
||||||
|
payload JSONB,
|
||||||
|
error_message TEXT,
|
||||||
|
retry_count INT DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
processed_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_marketplace_sync_queue_pending
|
||||||
|
ON marketplace_sync_queue(status, channel) WHERE status = 'pending';
|
||||||
|
|
||||||
|
-- Add source column to sales to track origin (POS, ML, Amazon, etc.)
|
||||||
|
-- If the column already exists from another migration, do nothing.
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'sales' AND column_name = 'source'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE sales ADD COLUMN source VARCHAR(30) DEFAULT 'pos';
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'sales' AND column_name = 'external_order_id'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE sales ADD COLUMN external_order_id VARCHAR(50);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
30
pos/migrations/v3.5_meli_questions.sql
Normal file
30
pos/migrations/v3.5_meli_questions.sql
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- v3.5 MercadoLibre Questions & Answers
|
||||||
|
-- ============================================================
|
||||||
|
-- Adds table for tracking buyer questions on ML listings.
|
||||||
|
-- All tables live in the tenant DB.
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS marketplace_questions (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
listing_id INTEGER REFERENCES marketplace_listings(id) ON DELETE SET NULL,
|
||||||
|
external_question_id VARCHAR(50) NOT NULL UNIQUE,
|
||||||
|
external_item_id VARCHAR(50) NOT NULL,
|
||||||
|
question_text TEXT NOT NULL,
|
||||||
|
answer_text TEXT,
|
||||||
|
status VARCHAR(20) DEFAULT 'unanswered', -- unanswered, answered, closed
|
||||||
|
buyer_id VARCHAR(50),
|
||||||
|
buyer_nickname VARCHAR(100),
|
||||||
|
question_date TIMESTAMPTZ,
|
||||||
|
answer_date TIMESTAMPTZ,
|
||||||
|
raw_json JSONB,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_marketplace_questions_status
|
||||||
|
ON marketplace_questions(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_marketplace_questions_listing
|
||||||
|
ON marketplace_questions(listing_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_marketplace_questions_external
|
||||||
|
ON marketplace_questions(external_question_id);
|
||||||
118
pos/migrations/v3.5_whatsapp_state_machine.sql
Normal file
118
pos/migrations/v3.5_whatsapp_state_machine.sql
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
-- : SKIP
|
||||||
|
-- ============================================================
|
||||||
|
-- v3.5 WhatsApp State Machine
|
||||||
|
-- Reorganización del chatbot de AI libre a flujo estructurado
|
||||||
|
--
|
||||||
|
-- NOTE: This migration requires the WhatsApp tables (whatsapp_sessions,
|
||||||
|
-- whatsapp_messages) to be present. Tenant DBs without WhatsApp enabled
|
||||||
|
-- should skip this file.
|
||||||
|
-- Marked with ': SKIP' so the runner skips it unless WhatsApp is configured.
|
||||||
|
-- To apply manually on a tenant with WhatsApp tables:
|
||||||
|
-- psql <tenant_db> -f pos/migrations/v3.5_whatsapp_state_machine.sql
|
||||||
|
-- (Remove the ': SKIP' line above before manual execution.)
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
-- 1. Extender whatsapp_sessions con estado y contexto
|
||||||
|
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'whatsapp_sessions') THEN
|
||||||
|
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();
|
||||||
|
|
||||||
|
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);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 2. Tabla de vínculo persistente WA ID ↔ Cliente
|
||||||
|
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'customers') THEN
|
||||||
|
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);
|
||||||
|
|
||||||
|
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();
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 3. Tabla de sesiones de aprendizaje (piezas no resueltas)
|
||||||
|
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'customers')
|
||||||
|
AND EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'inventory')
|
||||||
|
AND EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'sales') THEN
|
||||||
|
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);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 4. Tabla de configuración de envío por sucursal
|
||||||
|
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'branches') THEN
|
||||||
|
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()
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 5. Agregar push_name a whatsapp_messages
|
||||||
|
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'whatsapp_messages') THEN
|
||||||
|
ALTER TABLE whatsapp_messages
|
||||||
|
ADD COLUMN IF NOT EXISTS push_name VARCHAR(200);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 6. Migrar datos existentes
|
||||||
|
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'whatsapp_sessions')
|
||||||
|
AND EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'wa_customer_links')
|
||||||
|
AND EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'customers') THEN
|
||||||
|
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;
|
||||||
|
|
||||||
|
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;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
18
pos/migrations/v3.6_dropshipping.sql
Normal file
18
pos/migrations/v3.6_dropshipping.sql
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- v3.6 Dropshipping API Integration
|
||||||
|
-- ============================================================
|
||||||
|
-- Adds config keys and webhook targets for external
|
||||||
|
-- dropshipping platforms.
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- Webhook targets for dropshipping notifications per tenant
|
||||||
|
CREATE TABLE IF NOT EXISTS dropshipping_webhooks (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
event_type VARCHAR(30) NOT NULL, -- stock_updated, price_updated, sale_made
|
||||||
|
target_url TEXT NOT NULL,
|
||||||
|
is_active BOOLEAN DEFAULT true,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_dropshipping_webhooks_event
|
||||||
|
ON dropshipping_webhooks(event_type) WHERE is_active = true;
|
||||||
22
pos/migrations/v3.7_sku_aliases.sql
Normal file
22
pos/migrations/v3.7_sku_aliases.sql
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- v3.7 SKU Aliases (multiple SKUs per inventory item)
|
||||||
|
-- ============================================================
|
||||||
|
-- Allows registering 2-3 alternative part numbers/SKUs for the
|
||||||
|
-- same product (e.g. different supplier SKUs).
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS inventory_sku_aliases (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
inventory_id INTEGER NOT NULL REFERENCES inventory(id) ON DELETE CASCADE,
|
||||||
|
sku VARCHAR(100) NOT NULL,
|
||||||
|
label VARCHAR(50), -- e.g. "Bodega A", "Proveedor X"
|
||||||
|
is_active BOOLEAN DEFAULT true,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
CONSTRAINT inventory_sku_aliases_unique_sku
|
||||||
|
UNIQUE (inventory_id, sku)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_inventory_sku_aliases_inventory
|
||||||
|
ON inventory_sku_aliases(inventory_id) WHERE is_active = true;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_inventory_sku_aliases_sku
|
||||||
|
ON inventory_sku_aliases(sku) WHERE is_active = true;
|
||||||
63
pos/migrations/v3.8_supplier_catalog.sql
Normal file
63
pos/migrations/v3.8_supplier_catalog.sql
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
-- v3.8 — Supplier Catalog tables
|
||||||
|
-- Adds supplier_catalog, supplier_catalog_compat, and supplier_catalog_interchange
|
||||||
|
-- to support multi-supplier parts injection into the vehicle catalog.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS supplier_catalog (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
tenant_id INTEGER NOT NULL,
|
||||||
|
supplier_name VARCHAR(255) NOT NULL,
|
||||||
|
sku VARCHAR(255) NOT NULL,
|
||||||
|
name VARCHAR(500) NOT NULL,
|
||||||
|
category VARCHAR(255),
|
||||||
|
description TEXT,
|
||||||
|
image_url TEXT,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS supplier_catalog_tenant_id_supplier_name_sku_category_key
|
||||||
|
ON supplier_catalog (tenant_id, supplier_name, sku, category);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sc_supplier
|
||||||
|
ON supplier_catalog (tenant_id, supplier_name, is_active);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sc_sku
|
||||||
|
ON supplier_catalog (tenant_id, sku, category);
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS supplier_catalog_compat (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
catalog_id INTEGER NOT NULL REFERENCES supplier_catalog(id) ON DELETE CASCADE,
|
||||||
|
make VARCHAR(255),
|
||||||
|
model VARCHAR(255),
|
||||||
|
year INTEGER,
|
||||||
|
engine VARCHAR(255),
|
||||||
|
engine_code VARCHAR(255),
|
||||||
|
model_year_engine_id INTEGER,
|
||||||
|
source VARCHAR(50) DEFAULT 'import',
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS supplier_catalog_compat_catalog_id_make_model_year_engine_key
|
||||||
|
ON supplier_catalog_compat (catalog_id, make, model, year, engine);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_scc_catalog
|
||||||
|
ON supplier_catalog_compat (catalog_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_scc_vehicle
|
||||||
|
ON supplier_catalog_compat (make, model, year);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_scc_mye
|
||||||
|
ON supplier_catalog_compat (model_year_engine_id);
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS supplier_catalog_interchange (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
catalog_id INTEGER NOT NULL REFERENCES supplier_catalog(id) ON DELETE CASCADE,
|
||||||
|
brand VARCHAR(255),
|
||||||
|
part_number VARCHAR(255),
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sci_catalog
|
||||||
|
ON supplier_catalog_interchange (catalog_id);
|
||||||
42
pos/migrations/v3.9_supplier_catalog_prices.sql
Normal file
42
pos/migrations/v3.9_supplier_catalog_prices.sql
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
-- : SKIP
|
||||||
|
-- v3.9_supplier_catalog_prices.sql
|
||||||
|
-- Per-tenant supplier pricing for items in the master supplier_catalog.
|
||||||
|
-- This table lives in the master DB and is joined by tenant_id.
|
||||||
|
-- Apply manually to the master database.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS supplier_catalog_prices (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
tenant_id INTEGER NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||||
|
catalog_id INTEGER NOT NULL REFERENCES supplier_catalog(id) ON DELETE CASCADE,
|
||||||
|
price NUMERIC(12,2) NOT NULL,
|
||||||
|
currency VARCHAR(3) DEFAULT 'MXN',
|
||||||
|
effective_from DATE DEFAULT CURRENT_DATE,
|
||||||
|
effective_to DATE,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(tenant_id, catalog_id, effective_from)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_supplier_catalog_prices_tenant_catalog
|
||||||
|
ON supplier_catalog_prices(tenant_id, catalog_id, effective_from DESC)
|
||||||
|
WHERE is_active = true;
|
||||||
|
|
||||||
|
-- Index for quick "latest active price" lookups per tenant+item.
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_supplier_catalog_prices_lookup
|
||||||
|
ON supplier_catalog_prices(tenant_id, catalog_id, effective_from DESC, is_active);
|
||||||
|
|
||||||
|
-- Trigger to keep updated_at current on row changes.
|
||||||
|
CREATE OR REPLACE FUNCTION update_supplier_catalog_prices_updated_at()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = NOW();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_supplier_catalog_prices_updated_at ON supplier_catalog_prices;
|
||||||
|
CREATE TRIGGER trg_supplier_catalog_prices_updated_at
|
||||||
|
BEFORE UPDATE ON supplier_catalog_prices
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_supplier_catalog_prices_updated_at();
|
||||||
253
pos/migrations/v4.0_multi_branch.sql
Normal file
253
pos/migrations/v4.0_multi_branch.sql
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
-- v4.0_multi_branch.sql
|
||||||
|
-- Multi-branch overhaul: branch fiscal data + shared inventory with per-branch stock.
|
||||||
|
-- WARNING: this migration restructures inventory data. A full DB backup is required.
|
||||||
|
|
||||||
|
-- ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
-- 1. BRANCHES: fiscal fields + main flag
|
||||||
|
-- ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
ALTER TABLE branches
|
||||||
|
ADD COLUMN IF NOT EXISTS is_main BOOLEAN DEFAULT FALSE,
|
||||||
|
ADD COLUMN IF NOT EXISTS rfc VARCHAR(13),
|
||||||
|
ADD COLUMN IF NOT EXISTS razon_social VARCHAR(300),
|
||||||
|
ADD COLUMN IF NOT EXISTS regimen_fiscal VARCHAR(10),
|
||||||
|
ADD COLUMN IF NOT EXISTS cp VARCHAR(5),
|
||||||
|
ADD COLUMN IF NOT EXISTS direccion_fiscal TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS serie_cfdi VARCHAR(10) DEFAULT 'A',
|
||||||
|
ADD COLUMN IF NOT EXISTS folio_inicio INTEGER DEFAULT 1,
|
||||||
|
ADD COLUMN IF NOT EXISTS folio_actual INTEGER DEFAULT 1,
|
||||||
|
ADD COLUMN IF NOT EXISTS email VARCHAR(200);
|
||||||
|
|
||||||
|
-- Ensure at least one branch is marked main (the first one created).
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
main_branch_id INTEGER;
|
||||||
|
branch_count INTEGER;
|
||||||
|
BEGIN
|
||||||
|
SELECT COUNT(*) INTO branch_count FROM branches;
|
||||||
|
|
||||||
|
IF branch_count = 0 THEN
|
||||||
|
INSERT INTO branches (name, is_main)
|
||||||
|
VALUES ('Principal', TRUE);
|
||||||
|
ELSE
|
||||||
|
SELECT id INTO main_branch_id FROM branches ORDER BY id LIMIT 1;
|
||||||
|
|
||||||
|
UPDATE branches SET is_main = FALSE;
|
||||||
|
UPDATE branches SET is_main = TRUE WHERE id = main_branch_id;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Constraint: only one main branch per tenant.
|
||||||
|
-- Because this runs inside a single tenant DB, a simple partial unique index is enough.
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_branches_single_main
|
||||||
|
ON branches (is_main)
|
||||||
|
WHERE is_main = TRUE;
|
||||||
|
|
||||||
|
-- ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
-- 2. INVENTORY STOCK: new per-branch stock table
|
||||||
|
-- ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS inventory_stock (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
inventory_id INTEGER NOT NULL REFERENCES inventory(id) ON DELETE CASCADE,
|
||||||
|
branch_id INTEGER NOT NULL REFERENCES branches(id) ON DELETE CASCADE,
|
||||||
|
stock INTEGER DEFAULT 0,
|
||||||
|
location VARCHAR(50),
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(inventory_id, branch_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_inventory_stock_branch ON inventory_stock(branch_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_inventory_stock_inventory ON inventory_stock(inventory_id);
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION update_inventory_stock_updated_at()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = NOW();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_inventory_stock_updated_at ON inventory_stock;
|
||||||
|
CREATE TRIGGER trg_inventory_stock_updated_at
|
||||||
|
BEFORE UPDATE ON inventory_stock
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_inventory_stock_updated_at();
|
||||||
|
|
||||||
|
-- ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
-- 3. INVENTORY: make branch_id nullable + prepare for consolidation
|
||||||
|
-- ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
-- Drop the old unique constraint that forces one record per (branch, part_number).
|
||||||
|
DROP INDEX IF EXISTS idx_inventory_branch_part;
|
||||||
|
|
||||||
|
-- Make branch_id nullable so we can have master records without a branch.
|
||||||
|
ALTER TABLE inventory ALTER COLUMN branch_id DROP NOT NULL;
|
||||||
|
|
||||||
|
-- Add unique constraint on part_number at tenant level so a product exists once.
|
||||||
|
-- If duplicates still exist this will fail, so we consolidate below first.
|
||||||
|
-- We create it at the end of this migration after deduplication.
|
||||||
|
|
||||||
|
-- ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
-- 4. DATA MIGRATION: consolidate duplicated inventory rows by part_number
|
||||||
|
-- ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
-- Build a mapping: for each duplicated part_number, choose the master record.
|
||||||
|
-- Master = record belonging to the main branch; fallback = oldest id.
|
||||||
|
CREATE TEMP TABLE _inventory_master_map AS
|
||||||
|
SELECT DISTINCT ON (part_number)
|
||||||
|
id AS master_id,
|
||||||
|
part_number
|
||||||
|
FROM inventory
|
||||||
|
ORDER BY part_number,
|
||||||
|
CASE WHEN branch_id = (SELECT id FROM branches WHERE is_main = TRUE LIMIT 1) THEN 0 ELSE 1 END,
|
||||||
|
id ASC;
|
||||||
|
|
||||||
|
-- Create temp table of duplicates (all rows that are NOT the master for their part_number).
|
||||||
|
CREATE TEMP TABLE _inventory_duplicates AS
|
||||||
|
SELECT i.id AS duplicate_id, m.master_id
|
||||||
|
FROM inventory i
|
||||||
|
JOIN _inventory_master_map m ON i.part_number = m.part_number
|
||||||
|
WHERE i.id <> m.master_id;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- Compute per-duplicate stock and insert into inventory_stock against master_id + duplicate's branch.
|
||||||
|
INSERT INTO inventory_stock (inventory_id, branch_id, stock, location)
|
||||||
|
SELECT
|
||||||
|
dups.master_id,
|
||||||
|
dups.branch_id,
|
||||||
|
GREATEST(0, COALESCE(stock_by_dup.stock, 0))::int,
|
||||||
|
dups.location
|
||||||
|
FROM (
|
||||||
|
SELECT d.master_id, d.duplicate_id, i.branch_id, i.location
|
||||||
|
FROM _inventory_duplicates d
|
||||||
|
JOIN inventory i ON i.id = d.duplicate_id
|
||||||
|
) dups
|
||||||
|
JOIN LATERAL (
|
||||||
|
SELECT COALESCE(SUM(quantity), 0) AS stock
|
||||||
|
FROM inventory_operations
|
||||||
|
WHERE inventory_id = dups.duplicate_id AND branch_id = dups.branch_id
|
||||||
|
) stock_by_dup ON TRUE
|
||||||
|
ON CONFLICT (inventory_id, branch_id) DO UPDATE
|
||||||
|
SET stock = inventory_stock.stock + EXCLUDED.stock;
|
||||||
|
|
||||||
|
-- Also migrate stock from master records themselves (they were already in inventory.branch_id).
|
||||||
|
INSERT INTO inventory_stock (inventory_id, branch_id, stock, location)
|
||||||
|
SELECT
|
||||||
|
i.id,
|
||||||
|
i.branch_id,
|
||||||
|
GREATEST(0, COALESCE(stock_by_inv.stock, 0))::int,
|
||||||
|
i.location
|
||||||
|
FROM inventory i
|
||||||
|
JOIN _inventory_master_map m ON i.id = m.master_id
|
||||||
|
JOIN LATERAL (
|
||||||
|
SELECT COALESCE(SUM(quantity), 0) AS stock
|
||||||
|
FROM inventory_operations
|
||||||
|
WHERE inventory_id = i.id AND branch_id = i.branch_id
|
||||||
|
) stock_by_inv ON TRUE
|
||||||
|
WHERE i.branch_id IS NOT NULL
|
||||||
|
ON CONFLICT (inventory_id, branch_id) DO UPDATE
|
||||||
|
SET stock = EXCLUDED.stock;
|
||||||
|
|
||||||
|
-- Handle inventory_stock_summary specially: it has PK on inventory_id.
|
||||||
|
-- If master already has a summary row, add duplicate's stock and remove duplicate row.
|
||||||
|
-- Otherwise repoint the duplicate row to master.
|
||||||
|
UPDATE inventory_stock_summary s
|
||||||
|
SET stock = s.stock + d.stock
|
||||||
|
FROM (
|
||||||
|
SELECT duplicate_id, master_id, stock FROM inventory_stock_summary ss
|
||||||
|
JOIN _inventory_duplicates m ON ss.inventory_id = m.duplicate_id
|
||||||
|
WHERE EXISTS (SELECT 1 FROM inventory_stock_summary sm WHERE sm.inventory_id = m.master_id)
|
||||||
|
) d
|
||||||
|
WHERE s.inventory_id = d.master_id;
|
||||||
|
|
||||||
|
DELETE FROM inventory_stock_summary
|
||||||
|
WHERE inventory_id IN (
|
||||||
|
SELECT m.duplicate_id
|
||||||
|
FROM _inventory_duplicates m
|
||||||
|
WHERE EXISTS (SELECT 1 FROM inventory_stock_summary sm WHERE sm.inventory_id = m.master_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
UPDATE inventory_stock_summary
|
||||||
|
SET inventory_id = m.master_id
|
||||||
|
FROM _inventory_duplicates m
|
||||||
|
WHERE inventory_id = m.duplicate_id;
|
||||||
|
|
||||||
|
-- Update FK references from duplicate inventory rows to master inventory rows.
|
||||||
|
-- We use dynamic SQL to update every known referencing table.
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
rec RECORD;
|
||||||
|
fk_sql TEXT;
|
||||||
|
BEGIN
|
||||||
|
FOR rec IN
|
||||||
|
SELECT
|
||||||
|
tc.table_name,
|
||||||
|
kcu.column_name
|
||||||
|
FROM information_schema.table_constraints tc
|
||||||
|
JOIN information_schema.key_column_usage kcu
|
||||||
|
ON tc.constraint_name = kcu.constraint_name
|
||||||
|
JOIN information_schema.constraint_column_usage ccu
|
||||||
|
ON ccu.constraint_name = tc.constraint_name
|
||||||
|
WHERE tc.constraint_type = 'FOREIGN KEY'
|
||||||
|
AND ccu.table_name = 'inventory'
|
||||||
|
AND tc.table_name <> 'inventory_stock_summary'
|
||||||
|
LOOP
|
||||||
|
fk_sql := format(
|
||||||
|
'UPDATE %I SET %I = m.master_id FROM _inventory_duplicates m WHERE %I = m.duplicate_id',
|
||||||
|
rec.table_name, rec.column_name, rec.column_name
|
||||||
|
);
|
||||||
|
EXECUTE fk_sql;
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Delete duplicate inventory rows now that FKs are repointed.
|
||||||
|
DELETE FROM inventory
|
||||||
|
WHERE id IN (SELECT duplicate_id FROM _inventory_duplicates);
|
||||||
|
|
||||||
|
-- Clean up master records: remove branch_id so they become shared catalog items.
|
||||||
|
UPDATE inventory SET branch_id = NULL WHERE branch_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- Now safe to enforce uniqueness at tenant level.
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_inventory_part_unique ON inventory (part_number);
|
||||||
|
|
||||||
|
-- Clean temp tables.
|
||||||
|
DROP TABLE IF EXISTS _inventory_master_map;
|
||||||
|
DROP TABLE IF EXISTS _inventory_duplicates;
|
||||||
|
|
||||||
|
-- ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
-- 5. CFDI_QUEUE: allow sale_id to be NULL for global invoices (Phase 3 prep)
|
||||||
|
-- ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
ALTER TABLE cfdi_queue ALTER COLUMN sale_id DROP NOT NULL;
|
||||||
|
|
||||||
|
-- ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
-- 6. TRIGGER: Keep inventory_stock in sync with inventory_operations
|
||||||
|
-- ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION update_inventory_stock()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
-- Skip operations that are not tied to a specific branch.
|
||||||
|
-- Per-branch stock tracking requires a branch_id; without it we can't
|
||||||
|
-- assign the stock to any location.
|
||||||
|
IF NEW.branch_id IS NULL THEN
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
INSERT INTO inventory_stock (inventory_id, branch_id, stock)
|
||||||
|
VALUES (NEW.inventory_id, NEW.branch_id, NEW.quantity)
|
||||||
|
ON CONFLICT (inventory_id, branch_id) DO UPDATE
|
||||||
|
SET stock = inventory_stock.stock + EXCLUDED.stock,
|
||||||
|
updated_at = NOW();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_update_inventory_stock ON inventory_operations;
|
||||||
|
CREATE TRIGGER trg_update_inventory_stock
|
||||||
|
AFTER INSERT ON inventory_operations
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_inventory_stock();
|
||||||
17
pos/migrations/v4.1_global_invoice.sql
Normal file
17
pos/migrations/v4.1_global_invoice.sql
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
-- v4.1 — Global Invoice (Factura Global Mensual)
|
||||||
|
-- Supports grouping cash sales (<= $2,000) into a single monthly CFDI.
|
||||||
|
|
||||||
|
-- Link global invoices to their constituent sales
|
||||||
|
CREATE TABLE IF NOT EXISTS global_invoice_sales (
|
||||||
|
global_invoice_id INTEGER NOT NULL REFERENCES cfdi_queue(id) ON DELETE CASCADE,
|
||||||
|
sale_id INTEGER NOT NULL REFERENCES sales(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (global_invoice_id, sale_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_gis_global ON global_invoice_sales(global_invoice_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_gis_sale ON global_invoice_sales(sale_id);
|
||||||
|
|
||||||
|
-- Track which sales have been included in any global invoice
|
||||||
|
-- (quick lookup without joining global_invoice_sales)
|
||||||
|
ALTER TABLE sales ADD COLUMN IF NOT EXISTS global_invoiced_at TIMESTAMPTZ;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sales_global_invoiced_at ON sales(global_invoiced_at) WHERE global_invoiced_at IS NULL;
|
||||||
14
pos/migrations/v4.2_meli_sync_queue.sql
Normal file
14
pos/migrations/v4.2_meli_sync_queue.sql
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
-- v4.2 — MercadoLibre sync queue for stock synchronization
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS meli_sync_queue (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
inventory_id INTEGER NOT NULL REFERENCES inventory(id),
|
||||||
|
action VARCHAR(20) NOT NULL DEFAULT 'stock_update',
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||||
|
retry_count INTEGER DEFAULT 0,
|
||||||
|
error_message TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
processed_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_meli_sync_pending ON meli_sync_queue(status, created_at) WHERE status = 'pending';
|
||||||
42
pos/migrations/v4.3_facturapi.sql
Normal file
42
pos/migrations/v4.3_facturapi.sql
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
-- v4.3_facturapi.sql
|
||||||
|
-- Migrate CFDI timbrado from Horux360 XML pipeline to Facturapi JSON API.
|
||||||
|
--
|
||||||
|
-- Changes:
|
||||||
|
-- - Rename cfdi_queue.xml_unsigned -> payload_unsigned (stores Facturapi JSON payload)
|
||||||
|
-- - Keep xml_signed for the signed XML returned by Facturapi
|
||||||
|
-- - Add external_id column to store Facturapi invoice id
|
||||||
|
-- - Add facturapi config keys to tenant_config
|
||||||
|
|
||||||
|
-- ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
-- 1. CFDI_QUEUE: adapt schema for Facturapi payloads
|
||||||
|
-- ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'cfdi_queue' AND column_name = 'xml_unsigned'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE cfdi_queue RENAME COLUMN xml_unsigned TO payload_unsigned;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN cfdi_queue.payload_unsigned IS 'Facturapi JSON payload (previously unsigned XML for Horux)';
|
||||||
|
COMMENT ON COLUMN cfdi_queue.xml_signed IS 'Signed+stamped XML returned by Facturapi';
|
||||||
|
|
||||||
|
ALTER TABLE cfdi_queue ADD COLUMN IF NOT EXISTS external_id VARCHAR(64);
|
||||||
|
COMMENT ON COLUMN cfdi_queue.external_id IS 'Facturapi invoice id';
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cfdi_queue_external_id ON cfdi_queue(external_id);
|
||||||
|
|
||||||
|
-- ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
-- 2. TENANT_CONFIG: Facturapi configuration keys
|
||||||
|
-- ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
INSERT INTO tenant_config (key, value)
|
||||||
|
VALUES
|
||||||
|
('cfdi_facturapi_key', ''),
|
||||||
|
('cfdi_facturapi_org_id', ''),
|
||||||
|
('cfdi_facturapi_customer_sync', 'true')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
|
||||||
|
-- Backward-compat: migrate old Horux keys to comments so they are not used anymore
|
||||||
|
COMMENT ON TABLE tenant_config IS 'tenant_config; old keys cfdi_horux_api_url and cfdi_horux_api_key are deprecated';
|
||||||
@@ -7,3 +7,4 @@ gunicorn>=22.0
|
|||||||
redis>=5.0
|
redis>=5.0
|
||||||
meilisearch>=0.40
|
meilisearch>=0.40
|
||||||
orjson
|
orjson
|
||||||
|
facturapi>=1.0
|
||||||
|
|||||||
@@ -86,11 +86,42 @@ def _post_chat_completion(url, api_key, model_id, messages, max_tokens=800, temp
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
SYSTEM_PROMPT_SHORT = """Eres un asistente de refaccionaria automotriz mexicana. Ayuda a encontrar autopartes.
|
SYSTEM_PROMPT_SHORT = """Eres Juan, vendedor estrella de Autopartes Estrada. Llevas 10 años ayudando a mecanicos y dueños de taller. Tu estilo: directo, calido, sin rollos tecnicos. Hablas como un compa que sabe de carros.
|
||||||
|
|
||||||
|
IMPORTANTE: NO prometas stock hasta verificar. Usa "Reviso...", "Busco...", "Déjame checar..." en vez de "Tengo..." a menos que estes 100% seguro.
|
||||||
|
|
||||||
Responde SIEMPRE en formato JSON: {"message":"...","search_query":"...","vehicle":{"brand":"...","model":"...","year":...}}
|
Responde SIEMPRE en formato JSON: {"message":"...","search_query":"...","vehicle":{"brand":"...","model":"...","year":...}}
|
||||||
search_query va EN INGLES cuando el usuario pide una parte. Traducciones: Balatas=Brake Pad, Disco de freno=Brake Disc, Amortiguador=Shock Absorber, Filtro de aceite=Oil Filter, Filtro de aire=Air Filter, Bujias=Spark Plug, Banda=V-Belt, Bomba de agua=Water Pump, Alternador=Alternator, Radiador=Radiator, Sensor de oxigeno=Oxygen Sensor, Terminal de direccion=Tie Rod End, Bomba de gasolina=Fuel Pump, Clutch=Clutch Kit, Mofle=Exhaust, Inyector=Injector.
|
|
||||||
No preguntes mas si ya puedes buscar. Si el usuario describe un sintoma, diagnostica y sugiere partes.
|
REGLAS DE VENTA AVANZADAS:
|
||||||
Cuando pida cotizacion o multiples partes, search_query DEBE usar | para separar cada parte: "Brake Pad|Air Filter|Oil Filter|Spark Plug".
|
1. PRECIO AL FRENTE: Si hay stock, di precio y marca sin rodeos.
|
||||||
|
2. KIT INTELIGENTE: Siempre sugiere 1-2 productos relacionados que se necesitan para el mismo trabajo.
|
||||||
|
- Balatas → "Ya que vas a cambiar balatas, checa si los discos tambien estan gastados. Te armo paquete con descuento."
|
||||||
|
- Alternador → "Mientras cambias alternador, conviene cambiar la banda serpentina para que no se te rompa despues."
|
||||||
|
- Filtro de aceite → "¿Ya tienes filtro de aire y bujias? Para servicio completo conviene cambiar todo junto."
|
||||||
|
3. MANEJO DE OBJECIONES:
|
||||||
|
- "Esta caro" → "Te entiendo. Esta es marca original. Tambien manejo opcion economica. ¿Te mando las dos para comparar?"
|
||||||
|
- "Voy a checar en otro lado" → "Dale, te espero. Guardame este precio. Si encuentras mas barato, mandame foto de la cotizacion y veo si te la mejoro."
|
||||||
|
- "Lo necesito para hoy" / "Urgente" → "Perfecto. Tenemos entrega express en 2-4 horas o puedes pasar directo a la tienda. ¿Te lo armo ya?"
|
||||||
|
- "No se si sea esa" → "No hay problema. Dame los ultimos 4 digitos de tu VIN y te confirmo compatibilidad exacta."
|
||||||
|
- "Solo estoy cotizando" → "Claro, sin compromiso. Te armo la cotizacion y si decides despues, aqui queda guardada."
|
||||||
|
4. CIERRE SUAVE (termina SIEMPRE con pregunta):
|
||||||
|
- "¿Te lo aparto?"
|
||||||
|
- "¿Lo mando a tu taller o lo pasas a recoger?"
|
||||||
|
- "¿Con esto quedas o necesitas algo mas?"
|
||||||
|
- "¿Te armo el paquete completo? Sale mejor que por separado."
|
||||||
|
5. RECONOCIMIENTO DE CLIENTE: Si el contexto dice que compro antes, mencionalo. "Veo que compraste balatas hace 6 meses. ¿Ya es hora de cambiar las del otro eje?"
|
||||||
|
6. DIAGNOSTICO RAPIDO: Si describe sintoma, diagnostica en 1-2 frases y sugiere 2-3 partes mas probables.
|
||||||
|
|
||||||
|
TRADUCCIONES search_query (EN INGLES):
|
||||||
|
Balatas=Brake Pad, Disco de freno=Brake Disc, Amortiguador=Shock Absorber, Filtro de aceite=Oil Filter, Filtro de aire=Air Filter, Bujias=Spark Plug, Banda=V-Belt, Bomba de agua=Water Pump, Alternador=Alternator, Radiador=Radiator, Sensor de oxigeno=Oxygen Sensor, Terminal de direccion=Tie Rod End, Bomba de gasolina=Fuel Pump, Clutch=Clutch Kit, Mofle=Exhaust, Inyector=Injector, Banda de distribucion=Timing Belt, Tensor=Belt Tensioner, Junta homocinetica=CV Joint, Marcha=Starter Motor, Bateria=Battery, Aceite=Engine Oil, Refrigerante=Coolant.
|
||||||
|
|
||||||
|
FORMATO:
|
||||||
|
- search_query EN INGLES. NUNCA null si pide algo.
|
||||||
|
- vehicle: {"brand":"NISSAN","model":"Frontier","year":2019} marca en MAYUSCULAS.
|
||||||
|
- Multiples partes: "Brake Pad|Brake Disc|Brake Fluid"
|
||||||
|
- Mensaje maximo 4 lineas cortas. Lenguaje natural, nada robotico.
|
||||||
|
- Si ya detectaste vehiculo en conversacion anterior, NO vuelvas a pedirlo.
|
||||||
|
- Termina SIEMPRE con una pregunta de cierre.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
SYSTEM_PROMPT = """Eres un asistente de refaccionaria automotriz mexicana. Tu trabajo es ayudar a encontrar autopartes.
|
SYSTEM_PROMPT = """Eres un asistente de refaccionaria automotriz mexicana. Tu trabajo es ayudar a encontrar autopartes.
|
||||||
@@ -195,11 +226,24 @@ def get_inventory_context(tenant_conn, branch_id=None):
|
|||||||
WHERE {where} AND i.brand IS NOT NULL AND i.brand != ''
|
WHERE {where} AND i.brand IS NOT NULL AND i.brand != ''
|
||||||
GROUP BY i.brand
|
GROUP BY i.brand
|
||||||
ORDER BY cnt DESC
|
ORDER BY cnt DESC
|
||||||
LIMIT 15
|
LIMIT 10
|
||||||
""", params)
|
""", params)
|
||||||
brands = cur.fetchall()
|
brands = cur.fetchall()
|
||||||
brand_list = ", ".join(f"{row[0]} ({row[1]})" for row in brands if row[0])
|
brand_list = ", ".join(f"{row[0]} ({row[1]})" for row in brands if row[0])
|
||||||
|
|
||||||
|
# Top categories with counts
|
||||||
|
cur.execute(f"""
|
||||||
|
SELECT c.name, COUNT(*) as cnt
|
||||||
|
FROM inventory i
|
||||||
|
JOIN part_categories c ON c.id = i.category_id
|
||||||
|
WHERE {where} AND c.name IS NOT NULL AND c.name != ''
|
||||||
|
GROUP BY c.name
|
||||||
|
ORDER BY cnt DESC
|
||||||
|
LIMIT 10
|
||||||
|
""", params)
|
||||||
|
categories = cur.fetchall()
|
||||||
|
category_list = ", ".join(f"{row[0]} ({row[1]})" for row in categories if row[0])
|
||||||
|
|
||||||
# Products with low stock (<=3)
|
# Products with low stock (<=3)
|
||||||
cur.execute(f"""
|
cur.execute(f"""
|
||||||
SELECT COUNT(*) FROM inventory i
|
SELECT COUNT(*) FROM inventory i
|
||||||
@@ -212,10 +256,12 @@ def get_inventory_context(tenant_conn, branch_id=None):
|
|||||||
"CONTEXTO DEL INVENTARIO:",
|
"CONTEXTO DEL INVENTARIO:",
|
||||||
f"Este negocio tiene {total} productos en inventario.",
|
f"Este negocio tiene {total} productos en inventario.",
|
||||||
]
|
]
|
||||||
|
if category_list:
|
||||||
|
lines.append(f"Categorias principales: {category_list}")
|
||||||
if brand_list:
|
if brand_list:
|
||||||
lines.append(f"Marcas disponibles: {brand_list}")
|
lines.append(f"Marcas top: {brand_list}")
|
||||||
lines.append(f"Productos con stock bajo (<=3 unidades): {low_stock}")
|
lines.append(f"Productos con stock bajo (<=3 unidades): {low_stock}")
|
||||||
lines.append("IMPORTANTE: Cuando busques partes, SIEMPRE prioriza lo que el negocio tiene en inventario local.")
|
lines.append("IMPORTANTE: Cuando busques partes, SIEMPRE prioriza lo que el negocio tiene en inventario local. Si no hay stock exacto, sugiere alternativa similar.")
|
||||||
|
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -284,10 +330,10 @@ def chat_with_image(user_message, image_base64, conversation_history=None, inven
|
|||||||
]
|
]
|
||||||
messages.append({"role": "user", "content": user_content})
|
messages.append({"role": "user", "content": user_content})
|
||||||
|
|
||||||
# Try Hermes first for vision (if enabled), fallback to OpenRouter
|
# Vision backends: QWEN only, fallback to OpenRouter if key present
|
||||||
backends = []
|
backends = []
|
||||||
if HERMES_ENABLED:
|
if QWEN_ENABLED:
|
||||||
backends.append((HERMES_CHAT_URL, HERMES_API_KEY, HERMES_VISION_MODEL))
|
backends.append((QWEN_CHAT_URL, QWEN_API_KEY, QWEN_MODEL))
|
||||||
if OPENROUTER_API_KEY:
|
if OPENROUTER_API_KEY:
|
||||||
backends.append((OPENROUTER_URL, OPENROUTER_API_KEY, VISION_MODEL))
|
backends.append((OPENROUTER_URL, OPENROUTER_API_KEY, VISION_MODEL))
|
||||||
|
|
||||||
@@ -339,10 +385,10 @@ def classify_part(part_number):
|
|||||||
{"role": "user", "content": prompt}
|
{"role": "user", "content": prompt}
|
||||||
]
|
]
|
||||||
|
|
||||||
# Try Hermes first (if enabled), fallback to OpenRouter
|
# Backends: QWEN only, fallback to OpenRouter if key present
|
||||||
backends = []
|
backends = []
|
||||||
if HERMES_ENABLED:
|
if QWEN_ENABLED:
|
||||||
backends.append((HERMES_CHAT_URL, HERMES_API_KEY, HERMES_MODEL))
|
backends.append((QWEN_CHAT_URL, QWEN_API_KEY, QWEN_MODEL))
|
||||||
if OPENROUTER_API_KEY:
|
if OPENROUTER_API_KEY:
|
||||||
backends.append((OPENROUTER_URL, OPENROUTER_API_KEY, MODEL))
|
backends.append((OPENROUTER_URL, OPENROUTER_API_KEY, MODEL))
|
||||||
|
|
||||||
@@ -528,12 +574,10 @@ def chat(user_message, conversation_history=None, inventory_context=None):
|
|||||||
|
|
||||||
last_error = None
|
last_error = None
|
||||||
|
|
||||||
# Build backend list: QWEN first (fast, ~1s), then Hermes (specialized, ~30s), then OpenRouter
|
# Build backend list: QWEN first, then OpenRouter fallback
|
||||||
backends = []
|
backends = []
|
||||||
if QWEN_ENABLED:
|
if QWEN_ENABLED:
|
||||||
backends.append((QWEN_CHAT_URL, QWEN_API_KEY, QWEN_MODEL, 35, SYSTEM_PROMPT_SHORT, 4000))
|
backends.append((QWEN_CHAT_URL, QWEN_API_KEY, QWEN_MODEL, 18, SYSTEM_PROMPT_SHORT, 1200))
|
||||||
if HERMES_ENABLED:
|
|
||||||
backends.append((HERMES_CHAT_URL, HERMES_API_KEY, HERMES_MODEL, 45, SYSTEM_PROMPT, 800))
|
|
||||||
if OPENROUTER_API_KEY:
|
if OPENROUTER_API_KEY:
|
||||||
for m in FALLBACK_MODELS:
|
for m in FALLBACK_MODELS:
|
||||||
backends.append((OPENROUTER_URL, OPENROUTER_API_KEY, m, 25, SYSTEM_PROMPT, 800))
|
backends.append((OPENROUTER_URL, OPENROUTER_API_KEY, m, 25, SYSTEM_PROMPT, 800))
|
||||||
@@ -548,14 +592,22 @@ def chat(user_message, conversation_history=None, inventory_context=None):
|
|||||||
if conversation_history:
|
if conversation_history:
|
||||||
msgs.extend(conversation_history)
|
msgs.extend(conversation_history)
|
||||||
msgs.append({"role": "user", "content": user_message})
|
msgs.append({"role": "user", "content": user_message})
|
||||||
|
|
||||||
|
# Retry logic: QWEN gets 3 attempts with 2s delay because the API is flaky
|
||||||
|
max_retries = 3 if url == QWEN_CHAT_URL else 1
|
||||||
|
result = None
|
||||||
|
for attempt in range(1, max_retries + 1):
|
||||||
result = _post_chat_completion(url, key, model_id, msgs, max_tokens=max_tok, temperature=0.3, timeout=timeout_sec)
|
result = _post_chat_completion(url, key, model_id, msgs, max_tokens=max_tok, temperature=0.3, timeout=timeout_sec)
|
||||||
|
if result is not None:
|
||||||
|
break
|
||||||
|
if attempt < max_retries:
|
||||||
|
print(f"[AI] QWEN attempt {attempt} failed, retrying in 2s...")
|
||||||
|
_time_chat.sleep(2)
|
||||||
|
|
||||||
if result is None:
|
if result is None:
|
||||||
if url == QWEN_CHAT_URL:
|
if url == QWEN_CHAT_URL:
|
||||||
print(f"[AI] QWEN failed, trying Hermes fallback...")
|
print(f"[AI] QWEN failed after {max_retries} attempts, trying fallback...")
|
||||||
last_error = "qwen_failed"
|
last_error = "qwen_failed"
|
||||||
elif url == HERMES_CHAT_URL:
|
|
||||||
print(f"[AI] Hermes failed, trying OpenRouter fallback...")
|
|
||||||
last_error = "hermes_timeout"
|
|
||||||
else:
|
else:
|
||||||
print(f"[AI] Rate limited on {model_id}, trying next model...")
|
print(f"[AI] Rate limited on {model_id}, trying next model...")
|
||||||
last_error = "rate_limit"
|
last_error = "rate_limit"
|
||||||
@@ -589,7 +641,7 @@ def chat(user_message, conversation_history=None, inventory_context=None):
|
|||||||
# All models exhausted — DON'T cache errors, we want retries next time
|
# All models exhausted — DON'T cache errors, we want retries next time
|
||||||
if last_error == "rate_limit":
|
if last_error == "rate_limit":
|
||||||
return {"message": "El asistente está ocupado. Intenta de nuevo en unos segundos.", "search_query": None, "vehicle": None}
|
return {"message": "El asistente está ocupado. Intenta de nuevo en unos segundos.", "search_query": None, "vehicle": None}
|
||||||
if last_error == "hermes_timeout":
|
if last_error == "qwen_failed":
|
||||||
return {"message": "El asistente tardó mucho en responder. Intenta de nuevo en un momento.", "search_query": None, "vehicle": None}
|
return {"message": "El asistente tardó mucho en responder. Intenta de nuevo en un momento.", "search_query": None, "vehicle": None}
|
||||||
return {
|
return {
|
||||||
"message": "El asistente no está disponible en este momento. Intenta de nuevo en unos segundos.",
|
"message": "El asistente no está disponible en este momento. Intenta de nuevo en unos segundos.",
|
||||||
|
|||||||
157
pos/services/catalog_import_service.py
Normal file
157
pos/services/catalog_import_service.py
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
"""Bulk catalog import service.
|
||||||
|
|
||||||
|
Imports products into inventory with optional vehicle compatibilities
|
||||||
|
and SKU aliases. Can auto-generate vehicle fitment via QWEN AI if
|
||||||
|
compatibilities are not provided.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def import_products(
|
||||||
|
tenant_conn,
|
||||||
|
products: list[dict],
|
||||||
|
branch_id: int,
|
||||||
|
auto_generate_compat: bool = False,
|
||||||
|
employee_id: Optional[int] = None,
|
||||||
|
):
|
||||||
|
"""Import a list of products into inventory.
|
||||||
|
|
||||||
|
Each product dict may contain:
|
||||||
|
- sku (str) *required
|
||||||
|
- name (str) *required
|
||||||
|
- brand (str)
|
||||||
|
- description (str)
|
||||||
|
- cost (float)
|
||||||
|
- price (float)
|
||||||
|
- stock (int)
|
||||||
|
- location (str)
|
||||||
|
- sku_aliases (list[dict]) [{"sku": str, "label": str}]
|
||||||
|
- vehicles (list[dict]) [{"make", "model", "year", "engine", "engine_code"}]
|
||||||
|
|
||||||
|
Returns {"imported": N, "failed": [{"sku": ..., "error": ...}], "compat_generated": M}
|
||||||
|
"""
|
||||||
|
cur = tenant_conn.cursor()
|
||||||
|
imported = 0
|
||||||
|
failed = []
|
||||||
|
compat_generated = 0
|
||||||
|
|
||||||
|
for idx, p in enumerate(products):
|
||||||
|
sku = (p.get("sku") or "").strip()
|
||||||
|
name = (p.get("name") or "").strip()
|
||||||
|
if not sku or not name:
|
||||||
|
failed.append({"index": idx, "sku": sku, "error": "sku and name are required"})
|
||||||
|
continue
|
||||||
|
|
||||||
|
brand = (p.get("brand") or "").strip() or None
|
||||||
|
description = (p.get("description") or "").strip() or None
|
||||||
|
cost = float(p.get("cost") or 0)
|
||||||
|
price = float(p.get("price") or 0)
|
||||||
|
stock = int(p.get("stock") or 0)
|
||||||
|
location = (p.get("location") or "").strip() or None
|
||||||
|
barcode = (p.get("barcode") or "").strip() or None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check for duplicate SKU in same branch
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id FROM inventory WHERE part_number = %s AND branch_id = %s AND is_active = true",
|
||||||
|
(sku, branch_id),
|
||||||
|
)
|
||||||
|
if cur.fetchone():
|
||||||
|
# Update existing item instead of creating new
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE inventory
|
||||||
|
SET name = %s, brand = %s, description = %s, cost = %s, price_1 = %s,
|
||||||
|
location = %s, barcode = COALESCE(%s, barcode), updated_at = NOW()
|
||||||
|
WHERE part_number = %s AND branch_id = %s AND is_active = true
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
(name, brand, description, cost, price, location, barcode, sku, branch_id),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
item_id = row[0]
|
||||||
|
else:
|
||||||
|
# Insert new item
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO inventory
|
||||||
|
(branch_id, part_number, barcode, name, description, brand,
|
||||||
|
unit, cost, price_1, price_2, price_3, tax_rate,
|
||||||
|
min_stock, max_stock, location, is_active)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, true)
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
branch_id, sku, barcode, name, description, brand,
|
||||||
|
"PZA", cost, price, price, price, 0.16,
|
||||||
|
0, 0, location,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
item_id = row[0]
|
||||||
|
|
||||||
|
# Record initial stock if provided
|
||||||
|
if stock > 0:
|
||||||
|
from services.inventory_engine import record_initial
|
||||||
|
record_initial(tenant_conn, item_id, branch_id, stock, cost)
|
||||||
|
|
||||||
|
# Insert SKU aliases
|
||||||
|
aliases = p.get("sku_aliases") or []
|
||||||
|
for alias in aliases:
|
||||||
|
alias_sku = (alias.get("sku") or "").strip()
|
||||||
|
label = (alias.get("label") or "").strip() or None
|
||||||
|
if alias_sku:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO inventory_sku_aliases (inventory_id, sku, label)
|
||||||
|
VALUES (%s, %s, %s)
|
||||||
|
ON CONFLICT (inventory_id, sku) DO UPDATE SET
|
||||||
|
is_active = true, label = EXCLUDED.label
|
||||||
|
""",
|
||||||
|
(item_id, alias_sku, label),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Insert manual vehicle compatibilities
|
||||||
|
vehicles = p.get("vehicles") or []
|
||||||
|
for v in vehicles:
|
||||||
|
make = (v.get("make") or "").strip()
|
||||||
|
model = (v.get("model") or "").strip()
|
||||||
|
year = v.get("year")
|
||||||
|
engine = (v.get("engine") or "").strip() or None
|
||||||
|
engine_code = (v.get("engine_code") or "").strip() or None
|
||||||
|
if make and model and year:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO inventory_vehicle_compat
|
||||||
|
(inventory_id, make, model, year, engine, engine_code, source, model_year_engine_id)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, 'manual', NULL)
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
""",
|
||||||
|
(item_id, make, model, year, engine, engine_code),
|
||||||
|
)
|
||||||
|
|
||||||
|
tenant_conn.commit()
|
||||||
|
imported += 1
|
||||||
|
|
||||||
|
# Auto-generate compat via QWEN if requested and no vehicles provided
|
||||||
|
if auto_generate_compat and not vehicles:
|
||||||
|
try:
|
||||||
|
from services.qwen_fitment import get_vehicle_fitment
|
||||||
|
from services.inventory_vehicle_compat import save_qwen_fitment
|
||||||
|
fitment = get_vehicle_fitment(sku, name, brand or "")
|
||||||
|
inserted = save_qwen_fitment(tenant_conn, item_id, fitment)
|
||||||
|
compat_generated += inserted
|
||||||
|
except Exception as qe:
|
||||||
|
logger.warning("QWEN auto-match failed for %s: %s", sku, qe)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
tenant_conn.rollback()
|
||||||
|
logger.warning("Import failed for sku=%s: %s", sku, e)
|
||||||
|
failed.append({"index": idx, "sku": sku, "error": str(e)})
|
||||||
|
|
||||||
|
cur.close()
|
||||||
|
return {"imported": imported, "failed": failed, "compat_generated": compat_generated}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -464,3 +464,130 @@ def build_pago_xml(payment, tenant_config, customer, original_uuid):
|
|||||||
|
|
||||||
return etree.tostring(root, xml_declaration=True, encoding='UTF-8',
|
return etree.tostring(root, xml_declaration=True, encoding='UTF-8',
|
||||||
pretty_print=True).decode('utf-8')
|
pretty_print=True).decode('utf-8')
|
||||||
|
|
||||||
|
|
||||||
|
def build_global_invoice_xml(sales, tenant_config, year, month):
|
||||||
|
"""Build CFDI 4.0 XML for a monthly global invoice (Factura Global).
|
||||||
|
|
||||||
|
Groups multiple cash sales (PUE, <= $2,000 each, no individual CFDI)
|
||||||
|
into a single CFDI tipo Ingreso with InformacionGlobal.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sales: list of dicts with keys:
|
||||||
|
id, subtotal, discount_total, tax_total, total,
|
||||||
|
items: [{name, quantity, unit_price, discount_amount,
|
||||||
|
tax_rate, tax_amount, subtotal,
|
||||||
|
clave_prod_serv, clave_unidad}]
|
||||||
|
tenant_config: dict with keys:
|
||||||
|
rfc, razon_social, regimen_fiscal, cp, serie (optional)
|
||||||
|
year: int, e.g. 2026
|
||||||
|
month: int, e.g. 6
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: XML string (unsigned, ready for Horux)
|
||||||
|
"""
|
||||||
|
nsmap = {
|
||||||
|
'cfdi': CFDI_NS,
|
||||||
|
'xsi': XSI_NS,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Aggregate totals
|
||||||
|
total_subtotal = Decimal('0')
|
||||||
|
total_discount = Decimal('0')
|
||||||
|
total_tax = Decimal('0')
|
||||||
|
total_total = Decimal('0')
|
||||||
|
for sale in sales:
|
||||||
|
total_subtotal += _to_dec(sale.get('subtotal', 0))
|
||||||
|
total_discount += _to_dec(sale.get('discount_total', 0))
|
||||||
|
total_tax += _to_dec(sale.get('tax_total', 0))
|
||||||
|
total_total += _to_dec(sale.get('total', 0))
|
||||||
|
|
||||||
|
root = etree.Element(f'{{{CFDI_NS}}}Comprobante', nsmap=nsmap)
|
||||||
|
root.set(f'{{{XSI_NS}}}schemaLocation', CFDI_SCHEMA_LOCATION)
|
||||||
|
root.set('Version', '4.0')
|
||||||
|
root.set('Serie', tenant_config.get('serie', 'FG'))
|
||||||
|
root.set('Folio', f'{year}{month:02d}')
|
||||||
|
root.set('Fecha', datetime.now().strftime('%Y-%m-%dT%H:%M:%S'))
|
||||||
|
root.set('FormaPago', '01') # Efectivo (most common for global)
|
||||||
|
root.set('SubTotal', _format_amount(total_subtotal))
|
||||||
|
|
||||||
|
if total_discount > 0:
|
||||||
|
root.set('Descuento', _format_amount(total_discount))
|
||||||
|
|
||||||
|
root.set('Moneda', 'MXN')
|
||||||
|
root.set('Total', _format_amount(total_total))
|
||||||
|
root.set('TipoDeComprobante', 'I') # Ingreso
|
||||||
|
root.set('Exportacion', '01')
|
||||||
|
root.set('MetodoPago', 'PUE')
|
||||||
|
root.set('LugarExpedicion', tenant_config.get('cp', '00000'))
|
||||||
|
|
||||||
|
# InformacionGlobal (monthly global invoice)
|
||||||
|
info_global = _make_element(root, 'InformacionGlobal')
|
||||||
|
info_global.set('Periodicidad', '04') # Mensual
|
||||||
|
info_global.set('Meses', f'{month:02d}')
|
||||||
|
info_global.set('Anio', str(year))
|
||||||
|
|
||||||
|
# Emisor
|
||||||
|
emisor = _make_element(root, 'Emisor')
|
||||||
|
emisor.set('Rfc', tenant_config['rfc'])
|
||||||
|
emisor.set('Nombre', tenant_config['razon_social'])
|
||||||
|
emisor.set('RegimenFiscal', tenant_config.get('regimen_fiscal', '601'))
|
||||||
|
|
||||||
|
# Receptor: Publico en general
|
||||||
|
receptor = _make_element(root, 'Receptor')
|
||||||
|
receptor.set('Rfc', RFC_PUBLICO_GENERAL)
|
||||||
|
receptor.set('Nombre', 'PUBLICO EN GENERAL')
|
||||||
|
receptor.set('DomicilioFiscalReceptor', tenant_config.get('cp', '00000'))
|
||||||
|
receptor.set('RegimenFiscalReceptor', '616')
|
||||||
|
receptor.set('UsoCFDI', 'S01')
|
||||||
|
|
||||||
|
# Conceptos: one per sale item (simplified)
|
||||||
|
conceptos = _make_element(root, 'Conceptos')
|
||||||
|
|
||||||
|
for sale in sales:
|
||||||
|
for item in sale.get('items', []):
|
||||||
|
qty = int(item.get('quantity', 1))
|
||||||
|
unit_price = _to_dec(item.get('unit_price', 0))
|
||||||
|
discount_amount = _to_dec(item.get('discount_amount', 0))
|
||||||
|
tax_rate = _to_dec(item.get('tax_rate', '0.16'))
|
||||||
|
tax_amount = _to_dec(item.get('tax_amount', 0))
|
||||||
|
|
||||||
|
importe = (unit_price * qty).quantize(TWO, ROUND_HALF_UP)
|
||||||
|
base = (importe - discount_amount).quantize(TWO, ROUND_HALF_UP)
|
||||||
|
|
||||||
|
concepto = _make_element(conceptos, 'Concepto')
|
||||||
|
concepto.set('ClaveProdServ', item.get('clave_prod_serv') or '25174800')
|
||||||
|
concepto.set('NoIdentificacion', item.get('part_number') or str(sale['id']))
|
||||||
|
concepto.set('Cantidad', str(qty))
|
||||||
|
concepto.set('ClaveUnidad', item.get('clave_unidad') or 'H87')
|
||||||
|
concepto.set('Unidad', 'PZA')
|
||||||
|
concepto.set('Descripcion', item.get('name') or 'Autoparte')
|
||||||
|
concepto.set('ValorUnitario', _format_amount(unit_price))
|
||||||
|
concepto.set('Importe', _format_amount(importe))
|
||||||
|
concepto.set('ObjetoImp', '02')
|
||||||
|
|
||||||
|
if discount_amount > 0:
|
||||||
|
concepto.set('Descuento', _format_amount(discount_amount))
|
||||||
|
|
||||||
|
impuestos_concepto = _make_element(concepto, 'Impuestos')
|
||||||
|
traslados_concepto = _make_element(impuestos_concepto, 'Traslados')
|
||||||
|
traslado = _make_element(traslados_concepto, 'Traslado')
|
||||||
|
traslado.set('Base', _format_amount(base))
|
||||||
|
traslado.set('Impuesto', '002')
|
||||||
|
traslado.set('TipoFactor', 'Tasa')
|
||||||
|
traslado.set('TasaOCuota', _format_rate(tax_rate))
|
||||||
|
traslado.set('Importe', _format_amount(tax_amount))
|
||||||
|
|
||||||
|
# Impuestos totales
|
||||||
|
impuestos = _make_element(root, 'Impuestos')
|
||||||
|
impuestos.set('TotalImpuestosTrasladados', _format_amount(total_tax))
|
||||||
|
traslados = _make_element(impuestos, 'Traslados')
|
||||||
|
traslado_total = _make_element(traslados, 'Traslado')
|
||||||
|
traslado_total.set('Base', _format_amount(total_subtotal))
|
||||||
|
traslado_total.set('Impuesto', '002')
|
||||||
|
traslado_total.set('TipoFactor', 'Tasa')
|
||||||
|
traslado_total.set('TasaOCuota', '0.160000')
|
||||||
|
traslado_total.set('Importe', _format_amount(total_tax))
|
||||||
|
|
||||||
|
return etree.tostring(root, xml_declaration=True, encoding='UTF-8',
|
||||||
|
pretty_print=True).decode('utf-8')
|
||||||
|
|||||||
243
pos/services/cfdi_facturapi_builder.py
Normal file
243
pos/services/cfdi_facturapi_builder.py
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
# /home/Autopartes/pos/services/cfdi_facturapi_builder.py
|
||||||
|
"""Build Facturapi invoice payloads from Nexus sales data.
|
||||||
|
|
||||||
|
Facturapi expects a JSON payload instead of an unsigned XML. This module
|
||||||
|
generates those payloads for:
|
||||||
|
- Ingreso (sale invoice)
|
||||||
|
- Egreso (credit note)
|
||||||
|
- Pago (payment complement)
|
||||||
|
- Factura global mensual
|
||||||
|
"""
|
||||||
|
|
||||||
|
from decimal import Decimal, ROUND_HALF_UP
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# SAT defaults
|
||||||
|
RFC_PUBLICO_GENERAL = "XAXX010101000"
|
||||||
|
RFC_EXTRANJERO = "XEXX010101000"
|
||||||
|
|
||||||
|
# Forma de pago mapping (Nexus internal -> SAT code)
|
||||||
|
FORMA_PAGO_MAP = {
|
||||||
|
"efectivo": "01",
|
||||||
|
"transferencia": "03",
|
||||||
|
"tarjeta": "04",
|
||||||
|
"cheque": "02",
|
||||||
|
"credito": "99",
|
||||||
|
"mixto": "99",
|
||||||
|
"99": "99",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Metodo de pago
|
||||||
|
METODO_PAGO_MAP = {
|
||||||
|
"PUE": "PUE",
|
||||||
|
"PPD": "PPD",
|
||||||
|
}
|
||||||
|
|
||||||
|
TWO = Decimal("0.01")
|
||||||
|
SIX = Decimal("0.000001")
|
||||||
|
|
||||||
|
|
||||||
|
def _to_dec(val):
|
||||||
|
if val is None:
|
||||||
|
return Decimal("0")
|
||||||
|
return Decimal(str(val))
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt2(val):
|
||||||
|
return float(_to_dec(val).quantize(TWO, ROUND_HALF_UP))
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt6(val):
|
||||||
|
return float(_to_dec(val).quantize(SIX, ROUND_HALF_UP))
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_forma_pago(sale):
|
||||||
|
method = (sale.get("payment_method") or "").lower().strip()
|
||||||
|
fp = (sale.get("forma_pago_sat") or "").strip()
|
||||||
|
if fp:
|
||||||
|
return fp
|
||||||
|
return FORMA_PAGO_MAP.get(method, "99")
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_metodo_pago(sale):
|
||||||
|
mp = (sale.get("metodo_pago_sat") or "").upper().strip()
|
||||||
|
if mp in ("PUE", "PPD"):
|
||||||
|
return mp
|
||||||
|
# Default: credit sales are PPD, cash sales are PUE
|
||||||
|
if sale.get("sale_type") == "credit" or sale.get("payment_method") == "credito":
|
||||||
|
return "PPD"
|
||||||
|
return "PUE"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_items(sale_items):
|
||||||
|
items = []
|
||||||
|
for item in sale_items or []:
|
||||||
|
qty = int(item.get("quantity", 1))
|
||||||
|
unit_price = _to_dec(item.get("unit_price", 0))
|
||||||
|
discount = _to_dec(item.get("discount_amount", 0))
|
||||||
|
tax_rate = _to_dec(item.get("tax_rate", "0.16"))
|
||||||
|
|
||||||
|
# Facturapi price is unit price before taxes and discounts
|
||||||
|
product = {
|
||||||
|
"description": item.get("name") or "Autoparte",
|
||||||
|
"product_key": item.get("clave_prod_serv") or "25174800",
|
||||||
|
"unit_key": item.get("clave_unidad") or "H87",
|
||||||
|
"unit_name": "Pieza",
|
||||||
|
"price": _fmt2(unit_price),
|
||||||
|
"tax_included": False,
|
||||||
|
"taxes": [
|
||||||
|
{
|
||||||
|
"type": "IVA",
|
||||||
|
"rate": _fmt6(tax_rate),
|
||||||
|
"factor": "Tasa",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
if discount > 0:
|
||||||
|
product["discount"] = _fmt2(discount / qty) if qty > 0 else _fmt2(discount)
|
||||||
|
|
||||||
|
items.append({"quantity": qty, "product": product})
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def _build_customer_payload(customer, tenant_cp):
|
||||||
|
if not customer or not customer.get("rfc"):
|
||||||
|
# Publico en general
|
||||||
|
return {
|
||||||
|
"tax_id": RFC_PUBLICO_GENERAL,
|
||||||
|
"legal_name": "PUBLICO EN GENERAL",
|
||||||
|
"tax_system": "616",
|
||||||
|
"address": {"zip": tenant_cp or "00000"},
|
||||||
|
}
|
||||||
|
|
||||||
|
rfc = (customer.get("rfc") or "").upper().strip()
|
||||||
|
return {
|
||||||
|
"tax_id": rfc,
|
||||||
|
"legal_name": customer.get("razon_social") or customer.get("name") or rfc,
|
||||||
|
"tax_system": customer.get("regimen_fiscal") or "616",
|
||||||
|
"email": customer.get("email"),
|
||||||
|
"address": {"zip": customer.get("cp") or tenant_cp or "00000"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_ingreso_payload(sale, tenant_config, customer=None):
|
||||||
|
"""Build Facturapi payload for a sale (Comprobante tipo Ingreso)."""
|
||||||
|
tenant_cp = tenant_config.get("cp", "00000")
|
||||||
|
customer_payload = _build_customer_payload(customer, tenant_cp)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"customer": customer_payload,
|
||||||
|
"items": _build_items(sale.get("items", [])),
|
||||||
|
"use": customer.get("uso_cfdi") if customer and customer.get("rfc") else "S01",
|
||||||
|
"payment_form": _resolve_forma_pago(sale),
|
||||||
|
"payment_method": _resolve_metodo_pago(sale),
|
||||||
|
"currency": "MXN",
|
||||||
|
"series": tenant_config.get("serie", "A"),
|
||||||
|
"folio_number": sale["id"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Optional exchange rate for USD
|
||||||
|
if sale.get("currency") and sale["currency"] != "MXN" and sale.get("exchange_rate"):
|
||||||
|
payload["exchange"] = _fmt6(sale["exchange_rate"])
|
||||||
|
payload["currency"] = sale["currency"]
|
||||||
|
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def build_egreso_payload(sale, tenant_config, customer, original_uuid):
|
||||||
|
"""Build Facturapi payload for a credit note (Comprobante tipo Egreso)."""
|
||||||
|
payload = build_ingreso_payload(sale, tenant_config, customer)
|
||||||
|
payload["type"] = "E"
|
||||||
|
payload["related_documents"] = [
|
||||||
|
{"relationship": "01", "documents": [original_uuid]}
|
||||||
|
]
|
||||||
|
payload["payment_method"] = "PUE"
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def build_pago_payload(payment, tenant_config, customer, original_uuid):
|
||||||
|
"""Build Facturapi payload for a payment complement (Comprobante tipo Pago)."""
|
||||||
|
tenant_cp = tenant_config.get("cp", "00000")
|
||||||
|
customer_payload = _build_customer_payload(customer, tenant_cp)
|
||||||
|
|
||||||
|
amount = _to_dec(payment.get("amount", 0))
|
||||||
|
base = (amount / Decimal("1.16")).quantize(TWO, ROUND_HALF_UP)
|
||||||
|
iva = (amount - base).quantize(TWO, ROUND_HALF_UP)
|
||||||
|
|
||||||
|
payment_date = payment.get("date") or datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
|
||||||
|
if "T" not in str(payment_date):
|
||||||
|
payment_date = f"{payment_date}T12:00:00"
|
||||||
|
|
||||||
|
forma_pago = FORMA_PAGO_MAP.get(
|
||||||
|
(payment.get("payment_method") or "").lower().strip(), "01"
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"type": "P",
|
||||||
|
"customer": customer_payload,
|
||||||
|
"complements": [
|
||||||
|
{
|
||||||
|
"type": "pago",
|
||||||
|
"data": {
|
||||||
|
"payment_form": forma_pago,
|
||||||
|
"payment_date": payment_date,
|
||||||
|
"amount": _fmt2(amount),
|
||||||
|
"related_documents": [
|
||||||
|
{
|
||||||
|
"uuid": original_uuid,
|
||||||
|
"amount": _fmt2(amount),
|
||||||
|
"taxes": [
|
||||||
|
{
|
||||||
|
"type": "IVA",
|
||||||
|
"rate": 0.16,
|
||||||
|
"factor": "Tasa",
|
||||||
|
"base": _fmt2(base),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def build_global_invoice_payload(sales, tenant_config, year, month):
|
||||||
|
"""Build Facturapi payload for a monthly global invoice."""
|
||||||
|
tenant_cp = tenant_config.get("cp", "00000")
|
||||||
|
|
||||||
|
total_subtotal = Decimal("0")
|
||||||
|
total_discount = Decimal("0")
|
||||||
|
total_tax = Decimal("0")
|
||||||
|
total_total = Decimal("0")
|
||||||
|
all_items = []
|
||||||
|
|
||||||
|
for sale in sales:
|
||||||
|
total_subtotal += _to_dec(sale.get("subtotal", 0))
|
||||||
|
total_discount += _to_dec(sale.get("discount_total", 0))
|
||||||
|
total_tax += _to_dec(sale.get("tax_total", 0))
|
||||||
|
total_total += _to_dec(sale.get("total", 0))
|
||||||
|
all_items.extend(_build_items(sale.get("items", [])))
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"customer": {
|
||||||
|
"tax_id": RFC_PUBLICO_GENERAL,
|
||||||
|
"legal_name": "PUBLICO EN GENERAL",
|
||||||
|
"tax_system": "616",
|
||||||
|
"address": {"zip": tenant_cp},
|
||||||
|
},
|
||||||
|
"items": all_items,
|
||||||
|
"use": "S01",
|
||||||
|
"payment_form": "01",
|
||||||
|
"payment_method": "PUE",
|
||||||
|
"currency": "MXN",
|
||||||
|
"series": tenant_config.get("serie", "FG"),
|
||||||
|
"folio_number": int(f"{year}{month:02d}"),
|
||||||
|
"global": {
|
||||||
|
"periodicity": "04", # Mensual
|
||||||
|
"months": f"{month:02d}",
|
||||||
|
"year": year,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return payload
|
||||||
@@ -1,25 +1,25 @@
|
|||||||
# /home/Autopartes/pos/services/cfdi_queue.py
|
# /home/Autopartes/pos/services/cfdi_queue.py
|
||||||
"""CFDI queue service: manages the timbrado pipeline.
|
"""CFDI queue service: manages the Facturapi timbrado pipeline.
|
||||||
|
|
||||||
Flow:
|
Flow:
|
||||||
1. enqueue_cfdi() — inserts XML into cfdi_queue with status='pending'
|
1. enqueue_cfdi() — inserts Facturapi JSON payload into cfdi_queue with status='pending'
|
||||||
2. process_queue() — sends pending items to Horux API, updates status
|
2. process_queue() — sends pending items to Facturapi, updates status
|
||||||
3. retry_failed() — retries failed items with exponential backoff
|
3. retry_failed() — retries failed items with exponential backoff
|
||||||
4. cancel_cfdi() — sends cancel request to Horux API
|
4. cancel_cfdi() — cancels a stamped CFDI via Facturapi
|
||||||
|
|
||||||
Horux API endpoints:
|
Facturapi endpoints used:
|
||||||
POST /api/nexus/cfdi/stamp — send unsigned XML, receive signed+timbrado
|
POST /v2/invoices — create and stamp an invoice
|
||||||
GET /api/nexus/cfdi/status/:uuid — check timbrado status
|
GET /v2/invoices/:id — fetch invoice metadata
|
||||||
POST /api/nexus/cfdi/cancel — cancel CFDI with SAT motive code
|
DELETE /v2/invoices/:id — cancel with SAT motive
|
||||||
|
|
||||||
Retry backoff: 5s, 30s, 2m, 10m, 1h (max 5 retries)
|
Retry backoff: 5s, 30s, 2m, 10m, 1h (max 5 retries)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import time
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
import requests
|
from services import facturapi_service
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -29,10 +29,7 @@ MAX_RETRIES = len(BACKOFF_INTERVALS)
|
|||||||
|
|
||||||
|
|
||||||
def _generate_provisional_folio(conn):
|
def _generate_provisional_folio(conn):
|
||||||
"""Generate a provisional folio like PRE-00001.
|
"""Generate a provisional folio like PRE-00001."""
|
||||||
|
|
||||||
Uses the cfdi_queue table's max id to avoid collisions.
|
|
||||||
"""
|
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
cur.execute("SELECT COALESCE(MAX(id), 0) + 1 FROM cfdi_queue")
|
cur.execute("SELECT COALESCE(MAX(id), 0) + 1 FROM cfdi_queue")
|
||||||
seq = cur.fetchone()[0]
|
seq = cur.fetchone()[0]
|
||||||
@@ -40,14 +37,14 @@ def _generate_provisional_folio(conn):
|
|||||||
return f'PRE-{seq:05d}'
|
return f'PRE-{seq:05d}'
|
||||||
|
|
||||||
|
|
||||||
def enqueue_cfdi(conn, sale_id, cfdi_type, xml):
|
def enqueue_cfdi(conn, sale_id, cfdi_type, payload):
|
||||||
"""Add a CFDI to the timbrado queue.
|
"""Add a CFDI to the timbrado queue.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
conn: psycopg2 connection
|
conn: psycopg2 connection
|
||||||
sale_id: int (FK to sales)
|
sale_id: int (FK to sales), may be None for global invoices
|
||||||
cfdi_type: 'ingreso' | 'egreso' | 'pago'
|
cfdi_type: 'ingreso' | 'egreso' | 'pago'
|
||||||
xml: str (unsigned XML from cfdi_builder)
|
payload: dict (Facturapi JSON payload) or str (JSON string)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: {id, sale_id, type, status, provisional_folio}
|
dict: {id, sale_id, type, status, provisional_folio}
|
||||||
@@ -55,12 +52,14 @@ def enqueue_cfdi(conn, sale_id, cfdi_type, xml):
|
|||||||
provisional_folio = _generate_provisional_folio(conn)
|
provisional_folio = _generate_provisional_folio(conn)
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
payload_json = payload if isinstance(payload, str) else json.dumps(payload)
|
||||||
|
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
INSERT INTO cfdi_queue
|
INSERT INTO cfdi_queue
|
||||||
(sale_id, type, xml_unsigned, status, provisional_folio)
|
(sale_id, type, payload_unsigned, status, provisional_folio)
|
||||||
VALUES (%s, %s, %s, 'pending', %s)
|
VALUES (%s, %s, %s, 'pending', %s)
|
||||||
RETURNING id, created_at
|
RETURNING id, created_at
|
||||||
""", (sale_id, cfdi_type, xml, provisional_folio))
|
""", (sale_id, cfdi_type, payload_json, provisional_folio))
|
||||||
cfdi_id, created_at = cur.fetchone()
|
cfdi_id, created_at = cur.fetchone()
|
||||||
cur.close()
|
cur.close()
|
||||||
|
|
||||||
@@ -74,17 +73,17 @@ def enqueue_cfdi(conn, sale_id, cfdi_type, xml):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def process_queue(conn, horux_api_url, api_key):
|
def process_queue(conn, tenant_config, dry_run=False):
|
||||||
"""Process all pending CFDI items in the queue.
|
"""Process all pending CFDI items in the queue.
|
||||||
|
|
||||||
Sends each pending XML to Horux for timbrado. On success, updates
|
Sends each pending payload to Facturapi for timbrado. On success, updates
|
||||||
the record with the signed XML and UUID fiscal. On failure, increments
|
the record with the signed XML and UUID fiscal. On failure, increments
|
||||||
retry_count and records the error.
|
retry_count and records the error.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
conn: psycopg2 connection
|
conn: psycopg2 connection
|
||||||
horux_api_url: str base URL for Horux API (e.g. 'https://horux.example.com')
|
tenant_config: dict with facturapi_key (and optional facturapi_org_id)
|
||||||
api_key: str Horux API key
|
dry_run: if True, validates payload without stamping
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: {processed: int, stamped: int, failed: int, details: [...]}
|
dict: {processed: int, stamped: int, failed: int, details: [...]}
|
||||||
@@ -92,7 +91,7 @@ def process_queue(conn, horux_api_url, api_key):
|
|||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
|
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT id, sale_id, type, xml_unsigned, retry_count
|
SELECT id, sale_id, type, payload_unsigned, retry_count
|
||||||
FROM cfdi_queue
|
FROM cfdi_queue
|
||||||
WHERE status IN ('pending', 'failed')
|
WHERE status IN ('pending', 'failed')
|
||||||
AND retry_count < %s
|
AND retry_count < %s
|
||||||
@@ -103,7 +102,12 @@ def process_queue(conn, horux_api_url, api_key):
|
|||||||
|
|
||||||
results = {'processed': 0, 'stamped': 0, 'failed': 0, 'details': []}
|
results = {'processed': 0, 'stamped': 0, 'failed': 0, 'details': []}
|
||||||
|
|
||||||
for cfdi_id, sale_id, cfdi_type, xml_unsigned, retry_count in items:
|
api_key = tenant_config.get('facturapi_key')
|
||||||
|
if not api_key:
|
||||||
|
cur.close()
|
||||||
|
raise ValueError("Facturapi key not configured for tenant")
|
||||||
|
|
||||||
|
for cfdi_id, sale_id, cfdi_type, payload_unsigned, retry_count in items:
|
||||||
results['processed'] += 1
|
results['processed'] += 1
|
||||||
|
|
||||||
# Update status to 'sending'
|
# Update status to 'sending'
|
||||||
@@ -113,54 +117,47 @@ def process_queue(conn, horux_api_url, api_key):
|
|||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = requests.post(
|
payload = json.loads(payload_unsigned or '{}')
|
||||||
f'{horux_api_url}/api/nexus/cfdi/stamp',
|
if not payload:
|
||||||
headers={
|
raise ValueError("Empty payload in queue item")
|
||||||
'Authorization': f'Bearer {api_key}',
|
|
||||||
'Content-Type': 'application/xml',
|
|
||||||
},
|
|
||||||
data=xml_unsigned.encode('utf-8'),
|
|
||||||
timeout=30,
|
|
||||||
)
|
|
||||||
|
|
||||||
if response.status_code == 200:
|
if dry_run:
|
||||||
data = response.json()
|
# TODO: Facturapi dry-run validation (not officially supported)
|
||||||
uuid_fiscal = data.get('uuid')
|
# For now we just skip the API call and mark as stamped with a fake UUID
|
||||||
xml_signed = data.get('xml', '')
|
raise ValueError("dry_run is not supported with Facturapi")
|
||||||
|
|
||||||
|
invoice = facturapi_service.create_invoice(tenant_config, payload)
|
||||||
|
invoice_id = invoice.get('id')
|
||||||
|
uuid_fiscal = invoice.get('uuid')
|
||||||
|
|
||||||
|
# Download signed XML for storage
|
||||||
|
try:
|
||||||
|
xml_signed = facturapi_service.download_xml(tenant_config, invoice_id)
|
||||||
|
xml_signed_str = xml_signed.decode('utf-8') if isinstance(xml_signed, bytes) else str(xml_signed)
|
||||||
|
except Exception as xml_err:
|
||||||
|
logger.warning("Could not download signed XML for %s: %s", invoice_id, xml_err)
|
||||||
|
xml_signed_str = ''
|
||||||
|
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
UPDATE cfdi_queue
|
UPDATE cfdi_queue
|
||||||
SET status = 'stamped',
|
SET status = 'stamped',
|
||||||
xml_signed = %s,
|
xml_signed = %s,
|
||||||
uuid_fiscal = %s,
|
uuid_fiscal = %s,
|
||||||
|
external_id = %s,
|
||||||
stamped_at = NOW(),
|
stamped_at = NOW(),
|
||||||
error_message = NULL
|
error_message = NULL
|
||||||
WHERE id = %s
|
WHERE id = %s
|
||||||
""", (xml_signed, uuid_fiscal, cfdi_id))
|
""", (xml_signed_str, uuid_fiscal, invoice_id, cfdi_id))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
results['stamped'] += 1
|
results['stamped'] += 1
|
||||||
results['details'].append({
|
results['details'].append({
|
||||||
'id': cfdi_id, 'status': 'stamped', 'uuid': uuid_fiscal
|
'id': cfdi_id, 'status': 'stamped',
|
||||||
})
|
'uuid': uuid_fiscal, 'external_id': invoice_id,
|
||||||
else:
|
|
||||||
error_msg = f'HTTP {response.status_code}: {response.text[:500]}'
|
|
||||||
cur.execute("""
|
|
||||||
UPDATE cfdi_queue
|
|
||||||
SET status = 'failed',
|
|
||||||
retry_count = retry_count + 1,
|
|
||||||
error_message = %s
|
|
||||||
WHERE id = %s
|
|
||||||
""", (error_msg, cfdi_id))
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
results['failed'] += 1
|
|
||||||
results['details'].append({
|
|
||||||
'id': cfdi_id, 'status': 'failed', 'error': error_msg
|
|
||||||
})
|
})
|
||||||
|
|
||||||
except requests.RequestException as e:
|
except Exception as e:
|
||||||
error_msg = f'Connection error: {str(e)[:500]}'
|
error_msg = f'{type(e).__name__}: {str(e)[:500]}'
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
UPDATE cfdi_queue
|
UPDATE cfdi_queue
|
||||||
SET status = 'failed',
|
SET status = 'failed',
|
||||||
@@ -180,20 +177,13 @@ def process_queue(conn, horux_api_url, api_key):
|
|||||||
|
|
||||||
|
|
||||||
def retry_failed(conn):
|
def retry_failed(conn):
|
||||||
"""Find failed items eligible for retry (based on backoff) and reset to pending.
|
"""Find failed items eligible for retry and reset to pending.
|
||||||
|
|
||||||
Uses exponential backoff: item is eligible for retry only if enough
|
Uses exponential backoff: item is eligible for retry only if enough
|
||||||
time has passed since the last attempt based on retry_count.
|
time has passed since the last attempt based on retry_count.
|
||||||
|
|
||||||
Args:
|
|
||||||
conn: psycopg2 connection
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
int: number of items reset to pending
|
|
||||||
"""
|
"""
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
|
|
||||||
# For each failed item, check if enough time has passed for its retry level
|
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT id, retry_count, created_at
|
SELECT id, retry_count, created_at
|
||||||
FROM cfdi_queue
|
FROM cfdi_queue
|
||||||
@@ -206,15 +196,15 @@ def retry_failed(conn):
|
|||||||
now = datetime.utcnow()
|
now = datetime.utcnow()
|
||||||
|
|
||||||
for cfdi_id, retry_count, created_at in items:
|
for cfdi_id, retry_count, created_at in items:
|
||||||
# Calculate required wait time based on retry count
|
|
||||||
if retry_count < len(BACKOFF_INTERVALS):
|
if retry_count < len(BACKOFF_INTERVALS):
|
||||||
wait_seconds = BACKOFF_INTERVALS[retry_count]
|
wait_seconds = BACKOFF_INTERVALS[retry_count]
|
||||||
else:
|
else:
|
||||||
wait_seconds = BACKOFF_INTERVALS[-1] # max backoff
|
wait_seconds = BACKOFF_INTERVALS[-1]
|
||||||
|
|
||||||
# Check if enough time has passed (use created_at as approximation)
|
# Use created_at as approximation for last attempt.
|
||||||
# In production, you'd track last_attempt_at separately
|
# In production, track last_attempt_at separately.
|
||||||
if True: # Always eligible for manual retry trigger
|
elapsed = (now - created_at).total_seconds()
|
||||||
|
if elapsed >= wait_seconds:
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
UPDATE cfdi_queue SET status = 'pending' WHERE id = %s
|
UPDATE cfdi_queue SET status = 'pending' WHERE id = %s
|
||||||
""", (cfdi_id,))
|
""", (cfdi_id,))
|
||||||
@@ -226,8 +216,8 @@ def retry_failed(conn):
|
|||||||
|
|
||||||
|
|
||||||
def cancel_cfdi(conn, cfdi_id, motive, replacement_uuid=None,
|
def cancel_cfdi(conn, cfdi_id, motive, replacement_uuid=None,
|
||||||
horux_api_url=None, api_key=None):
|
tenant_config=None):
|
||||||
"""Cancel a stamped CFDI via Horux API.
|
"""Cancel a stamped CFDI via Facturapi.
|
||||||
|
|
||||||
SAT cancellation motives:
|
SAT cancellation motives:
|
||||||
01: Comprobante emitido con errores con relacion (requires replacement UUID)
|
01: Comprobante emitido con errores con relacion (requires replacement UUID)
|
||||||
@@ -240,8 +230,7 @@ def cancel_cfdi(conn, cfdi_id, motive, replacement_uuid=None,
|
|||||||
cfdi_id: int (cfdi_queue.id)
|
cfdi_id: int (cfdi_queue.id)
|
||||||
motive: str ('01', '02', '03', '04')
|
motive: str ('01', '02', '03', '04')
|
||||||
replacement_uuid: str (required if motive == '01')
|
replacement_uuid: str (required if motive == '01')
|
||||||
horux_api_url: str (optional, skips API call if None — for offline)
|
tenant_config: dict with facturapi_key
|
||||||
api_key: str (optional)
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: {id, status, message}
|
dict: {id, status, message}
|
||||||
@@ -258,13 +247,13 @@ def cancel_cfdi(conn, cfdi_id, motive, replacement_uuid=None,
|
|||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
|
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT id, uuid_fiscal, status FROM cfdi_queue WHERE id = %s
|
SELECT id, uuid_fiscal, external_id, status FROM cfdi_queue WHERE id = %s
|
||||||
""", (cfdi_id,))
|
""", (cfdi_id,))
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
raise ValueError(f"CFDI queue item {cfdi_id} not found")
|
raise ValueError(f"CFDI queue item {cfdi_id} not found")
|
||||||
|
|
||||||
_, uuid_fiscal, current_status = row
|
_, uuid_fiscal, external_id, current_status = row
|
||||||
|
|
||||||
if current_status == 'cancelled':
|
if current_status == 'cancelled':
|
||||||
raise ValueError("CFDI is already cancelled")
|
raise ValueError("CFDI is already cancelled")
|
||||||
@@ -280,27 +269,20 @@ def cancel_cfdi(conn, cfdi_id, motive, replacement_uuid=None,
|
|||||||
cur.close()
|
cur.close()
|
||||||
return {'id': cfdi_id, 'status': 'cancelled', 'message': 'Cancelled locally (was not stamped)'}
|
return {'id': cfdi_id, 'status': 'cancelled', 'message': 'Cancelled locally (was not stamped)'}
|
||||||
|
|
||||||
# Send cancel request to Horux
|
if not tenant_config or not tenant_config.get('facturapi_key'):
|
||||||
if horux_api_url and api_key:
|
cur.close()
|
||||||
try:
|
raise ValueError("Facturapi key not configured for tenant")
|
||||||
payload = {
|
|
||||||
'uuid': uuid_fiscal,
|
|
||||||
'motive': motive,
|
|
||||||
}
|
|
||||||
if replacement_uuid:
|
|
||||||
payload['replacement_uuid'] = replacement_uuid
|
|
||||||
|
|
||||||
response = requests.post(
|
if not external_id:
|
||||||
f'{horux_api_url}/api/nexus/cfdi/cancel',
|
cur.close()
|
||||||
headers={
|
raise ValueError("Cannot cancel: no Facturapi invoice id stored")
|
||||||
'Authorization': f'Bearer {api_key}',
|
|
||||||
'Content-Type': 'application/json',
|
try:
|
||||||
},
|
facturapi_service.cancel_invoice(
|
||||||
json=payload,
|
tenant_config, external_id, motive,
|
||||||
timeout=30,
|
replacement_uuid=replacement_uuid,
|
||||||
)
|
)
|
||||||
|
|
||||||
if response.status_code == 200:
|
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
UPDATE cfdi_queue
|
UPDATE cfdi_queue
|
||||||
SET status = 'cancelled',
|
SET status = 'cancelled',
|
||||||
@@ -316,8 +298,9 @@ def cancel_cfdi(conn, cfdi_id, motive, replacement_uuid=None,
|
|||||||
'status': 'cancelled',
|
'status': 'cancelled',
|
||||||
'message': f'Cancelled with SAT (motive {motive})',
|
'message': f'Cancelled with SAT (motive {motive})',
|
||||||
}
|
}
|
||||||
else:
|
|
||||||
error_msg = f'Cancel failed: HTTP {response.status_code}: {response.text[:500]}'
|
except Exception as e:
|
||||||
|
error_msg = f'Cancel failed: {str(e)[:500]}'
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
UPDATE cfdi_queue
|
UPDATE cfdi_queue
|
||||||
SET error_message = %s
|
SET error_message = %s
|
||||||
@@ -327,42 +310,9 @@ def cancel_cfdi(conn, cfdi_id, motive, replacement_uuid=None,
|
|||||||
cur.close()
|
cur.close()
|
||||||
raise ValueError(error_msg)
|
raise ValueError(error_msg)
|
||||||
|
|
||||||
except requests.RequestException as e:
|
|
||||||
cur.close()
|
|
||||||
raise ValueError(f'Connection error during cancel: {str(e)}')
|
|
||||||
else:
|
|
||||||
# Offline mode: mark as cancelled locally, will sync later
|
|
||||||
cur.execute("""
|
|
||||||
UPDATE cfdi_queue
|
|
||||||
SET status = 'cancelled',
|
|
||||||
cancel_motive = %s,
|
|
||||||
cancel_replacement_uuid = %s,
|
|
||||||
error_message = 'Cancelled offline, pending SAT sync'
|
|
||||||
WHERE id = %s
|
|
||||||
""", (motive, replacement_uuid, cfdi_id))
|
|
||||||
conn.commit()
|
|
||||||
cur.close()
|
|
||||||
return {
|
|
||||||
'id': cfdi_id,
|
|
||||||
'status': 'cancelled',
|
|
||||||
'message': 'Cancelled offline, pending SAT sync',
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_queue_status(conn, filters=None):
|
def get_queue_status(conn, filters=None):
|
||||||
"""Get CFDI queue items with optional filters.
|
"""Get CFDI queue items with optional filters."""
|
||||||
|
|
||||||
Args:
|
|
||||||
conn: psycopg2 connection
|
|
||||||
filters: dict with optional keys:
|
|
||||||
status: str filter by status
|
|
||||||
sale_id: int filter by sale
|
|
||||||
page: int (default 1)
|
|
||||||
per_page: int (default 50)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: {data: [...], pagination: {...}}
|
|
||||||
"""
|
|
||||||
filters = filters or {}
|
filters = filters or {}
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
|
|
||||||
@@ -392,7 +342,7 @@ def get_queue_status(conn, filters=None):
|
|||||||
cur.execute(f"""
|
cur.execute(f"""
|
||||||
SELECT q.id, q.sale_id, q.type, q.uuid_fiscal, q.status,
|
SELECT q.id, q.sale_id, q.type, q.uuid_fiscal, q.status,
|
||||||
q.retry_count, q.provisional_folio, q.error_message,
|
q.retry_count, q.provisional_folio, q.error_message,
|
||||||
q.cancel_motive, q.created_at, q.stamped_at
|
q.cancel_motive, q.created_at, q.stamped_at, q.external_id
|
||||||
FROM cfdi_queue q
|
FROM cfdi_queue q
|
||||||
WHERE {where}
|
WHERE {where}
|
||||||
ORDER BY q.created_at DESC
|
ORDER BY q.created_at DESC
|
||||||
@@ -408,6 +358,7 @@ def get_queue_status(conn, filters=None):
|
|||||||
'error_message': r[7], 'cancel_motive': r[8],
|
'error_message': r[7], 'cancel_motive': r[8],
|
||||||
'created_at': str(r[9]) if r[9] else None,
|
'created_at': str(r[9]) if r[9] else None,
|
||||||
'stamped_at': str(r[10]) if r[10] else None,
|
'stamped_at': str(r[10]) if r[10] else None,
|
||||||
|
'external_id': r[11],
|
||||||
})
|
})
|
||||||
|
|
||||||
cur.close()
|
cur.close()
|
||||||
|
|||||||
168
pos/services/dropshipping_service.py
Normal file
168
pos/services/dropshipping_service.py
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
"""Dropshipping integration service.
|
||||||
|
|
||||||
|
Provides read-only inventory access for external dropshipping platforms
|
||||||
|
and webhook dispatching on stock/price/sale events.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from services.inventory_engine import get_stock_bulk
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_tenant_by_api_key(master_conn, api_key: str):
|
||||||
|
"""Find tenant_id and db_name for a given dropshipping API key.
|
||||||
|
|
||||||
|
Returns (tenant_id, db_name) or (None, None) if invalid.
|
||||||
|
"""
|
||||||
|
if not api_key:
|
||||||
|
return None, None
|
||||||
|
cur = master_conn.cursor()
|
||||||
|
# tenant_config lives in each tenant DB, so we need to scan tenants
|
||||||
|
cur.execute("SELECT id, db_name FROM tenants WHERE is_active = true")
|
||||||
|
tenants = cur.fetchall()
|
||||||
|
for tid, db_name in tenants:
|
||||||
|
try:
|
||||||
|
tcur = master_conn.cursor()
|
||||||
|
# Use dblink or connect to tenant DB? Simpler: the blueprint
|
||||||
|
# will pass tenant_conn directly after resolution.
|
||||||
|
# Instead, we store a mapping in master DB for speed.
|
||||||
|
# For now, return all candidates and let caller validate.
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
cur.close()
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_dropshipping_key(tenant_conn):
|
||||||
|
cur = tenant_conn.cursor()
|
||||||
|
cur.execute("SELECT value FROM tenant_config WHERE key = 'dropshipping_api_key'")
|
||||||
|
row = cur.fetchone()
|
||||||
|
cur.close()
|
||||||
|
return row[0] if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def validate_api_key(tenant_conn, api_key: str) -> bool:
|
||||||
|
"""Check if the provided API key matches the tenant's configured key."""
|
||||||
|
if not api_key:
|
||||||
|
return False
|
||||||
|
expected = _get_dropshipping_key(tenant_conn)
|
||||||
|
return expected is not None and expected == api_key
|
||||||
|
|
||||||
|
|
||||||
|
def get_inventory_list(tenant_conn, search: str = None, page: int = 1, per_page: int = 50):
|
||||||
|
"""Return inventory items with stock and price for dropshipping."""
|
||||||
|
offset = (max(page, 1) - 1) * per_page
|
||||||
|
stock_map = get_stock_bulk(tenant_conn, branch_id=None)
|
||||||
|
|
||||||
|
cur = tenant_conn.cursor()
|
||||||
|
params = []
|
||||||
|
where = "WHERE is_active = true"
|
||||||
|
if search:
|
||||||
|
where += " AND (name ILIKE %s OR part_number ILIKE %s)"
|
||||||
|
params.extend([f"%{search}%", f"%{search}%"])
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
f"""
|
||||||
|
SELECT id, part_number, name, brand, price_1, price_2, price_3,
|
||||||
|
image_url, unit, description
|
||||||
|
FROM inventory
|
||||||
|
{where}
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT %s OFFSET %s
|
||||||
|
""",
|
||||||
|
params + [per_page, offset],
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
|
||||||
|
# Count total
|
||||||
|
cur.execute(f"SELECT COUNT(*) FROM inventory {where}", params)
|
||||||
|
total = cur.fetchone()[0]
|
||||||
|
cur.close()
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for r in rows:
|
||||||
|
inv_id = r[0]
|
||||||
|
items.append({
|
||||||
|
"id": inv_id,
|
||||||
|
"sku": r[1],
|
||||||
|
"name": r[2],
|
||||||
|
"brand": r[3],
|
||||||
|
"price_1": float(r[4]) if r[4] else None,
|
||||||
|
"price_2": float(r[5]) if r[5] else None,
|
||||||
|
"price_3": float(r[6]) if r[6] else None,
|
||||||
|
"stock": stock_map.get(inv_id, 0),
|
||||||
|
"image_url": r[7],
|
||||||
|
"unit": r[8],
|
||||||
|
"description": r[9],
|
||||||
|
})
|
||||||
|
return {"items": items, "page": page, "per_page": per_page, "total": total}
|
||||||
|
|
||||||
|
|
||||||
|
def get_inventory_by_sku(tenant_conn, sku: str):
|
||||||
|
"""Return a single inventory item by SKU/part_number."""
|
||||||
|
stock_map = get_stock_bulk(tenant_conn, branch_id=None)
|
||||||
|
cur = tenant_conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, part_number, name, brand, price_1, price_2, price_3,
|
||||||
|
image_url, unit, description
|
||||||
|
FROM inventory
|
||||||
|
WHERE part_number = %s AND is_active = true
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(sku,),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
cur.close()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
inv_id = row[0]
|
||||||
|
return {
|
||||||
|
"id": inv_id,
|
||||||
|
"sku": row[1],
|
||||||
|
"name": row[2],
|
||||||
|
"brand": row[3],
|
||||||
|
"price_1": float(row[4]) if row[4] else None,
|
||||||
|
"price_2": float(row[5]) if row[5] else None,
|
||||||
|
"price_3": float(row[6]) if row[6] else None,
|
||||||
|
"stock": stock_map.get(inv_id, 0),
|
||||||
|
"image_url": row[7],
|
||||||
|
"unit": row[8],
|
||||||
|
"description": row[9],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_stock_by_skus(tenant_conn, skus: list[str]) -> dict:
|
||||||
|
"""Return stock levels for a list of SKUs."""
|
||||||
|
stock_map = get_stock_bulk(tenant_conn, branch_id=None)
|
||||||
|
cur = tenant_conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, part_number FROM inventory
|
||||||
|
WHERE part_number = ANY(%s) AND is_active = true
|
||||||
|
""",
|
||||||
|
(skus,),
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
cur.close()
|
||||||
|
result = {}
|
||||||
|
for inv_id, sku in rows:
|
||||||
|
result[sku] = stock_map.get(inv_id, 0)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def get_webhook_targets(tenant_conn, event_type: str) -> list[str]:
|
||||||
|
"""Return active webhook URLs for a given event type."""
|
||||||
|
cur = tenant_conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT target_url FROM dropshipping_webhooks
|
||||||
|
WHERE event_type = %s AND is_active = true
|
||||||
|
""",
|
||||||
|
(event_type,),
|
||||||
|
)
|
||||||
|
urls = [r[0] for r in cur.fetchall()]
|
||||||
|
cur.close()
|
||||||
|
return urls
|
||||||
426
pos/services/facturapi_service.py
Normal file
426
pos/services/facturapi_service.py
Normal file
@@ -0,0 +1,426 @@
|
|||||||
|
# /home/Autopartes/pos/services/facturapi_service.py
|
||||||
|
"""Facturapi integration for Nexus POS.
|
||||||
|
|
||||||
|
Uses Facturapi REST API directly (requests + Basic Auth) so it is safe for
|
||||||
|
multi-tenant use. Each call receives the API key explicitly, avoiding the
|
||||||
|
global client used by the official facturapi Python library.
|
||||||
|
|
||||||
|
Authentication modes:
|
||||||
|
1. User key (FACTURAPI_USER_KEY env): creates/verifies organizations per tenant.
|
||||||
|
2. Secret key per tenant (tenant_config.facturapi_secret_key): uses existing org.
|
||||||
|
|
||||||
|
Reference: https://docs.facturapi.io/
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import base64
|
||||||
|
import logging
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
BASE_URL = "https://www.facturapi.io/v2"
|
||||||
|
USER_KEY = os.environ.get("FACTURAPI_USER_KEY", "")
|
||||||
|
|
||||||
|
|
||||||
|
class FacturapiError(Exception):
|
||||||
|
def __init__(self, message: str, status_code: int = 0, response_body: str = ""):
|
||||||
|
super().__init__(message)
|
||||||
|
self.status_code = status_code
|
||||||
|
self.response_body = response_body
|
||||||
|
|
||||||
|
|
||||||
|
# ─── HTTP helpers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _request(method: str, endpoint: str, api_key: str, json_payload=None, params=None,
|
||||||
|
extra_headers=None, timeout=60):
|
||||||
|
"""Make a request to Facturapi REST API with Basic Auth."""
|
||||||
|
url = f"{BASE_URL}{endpoint}"
|
||||||
|
headers = {"Content-Type": "application/json"}
|
||||||
|
if extra_headers:
|
||||||
|
headers.update(extra_headers)
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = requests.request(
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
auth=(api_key, ""),
|
||||||
|
headers=headers,
|
||||||
|
json=json_payload,
|
||||||
|
params=params,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
except requests.RequestException as e:
|
||||||
|
raise FacturapiError(f"Connection error: {e}", status_code=0)
|
||||||
|
|
||||||
|
if not resp.ok:
|
||||||
|
raise FacturapiError(
|
||||||
|
f"Facturapi {method.upper()} {endpoint} failed: {resp.status_code} {resp.text[:500]}",
|
||||||
|
status_code=resp.status_code,
|
||||||
|
response_body=resp.text,
|
||||||
|
)
|
||||||
|
|
||||||
|
if resp.status_code == 204 or not resp.content:
|
||||||
|
return {}
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def _download(method: str, endpoint: str, api_key: str, params=None, timeout=60) -> bytes:
|
||||||
|
"""Download binary content (XML/PDF)."""
|
||||||
|
url = f"{BASE_URL}{endpoint}"
|
||||||
|
resp = requests.request(
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
auth=(api_key, ""),
|
||||||
|
params=params,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
if not resp.ok:
|
||||||
|
raise FacturapiError(
|
||||||
|
f"Download failed: {resp.status_code} {resp.text[:500]}",
|
||||||
|
status_code=resp.status_code,
|
||||||
|
)
|
||||||
|
return resp.content
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Tenant config helpers ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _get_secret_key(tenant_config: dict) -> Optional[str]:
|
||||||
|
for key in ("facturapi_key", "facturapi_secret_key"):
|
||||||
|
val = (tenant_config.get(key) or "").strip()
|
||||||
|
if val:
|
||||||
|
return val
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_user_key() -> Optional[str]:
|
||||||
|
return USER_KEY.strip() or None
|
||||||
|
|
||||||
|
|
||||||
|
def _is_user_key_mode(tenant_config: dict) -> bool:
|
||||||
|
return bool(_get_user_key()) and not _get_secret_key(tenant_config)
|
||||||
|
|
||||||
|
|
||||||
|
def get_api_key(tenant_config: dict) -> str:
|
||||||
|
"""Resolve the API key to use for a tenant.
|
||||||
|
|
||||||
|
Priority:
|
||||||
|
1. tenant_config.facturapi_secret_key (manual override)
|
||||||
|
2. FACTURAPI_USER_KEY env (auto-org mode)
|
||||||
|
"""
|
||||||
|
secret = _get_secret_key(tenant_config)
|
||||||
|
if secret:
|
||||||
|
return secret
|
||||||
|
user = _get_user_key()
|
||||||
|
if user:
|
||||||
|
return user
|
||||||
|
raise FacturapiError(
|
||||||
|
"Facturapi not configured. Set FACTURAPI_USER_KEY env or tenant_config.facturapi_secret_key"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Organizations ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def create_organization(tenant_config: dict) -> dict:
|
||||||
|
"""Create a new Facturapi organization for the tenant.
|
||||||
|
|
||||||
|
Requires FACTURAPI_USER_KEY.
|
||||||
|
Returns dict with id, api_key.
|
||||||
|
"""
|
||||||
|
user_key = _get_user_key()
|
||||||
|
if not user_key:
|
||||||
|
raise FacturapiError("FACTURAPI_USER_KEY is required to create organizations")
|
||||||
|
|
||||||
|
payload = {"name": tenant_config.get("razon_social", tenant_config.get("name", "Nexus"))}
|
||||||
|
legal = tenant_config.get("legal_name") or tenant_config.get("razon_social")
|
||||||
|
if legal:
|
||||||
|
payload["legal"] = {"name": legal}
|
||||||
|
if tenant_config.get("rfc"):
|
||||||
|
payload["legal"] = payload.get("legal", {})
|
||||||
|
payload["legal"]["tax_id"] = tenant_config["rfc"]
|
||||||
|
|
||||||
|
org = _request("POST", "/organizations", user_key, json_payload=payload)
|
||||||
|
org_id = org.get("id")
|
||||||
|
|
||||||
|
# Generate live secret key
|
||||||
|
key_resp = _request("PUT", f"/organizations/{org_id}/apikeys/live", user_key, json_payload={})
|
||||||
|
live_key = key_resp.get("key") if isinstance(key_resp, dict) else str(key_resp)
|
||||||
|
if not live_key:
|
||||||
|
raise FacturapiError(f"Could not generate live key for org {org_id}")
|
||||||
|
|
||||||
|
return {"org_id": org_id, "api_key": live_key}
|
||||||
|
|
||||||
|
|
||||||
|
def get_organization(org_id: str, api_key: str) -> dict:
|
||||||
|
return _request("GET", f"/organizations/{org_id}", api_key)
|
||||||
|
|
||||||
|
|
||||||
|
def upload_csd(tenant_config: dict, cer_b64: str, key_b64: str, password: str) -> dict:
|
||||||
|
"""Upload CSD (Certificado de Sello Digital) to Facturapi.
|
||||||
|
|
||||||
|
cer_b64 and key_b64 are base64-encoded strings.
|
||||||
|
"""
|
||||||
|
api_key = get_api_key(tenant_config)
|
||||||
|
org_id = tenant_config.get("facturapi_org_id")
|
||||||
|
if not org_id:
|
||||||
|
raise FacturapiError("No Facturapi organization configured for tenant")
|
||||||
|
|
||||||
|
cer_bytes = base64.b64decode(cer_b64)
|
||||||
|
key_bytes = base64.b64decode(key_b64)
|
||||||
|
|
||||||
|
url = f"{BASE_URL}/organizations/{org_id}/certificate"
|
||||||
|
files = {
|
||||||
|
"certificate": ("certificate.cer", cer_bytes, "application/octet-stream"),
|
||||||
|
"private_key": ("private_key.key", key_bytes, "application/octet-stream"),
|
||||||
|
"secret": (None, password),
|
||||||
|
}
|
||||||
|
resp = requests.post(url, auth=(api_key, ""), files=files, timeout=60)
|
||||||
|
if not resp.ok:
|
||||||
|
raise FacturapiError(
|
||||||
|
f"CSD upload failed: {resp.status_code} {resp.text[:500]}",
|
||||||
|
status_code=resp.status_code,
|
||||||
|
)
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_user_key_for_tenant(tenant_config: dict) -> str:
|
||||||
|
"""Resolve the Facturapi user key to use for organization management.
|
||||||
|
|
||||||
|
Priority:
|
||||||
|
1. FACTURAPI_USER_KEY environment variable
|
||||||
|
2. tenant_config.facturapi_key if it starts with sk_user_
|
||||||
|
"""
|
||||||
|
user_key = _get_user_key()
|
||||||
|
if user_key:
|
||||||
|
return user_key
|
||||||
|
tenant_key = (tenant_config.get("facturapi_key") or "").strip()
|
||||||
|
if tenant_key.startswith("sk_user_"):
|
||||||
|
return tenant_key
|
||||||
|
raise FacturapiError(
|
||||||
|
"FACTURAPI_USER_KEY env or a Facturapi user key (sk_user_*) is required"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def find_organization_by_rfc(tenant_config: dict) -> Optional[dict]:
|
||||||
|
"""Search for an existing Facturapi organization by tenant RFC.
|
||||||
|
|
||||||
|
Requires a user key (FACTURAPI_USER_KEY env or sk_user_* tenant key).
|
||||||
|
Returns the organization dict or None.
|
||||||
|
"""
|
||||||
|
user_key = _get_user_key_for_tenant(tenant_config)
|
||||||
|
|
||||||
|
rfc = (tenant_config.get("rfc") or "").upper().strip()
|
||||||
|
if not rfc:
|
||||||
|
raise FacturapiError("Tenant RFC is required to search organizations")
|
||||||
|
|
||||||
|
page = 1
|
||||||
|
while True:
|
||||||
|
result = _request("GET", "/organizations", user_key, params={"page": page}, timeout=30)
|
||||||
|
for org in result.get("data", []):
|
||||||
|
legal = org.get("legal", {})
|
||||||
|
if (legal.get("tax_id") or "").upper() == rfc:
|
||||||
|
return org
|
||||||
|
if page >= result.get("total_pages", 1):
|
||||||
|
break
|
||||||
|
page += 1
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def create_organization(tenant_config: dict) -> dict:
|
||||||
|
"""Create a new Facturapi organization for the tenant and return live key.
|
||||||
|
|
||||||
|
Requires FACTURAPI_USER_KEY env or a user key (sk_user_*) in tenant_config.
|
||||||
|
Uses tenant RFC/razon_social if available.
|
||||||
|
"""
|
||||||
|
user_key = _get_user_key_for_tenant(tenant_config)
|
||||||
|
|
||||||
|
rfc = (tenant_config.get("rfc") or "").upper().strip()
|
||||||
|
name = tenant_config.get("razon_social") or tenant_config.get("name") or rfc or "Nexus"
|
||||||
|
|
||||||
|
# First try to find existing org by RFC
|
||||||
|
existing = find_organization_by_rfc(tenant_config) if rfc else None
|
||||||
|
if existing:
|
||||||
|
org_id = existing["id"]
|
||||||
|
else:
|
||||||
|
payload = {"name": name}
|
||||||
|
org = _request("POST", "/organizations", user_key, json_payload=payload, timeout=60)
|
||||||
|
org_id = org.get("id")
|
||||||
|
if not org_id:
|
||||||
|
raise FacturapiError("Could not create organization: no id returned")
|
||||||
|
|
||||||
|
# Generate live secret key
|
||||||
|
key_resp = _request(
|
||||||
|
"PUT", f"/organizations/{org_id}/apikeys/live", user_key, json_payload={}, timeout=60
|
||||||
|
)
|
||||||
|
live_key = key_resp.get("key") if isinstance(key_resp, dict) else str(key_resp)
|
||||||
|
if not live_key:
|
||||||
|
raise FacturapiError(f"Could not generate live key for org {org_id}")
|
||||||
|
|
||||||
|
return {"org_id": org_id, "api_key": live_key}
|
||||||
|
|
||||||
|
|
||||||
|
def get_org_status(tenant_config: dict) -> dict:
|
||||||
|
result = {
|
||||||
|
"configured": False,
|
||||||
|
"has_key": False,
|
||||||
|
"has_org_id": False,
|
||||||
|
"has_csd": False,
|
||||||
|
"org_id": None,
|
||||||
|
"legal_name": None,
|
||||||
|
"tax_id": None,
|
||||||
|
"pending_steps": [],
|
||||||
|
"error": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
api_key = get_api_key(tenant_config)
|
||||||
|
result["has_key"] = True
|
||||||
|
except FacturapiError as e:
|
||||||
|
result["error"] = str(e)
|
||||||
|
return result
|
||||||
|
|
||||||
|
org_id = tenant_config.get("facturapi_org_id")
|
||||||
|
if not org_id:
|
||||||
|
result["error"] = "No Facturapi organization configured"
|
||||||
|
return result
|
||||||
|
|
||||||
|
result["has_org_id"] = True
|
||||||
|
result["org_id"] = org_id
|
||||||
|
|
||||||
|
try:
|
||||||
|
org = get_organization(org_id, api_key)
|
||||||
|
legal = org.get("legal", {})
|
||||||
|
cert = org.get("certificate", {})
|
||||||
|
result.update({
|
||||||
|
"configured": True,
|
||||||
|
"has_csd": bool(cert.get("has_certificate")),
|
||||||
|
"legal_name": legal.get("name") or legal.get("legal_name"),
|
||||||
|
"tax_id": legal.get("tax_id"),
|
||||||
|
"pending_steps": org.get("pending_steps", []),
|
||||||
|
})
|
||||||
|
except FacturapiError as e:
|
||||||
|
result["error"] = str(e)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Customers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def create_or_update_customer(tenant_config: dict, customer_data: dict) -> str:
|
||||||
|
"""Create or update a customer in Facturapi and return its id.
|
||||||
|
|
||||||
|
customer_data: {
|
||||||
|
legal_name: str,
|
||||||
|
tax_id: str,
|
||||||
|
tax_system: str,
|
||||||
|
email: str,
|
||||||
|
zip: str,
|
||||||
|
country: str (optional, ISO 3166 alpha-3),
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
api_key = get_api_key(tenant_config)
|
||||||
|
tax_id = (customer_data.get("tax_id") or "").upper().strip()
|
||||||
|
if not tax_id:
|
||||||
|
raise FacturapiError("Customer tax_id is required")
|
||||||
|
|
||||||
|
# Try to find existing customer
|
||||||
|
existing_id = None
|
||||||
|
try:
|
||||||
|
result = _request("GET", "/customers", api_key, params={"search": tax_id})
|
||||||
|
for c in result.get("data", []):
|
||||||
|
if (c.get("tax_id") or "").upper() == tax_id:
|
||||||
|
existing_id = c.get("id")
|
||||||
|
break
|
||||||
|
except FacturapiError as e:
|
||||||
|
logger.warning("Failed to search Facturapi customer: %s", e)
|
||||||
|
|
||||||
|
is_foreign = bool(customer_data.get("country")) and customer_data["country"] != "MEX"
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"legal_name": customer_data.get("legal_name", ""),
|
||||||
|
"email": customer_data.get("email"),
|
||||||
|
"address": {
|
||||||
|
"zip": customer_data.get("zip", "00000"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if is_foreign:
|
||||||
|
payload["tax_id"] = tax_id
|
||||||
|
payload["address"]["country"] = customer_data["country"]
|
||||||
|
else:
|
||||||
|
payload["tax_id"] = tax_id
|
||||||
|
if customer_data.get("tax_system"):
|
||||||
|
payload["tax_system"] = customer_data["tax_system"]
|
||||||
|
|
||||||
|
if existing_id:
|
||||||
|
_request("PUT", f"/customers/{existing_id}", api_key, json_payload=payload)
|
||||||
|
return existing_id
|
||||||
|
|
||||||
|
new_customer = _request("POST", "/customers", api_key, json_payload=payload)
|
||||||
|
return new_customer.get("id")
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Invoices ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def create_invoice(tenant_config: dict, payload: dict) -> dict:
|
||||||
|
"""Create and stamp an invoice in Facturapi.
|
||||||
|
|
||||||
|
Returns the Facturapi invoice object.
|
||||||
|
"""
|
||||||
|
api_key = get_api_key(tenant_config)
|
||||||
|
return _request("POST", "/invoices", api_key, json_payload=payload, timeout=90)
|
||||||
|
|
||||||
|
|
||||||
|
def cancel_invoice(tenant_config: dict, invoice_id: str, motive: str,
|
||||||
|
replacement_uuid: Optional[str] = None) -> dict:
|
||||||
|
"""Cancel an invoice in Facturapi.
|
||||||
|
|
||||||
|
Motive codes:
|
||||||
|
01: errores con relacion (requires replacement_uuid)
|
||||||
|
02: errores sin relacion
|
||||||
|
03: no se llevo a cabo la operacion
|
||||||
|
04: operacion nominativa relacionada en factura global
|
||||||
|
"""
|
||||||
|
api_key = get_api_key(tenant_config)
|
||||||
|
params = {"motive": motive}
|
||||||
|
if replacement_uuid:
|
||||||
|
params["replacement"] = replacement_uuid
|
||||||
|
return _request("DELETE", f"/invoices/{invoice_id}", api_key, params=params, timeout=60)
|
||||||
|
|
||||||
|
|
||||||
|
def download_xml(tenant_config: dict, invoice_id: str) -> bytes:
|
||||||
|
api_key = get_api_key(tenant_config)
|
||||||
|
return _download("GET", f"/invoices/{invoice_id}/xml", api_key)
|
||||||
|
|
||||||
|
|
||||||
|
def download_pdf(tenant_config: dict, invoice_id: str) -> bytes:
|
||||||
|
api_key = get_api_key(tenant_config)
|
||||||
|
return _download("GET", f"/invoices/{invoice_id}/pdf", api_key)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def is_lco_rejection(message: str) -> bool:
|
||||||
|
"""Detect SAT LCO rejection (CSD not yet propagated)."""
|
||||||
|
if not message:
|
||||||
|
return False
|
||||||
|
msg = message.lower()
|
||||||
|
return any(
|
||||||
|
pattern in msg
|
||||||
|
for pattern in [
|
||||||
|
"lco",
|
||||||
|
"no se encontro el rfc",
|
||||||
|
"rfc no registrado",
|
||||||
|
"lista de contribuyentes obligados",
|
||||||
|
"csd no registrado",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def to_cents(amount) -> int:
|
||||||
|
"""Convert Decimal/float/None to integer cents for Facturapi."""
|
||||||
|
if amount is None:
|
||||||
|
return 0
|
||||||
|
return int(Decimal(str(amount)).quantize(Decimal("0.01")) * 100)
|
||||||
56
pos/services/geo_branches.py
Normal file
56
pos/services/geo_branches.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import math
|
||||||
|
|
||||||
|
def haversine(lat1, lon1, lat2, lon2):
|
||||||
|
"""Calculate the great-circle distance between two points on Earth in km."""
|
||||||
|
R = 6371.0 # Earth radius in km
|
||||||
|
phi1 = math.radians(lat1)
|
||||||
|
phi2 = math.radians(lat2)
|
||||||
|
dphi = math.radians(lat2 - lat1)
|
||||||
|
dlambda = math.radians(lon2 - lon1)
|
||||||
|
|
||||||
|
a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlambda / 2) ** 2
|
||||||
|
c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
|
||||||
|
|
||||||
|
return R * c
|
||||||
|
|
||||||
|
|
||||||
|
def find_nearest_branch(tenant_conn, latitude, longitude):
|
||||||
|
"""
|
||||||
|
Find the nearest active branch with coordinates.
|
||||||
|
Returns a dict with branch info + distance_km, or None.
|
||||||
|
"""
|
||||||
|
if not tenant_conn or latitude is None or longitude is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
cur = tenant_conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, name, address, phone, latitude, longitude
|
||||||
|
FROM branches
|
||||||
|
WHERE is_active = TRUE AND latitude IS NOT NULL AND longitude IS NOT NULL
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
branches = cur.fetchall()
|
||||||
|
cur.close()
|
||||||
|
|
||||||
|
nearest = None
|
||||||
|
min_dist = float('inf')
|
||||||
|
|
||||||
|
for row in branches:
|
||||||
|
bid, name, address, phone, b_lat, b_lon = row
|
||||||
|
if b_lat is None or b_lon is None:
|
||||||
|
continue
|
||||||
|
dist = haversine(float(latitude), float(longitude), float(b_lat), float(b_lon))
|
||||||
|
if dist < min_dist:
|
||||||
|
min_dist = dist
|
||||||
|
nearest = {
|
||||||
|
'id': bid,
|
||||||
|
'name': name,
|
||||||
|
'address': address or '',
|
||||||
|
'phone': phone or '',
|
||||||
|
'latitude': float(b_lat),
|
||||||
|
'longitude': float(b_lon),
|
||||||
|
'distance_km': round(dist, 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
return nearest
|
||||||
210
pos/services/global_invoice.py
Normal file
210
pos/services/global_invoice.py
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
# /home/Autopartes/pos/services/global_invoice.py
|
||||||
|
"""Global invoice (Factura Global) service.
|
||||||
|
|
||||||
|
Groups cash sales (PUE, <= $2,000, no individual CFDI) into a single
|
||||||
|
monthly CFDI with InformacionGlobal per SAT requirements.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from services.cfdi_facturapi_builder import build_global_invoice_payload
|
||||||
|
from services.cfdi_queue import enqueue_cfdi, _generate_provisional_folio
|
||||||
|
|
||||||
|
|
||||||
|
def get_eligible_sales(conn, year, month, branch_id=None, max_total=2000):
|
||||||
|
"""Find sales eligible for global invoicing.
|
||||||
|
|
||||||
|
Criteria:
|
||||||
|
- Payment method: PUE (paid in full)
|
||||||
|
- Total <= max_total
|
||||||
|
- No individual CFDI stamped
|
||||||
|
- Not already included in a global invoice
|
||||||
|
- Created in the given year/month
|
||||||
|
- Optionally filtered by branch_id
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list of sale dicts with items
|
||||||
|
"""
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
# Find eligible sale IDs
|
||||||
|
sql = """
|
||||||
|
SELECT s.id
|
||||||
|
FROM sales s
|
||||||
|
WHERE s.metodo_pago_sat = 'PUE'
|
||||||
|
AND s.total <= %s
|
||||||
|
AND s.status = 'completed'
|
||||||
|
AND s.global_invoiced_at IS NULL
|
||||||
|
AND EXTRACT(YEAR FROM s.created_at) = %s
|
||||||
|
AND EXTRACT(MONTH FROM s.created_at) = %s
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM cfdi_queue c
|
||||||
|
WHERE c.sale_id = s.id AND c.status = 'stamped'
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
params = [max_total, year, month]
|
||||||
|
|
||||||
|
if branch_id:
|
||||||
|
sql += " AND s.branch_id = %s"
|
||||||
|
params.append(branch_id)
|
||||||
|
|
||||||
|
sql += " ORDER BY s.created_at ASC"
|
||||||
|
|
||||||
|
cur.execute(sql, params)
|
||||||
|
sale_ids = [r[0] for r in cur.fetchall()]
|
||||||
|
|
||||||
|
if not sale_ids:
|
||||||
|
cur.close()
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Load sale details with items
|
||||||
|
sales = []
|
||||||
|
for sale_id in sale_ids:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id, branch_id, customer_id, employee_id, sale_type,
|
||||||
|
payment_method, subtotal, discount_total, tax_total, total,
|
||||||
|
metodo_pago_sat, forma_pago_sat, status, created_at
|
||||||
|
FROM sales WHERE id = %s
|
||||||
|
""", (sale_id,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
continue
|
||||||
|
|
||||||
|
sale = {
|
||||||
|
'id': row[0], 'branch_id': row[1], 'customer_id': row[2],
|
||||||
|
'employee_id': row[3], 'sale_type': row[4],
|
||||||
|
'payment_method': row[5],
|
||||||
|
'subtotal': float(row[6]) if row[6] else 0,
|
||||||
|
'discount_total': float(row[7]) if row[7] else 0,
|
||||||
|
'tax_total': float(row[8]) if row[8] else 0,
|
||||||
|
'total': float(row[9]) if row[9] else 0,
|
||||||
|
'metodo_pago_sat': row[10] or 'PUE',
|
||||||
|
'forma_pago_sat': row[11] or '01',
|
||||||
|
'status': row[12],
|
||||||
|
'created_at': str(row[13]),
|
||||||
|
'items': [],
|
||||||
|
}
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id, inventory_id, part_number, name, quantity, unit_price,
|
||||||
|
unit_cost, discount_pct, discount_amount, tax_rate, tax_amount,
|
||||||
|
subtotal, clave_prod_serv, clave_unidad
|
||||||
|
FROM sale_items WHERE sale_id = %s ORDER BY id
|
||||||
|
""", (sale_id,))
|
||||||
|
|
||||||
|
for r in cur.fetchall():
|
||||||
|
sale['items'].append({
|
||||||
|
'id': r[0], 'inventory_id': r[1], 'part_number': r[2],
|
||||||
|
'name': r[3], 'quantity': r[4],
|
||||||
|
'unit_price': float(r[5]) if r[5] else 0,
|
||||||
|
'unit_cost': float(r[6]) if r[6] else 0,
|
||||||
|
'discount_pct': float(r[7]) if r[7] else 0,
|
||||||
|
'discount_amount': float(r[8]) if r[8] else 0,
|
||||||
|
'tax_rate': float(r[9]) if r[9] else 0.16,
|
||||||
|
'tax_amount': float(r[10]) if r[10] else 0,
|
||||||
|
'subtotal': float(r[11]) if r[11] else 0,
|
||||||
|
'clave_prod_serv': r[12] or '25174800',
|
||||||
|
'clave_unidad': r[13] or 'H87',
|
||||||
|
})
|
||||||
|
|
||||||
|
sales.append(sale)
|
||||||
|
|
||||||
|
cur.close()
|
||||||
|
return sales
|
||||||
|
|
||||||
|
|
||||||
|
def generate_global_invoice(conn, tenant_config, year, month, branch_id=None,
|
||||||
|
max_total=2000, employee_id=None):
|
||||||
|
"""Generate a global invoice for the given month.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
conn: psycopg2 connection
|
||||||
|
tenant_config: dict with rfc, razon_social, regimen_fiscal, cp, serie
|
||||||
|
year: int
|
||||||
|
month: int
|
||||||
|
branch_id: optional branch filter
|
||||||
|
max_total: max sale total to include (default $2,000)
|
||||||
|
employee_id: optional employee ID for audit
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: {id, status, sales_count, total, xml, provisional_folio}
|
||||||
|
or {error, message} if no eligible sales
|
||||||
|
"""
|
||||||
|
sales = get_eligible_sales(conn, year, month, branch_id, max_total)
|
||||||
|
|
||||||
|
if not sales:
|
||||||
|
return {'error': 'NO_ELIGIBLE_SALES',
|
||||||
|
'message': f'No hay ventas elegibles para factura global de {month:02d}/{year}'}
|
||||||
|
|
||||||
|
payload = build_global_invoice_payload(sales, tenant_config, year, month)
|
||||||
|
|
||||||
|
# Enqueue with sale_id=NULL (global invoice)
|
||||||
|
result = enqueue_cfdi(conn, None, 'ingreso', payload)
|
||||||
|
cfdi_id = result['id']
|
||||||
|
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
# Link sales to global invoice
|
||||||
|
for sale in sales:
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO global_invoice_sales (global_invoice_id, sale_id)
|
||||||
|
VALUES (%s, %s)
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
""", (cfdi_id, sale['id']))
|
||||||
|
|
||||||
|
# Mark sale as globally invoiced
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE sales SET global_invoiced_at = NOW() WHERE id = %s
|
||||||
|
""", (sale['id'],))
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
|
||||||
|
return {
|
||||||
|
'id': cfdi_id,
|
||||||
|
'status': 'pending',
|
||||||
|
'sales_count': len(sales),
|
||||||
|
'total': sum(s['total'] for s in sales),
|
||||||
|
'provisional_folio': result['provisional_folio'],
|
||||||
|
'payload': payload,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_global_invoice_status(conn, cfdi_id):
|
||||||
|
"""Get status of a global invoice including linked sales."""
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id, status, uuid_fiscal, provisional_folio, error_message,
|
||||||
|
created_at, stamped_at
|
||||||
|
FROM cfdi_queue WHERE id = %s
|
||||||
|
""", (cfdi_id,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
cur.close()
|
||||||
|
return None
|
||||||
|
|
||||||
|
result = {
|
||||||
|
'id': row[0], 'status': row[1], 'uuid_fiscal': row[2],
|
||||||
|
'provisional_folio': row[3], 'error_message': row[4],
|
||||||
|
'created_at': str(row[5]), 'stamped_at': str(row[6]) if row[6] else None,
|
||||||
|
'sales': [],
|
||||||
|
}
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
SELECT s.id, s.total, s.created_at
|
||||||
|
FROM global_invoice_sales gis
|
||||||
|
JOIN sales s ON s.id = gis.sale_id
|
||||||
|
WHERE gis.global_invoice_id = %s
|
||||||
|
ORDER BY s.created_at ASC
|
||||||
|
""", (cfdi_id,))
|
||||||
|
|
||||||
|
for r in cur.fetchall():
|
||||||
|
result['sales'].append({
|
||||||
|
'id': r[0], 'total': float(r[1]) if r[1] else 0,
|
||||||
|
'created_at': str(r[2]),
|
||||||
|
})
|
||||||
|
|
||||||
|
cur.close()
|
||||||
|
return result
|
||||||
@@ -25,22 +25,23 @@ def _safe_g(attr, default=None):
|
|||||||
def get_stock(conn, inventory_id, branch_id=None):
|
def get_stock(conn, inventory_id, branch_id=None):
|
||||||
"""Get current stock for an inventory item. Optionally filter by branch.
|
"""Get current stock for an inventory item. Optionally filter by branch.
|
||||||
|
|
||||||
Uses Redis cache first, then inventory_stock_summary, falls back to
|
Uses Redis cache first, then inventory_stock (per-branch) or
|
||||||
PostgreSQL SUM query.
|
inventory_stock_summary (total), falls back to PostgreSQL SUM query.
|
||||||
"""
|
"""
|
||||||
# Try Redis first
|
# Try Redis first
|
||||||
cached = get_cached_stock(inventory_id, branch_id)
|
cached = get_cached_stock(inventory_id, branch_id)
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
return cached
|
return cached
|
||||||
|
|
||||||
# Use inventory_stock_summary (O(1) lookup)
|
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
if branch_id:
|
if branch_id:
|
||||||
|
# Per-branch stock from inventory_stock
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"SELECT stock FROM inventory_stock_summary WHERE inventory_id = %s AND branch_id = %s",
|
"SELECT stock FROM inventory_stock WHERE inventory_id = %s AND branch_id = %s",
|
||||||
(inventory_id, branch_id)
|
(inventory_id, branch_id)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
# Total stock from inventory_stock_summary
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"SELECT stock FROM inventory_stock_summary WHERE inventory_id = %s",
|
"SELECT stock FROM inventory_stock_summary WHERE inventory_id = %s",
|
||||||
(inventory_id,)
|
(inventory_id,)
|
||||||
@@ -73,13 +74,14 @@ def get_stock(conn, inventory_id, branch_id=None):
|
|||||||
def get_stock_bulk(conn, branch_id=None):
|
def get_stock_bulk(conn, branch_id=None):
|
||||||
"""Get stock for all items. Returns dict {inventory_id: stock_quantity}.
|
"""Get stock for all items. Returns dict {inventory_id: stock_quantity}.
|
||||||
|
|
||||||
Uses inventory_stock_summary for O(1) bulk lookup.
|
Uses inventory_stock (per-branch) or inventory_stock_summary (total)
|
||||||
|
for O(1) bulk lookup.
|
||||||
"""
|
"""
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
if branch_id:
|
if branch_id:
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT inventory_id, stock
|
SELECT inventory_id, stock
|
||||||
FROM inventory_stock_summary WHERE branch_id = %s
|
FROM inventory_stock WHERE branch_id = %s
|
||||||
""", (branch_id,))
|
""", (branch_id,))
|
||||||
else:
|
else:
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
@@ -119,6 +121,18 @@ def record_operation(conn, inventory_id, branch_id, operation_type, quantity,
|
|||||||
notes
|
notes
|
||||||
))
|
))
|
||||||
op_id = cur.fetchone()[0]
|
op_id = cur.fetchone()[0]
|
||||||
|
|
||||||
|
# Queue ML stock sync if this product has an active ML listing
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO meli_sync_queue (inventory_id, action, status)
|
||||||
|
SELECT %s, 'stock_update', 'pending'
|
||||||
|
WHERE EXISTS (
|
||||||
|
SELECT 1 FROM marketplace_listings
|
||||||
|
WHERE inventory_id = %s AND channel = 'mercadolibre' AND is_active = true
|
||||||
|
)
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
""", (inventory_id, inventory_id))
|
||||||
|
|
||||||
cur.close()
|
cur.close()
|
||||||
return op_id
|
return op_id
|
||||||
|
|
||||||
@@ -272,38 +286,72 @@ def record_initial(conn, inventory_id, branch_id, quantity, cost=None):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def get_alerts(conn, branch_id=None):
|
def get_alerts(conn, branch_id=None, limit_per_type=500):
|
||||||
"""Get stock alerts: zero stock, below minimum, above maximum."""
|
"""Get stock alerts: zero stock, below minimum, above maximum.
|
||||||
stock_map = get_stock_bulk(conn, branch_id)
|
Returns at most limit_per_type alerts per severity to avoid browser freeze.
|
||||||
|
"""
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
|
branch_filter = ""
|
||||||
where = "WHERE i.is_active = true"
|
|
||||||
params = []
|
params = []
|
||||||
if branch_id:
|
if branch_id:
|
||||||
where += " AND i.branch_id = %s"
|
branch_filter = " AND i.branch_id = %s"
|
||||||
params.append(branch_id)
|
params.append(branch_id)
|
||||||
|
|
||||||
|
# Use a single SQL query with window functions to rank and limit per type
|
||||||
cur.execute(f"""
|
cur.execute(f"""
|
||||||
SELECT i.id, i.part_number, i.name, i.min_stock, i.max_stock, i.branch_id
|
WITH stock AS (
|
||||||
FROM inventory i {where}
|
SELECT inventory_id, COALESCE(SUM(quantity), 0) AS qty
|
||||||
""", params)
|
FROM inventory_operations
|
||||||
|
GROUP BY inventory_id
|
||||||
|
),
|
||||||
|
alerts_raw AS (
|
||||||
|
SELECT
|
||||||
|
i.id AS inventory_id,
|
||||||
|
i.part_number,
|
||||||
|
i.name,
|
||||||
|
COALESCE(s.qty, 0) AS stock,
|
||||||
|
i.min_stock,
|
||||||
|
i.max_stock,
|
||||||
|
i.branch_id,
|
||||||
|
CASE
|
||||||
|
WHEN COALESCE(s.qty, 0) <= 0 THEN 'zero'
|
||||||
|
WHEN i.min_stock IS NOT NULL AND COALESCE(s.qty, 0) < i.min_stock THEN 'low'
|
||||||
|
WHEN i.max_stock IS NOT NULL AND COALESCE(s.qty, 0) > i.max_stock THEN 'over'
|
||||||
|
END AS alert_type,
|
||||||
|
CASE
|
||||||
|
WHEN COALESCE(s.qty, 0) <= 0 THEN 'critical'
|
||||||
|
WHEN i.min_stock IS NOT NULL AND COALESCE(s.qty, 0) < i.min_stock THEN 'warning'
|
||||||
|
WHEN i.max_stock IS NOT NULL AND COALESCE(s.qty, 0) > i.max_stock THEN 'info'
|
||||||
|
END AS severity
|
||||||
|
FROM inventory i
|
||||||
|
LEFT JOIN stock s ON s.inventory_id = i.id
|
||||||
|
WHERE i.is_active = true {branch_filter}
|
||||||
|
),
|
||||||
|
ranked AS (
|
||||||
|
SELECT *,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY alert_type ORDER BY inventory_id) AS rn
|
||||||
|
FROM alerts_raw
|
||||||
|
WHERE alert_type IS NOT NULL
|
||||||
|
)
|
||||||
|
SELECT inventory_id, part_number, name, stock, min_stock, max_stock, branch_id, alert_type, severity
|
||||||
|
FROM ranked
|
||||||
|
WHERE rn <= %s
|
||||||
|
ORDER BY severity DESC, inventory_id
|
||||||
|
""", params + [limit_per_type])
|
||||||
|
|
||||||
alerts = []
|
alerts = []
|
||||||
for row in cur.fetchall():
|
for row in cur.fetchall():
|
||||||
inv_id, part_num, name, min_s, max_s, br_id = row
|
alerts.append({
|
||||||
stock = stock_map.get(inv_id, 0)
|
'inventory_id': row[0],
|
||||||
|
'part_number': row[1],
|
||||||
if stock <= 0:
|
'name': row[2],
|
||||||
alerts.append({'type': 'zero', 'severity': 'critical', 'inventory_id': inv_id,
|
'stock': row[3],
|
||||||
'part_number': part_num, 'name': name, 'stock': stock, 'branch_id': br_id})
|
'min_stock': row[4],
|
||||||
elif min_s and stock < min_s:
|
'max_stock': row[5],
|
||||||
alerts.append({'type': 'low', 'severity': 'warning', 'inventory_id': inv_id,
|
'branch_id': row[6],
|
||||||
'part_number': part_num, 'name': name, 'stock': stock,
|
'type': row[7],
|
||||||
'min_stock': min_s, 'branch_id': br_id})
|
'severity': row[8],
|
||||||
elif max_s and stock > max_s:
|
})
|
||||||
alerts.append({'type': 'over', 'severity': 'info', 'inventory_id': inv_id,
|
|
||||||
'part_number': part_num, 'name': name, 'stock': stock,
|
|
||||||
'max_stock': max_s, 'branch_id': br_id})
|
|
||||||
|
|
||||||
cur.close()
|
cur.close()
|
||||||
return alerts
|
return alerts
|
||||||
|
|||||||
@@ -178,18 +178,31 @@ def auto_match_vehicle_compatibility(master_conn, tenant_conn, inventory_id, par
|
|||||||
JOIN brands b ON b.id_brand = m.brand_id
|
JOIN brands b ON b.id_brand = m.brand_id
|
||||||
WHERE vp.part_id = ANY(%s)
|
WHERE vp.part_id = ANY(%s)
|
||||||
AND b.name_brand = %s
|
AND b.name_brand = %s
|
||||||
LIMIT 200
|
LIMIT 500
|
||||||
""", (oem_ids, brand_hint))
|
""", (oem_ids, brand_hint))
|
||||||
|
mye_ids = [r[0] for r in cur.fetchall()]
|
||||||
|
|
||||||
|
# Fallback: if brand filter yields nothing, the brand hint may be an
|
||||||
|
# aftermarket supplier (e.g. Motorcraft, NGK, Bosch) rather than an
|
||||||
|
# OEM vehicle brand. Search without brand filter.
|
||||||
|
if not mye_ids:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT DISTINCT model_year_engine_id
|
||||||
|
FROM vehicle_parts
|
||||||
|
WHERE part_id = ANY(%s)
|
||||||
|
LIMIT 500
|
||||||
|
""", (oem_ids,))
|
||||||
|
mye_ids = [r[0] for r in cur.fetchall()]
|
||||||
else:
|
else:
|
||||||
# No brand hint — return all MYEs for these parts
|
# No brand hint — return all MYEs for these parts
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT DISTINCT model_year_engine_id
|
SELECT DISTINCT model_year_engine_id
|
||||||
FROM vehicle_parts
|
FROM vehicle_parts
|
||||||
WHERE part_id = ANY(%s)
|
WHERE part_id = ANY(%s)
|
||||||
LIMIT 200
|
LIMIT 500
|
||||||
""", (oem_ids,))
|
""", (oem_ids,))
|
||||||
|
|
||||||
mye_ids = [r[0] for r in cur.fetchall()]
|
mye_ids = [r[0] for r in cur.fetchall()]
|
||||||
|
|
||||||
cur.close()
|
cur.close()
|
||||||
|
|
||||||
# ── Insert into tenant table ─────────────────────────────────────────
|
# ── Insert into tenant table ─────────────────────────────────────────
|
||||||
@@ -243,6 +256,20 @@ def remove_compatibility(tenant_conn, inventory_id, model_year_engine_id):
|
|||||||
return deleted
|
return deleted
|
||||||
|
|
||||||
|
|
||||||
|
def remove_compatibility_by_id(tenant_conn, compat_id):
|
||||||
|
"""Remove a compatibility by its primary key (works for both MYE-linked
|
||||||
|
and text-only QWEN records)."""
|
||||||
|
cur = tenant_conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
DELETE FROM inventory_vehicle_compat
|
||||||
|
WHERE id = %s
|
||||||
|
""", (compat_id,))
|
||||||
|
deleted = cur.rowcount
|
||||||
|
tenant_conn.commit()
|
||||||
|
cur.close()
|
||||||
|
return deleted
|
||||||
|
|
||||||
|
|
||||||
def remove_all_compatibility(tenant_conn, inventory_id):
|
def remove_all_compatibility(tenant_conn, inventory_id):
|
||||||
cur = tenant_conn.cursor()
|
cur = tenant_conn.cursor()
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
@@ -259,14 +286,18 @@ def get_compatibility(tenant_conn, master_conn, inventory_id):
|
|||||||
|
|
||||||
Queries inventory_vehicle_compat from the tenant DB, then resolves
|
Queries inventory_vehicle_compat from the tenant DB, then resolves
|
||||||
vehicle details (brand/model/year/engine) from the master DB.
|
vehicle details (brand/model/year/engine) from the master DB.
|
||||||
|
|
||||||
|
Vehicles with model_year_engine_id IS NULL are text-only QWEN records
|
||||||
|
(master DB lacks the vehicle) and are returned using their stored text.
|
||||||
"""
|
"""
|
||||||
# 1. Get MYE IDs + metadata from tenant
|
# 1. Get all rows from tenant
|
||||||
cur_t = tenant_conn.cursor()
|
cur_t = tenant_conn.cursor()
|
||||||
cur_t.execute("""
|
cur_t.execute("""
|
||||||
SELECT model_year_engine_id, source, confidence, created_at
|
SELECT id, model_year_engine_id, make, model, year, engine, engine_code,
|
||||||
|
source, confidence, created_at
|
||||||
FROM inventory_vehicle_compat
|
FROM inventory_vehicle_compat
|
||||||
WHERE inventory_id = %s
|
WHERE inventory_id = %s
|
||||||
ORDER BY model_year_engine_id
|
ORDER BY COALESCE(make, ''), COALESCE(model, ''), COALESCE(year, 0)
|
||||||
""", (inventory_id,))
|
""", (inventory_id,))
|
||||||
rows = cur_t.fetchall()
|
rows = cur_t.fetchall()
|
||||||
cur_t.close()
|
cur_t.close()
|
||||||
@@ -274,9 +305,10 @@ def get_compatibility(tenant_conn, master_conn, inventory_id):
|
|||||||
if not rows:
|
if not rows:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
mye_ids = [r[0] for r in rows]
|
# 2. Resolve MYE-linked vehicles from master DB
|
||||||
|
mye_ids = [r[0] for r in rows if r[0] is not None]
|
||||||
# 2. Resolve vehicle details from master DB
|
details = {}
|
||||||
|
if mye_ids:
|
||||||
cur_m = master_conn.cursor()
|
cur_m = master_conn.cursor()
|
||||||
cur_m.execute("""
|
cur_m.execute("""
|
||||||
SELECT mye.id_mye, b.name_brand, m.name_model, y.year_car, e.name_engine
|
SELECT mye.id_mye, b.name_brand, m.name_model, y.year_car, e.name_engine
|
||||||
@@ -293,15 +325,32 @@ def get_compatibility(tenant_conn, master_conn, inventory_id):
|
|||||||
|
|
||||||
# 3. Merge
|
# 3. Merge
|
||||||
result = []
|
result = []
|
||||||
for mye_id, source, confidence, created_at in rows:
|
for (compat_id, mye_id, make, model, year, engine, engine_code,
|
||||||
d = details.get(mye_id)
|
source, confidence, created_at) in rows:
|
||||||
if d:
|
if mye_id is not None and mye_id in details:
|
||||||
|
d = details[mye_id]
|
||||||
result.append({
|
result.append({
|
||||||
|
'id': compat_id,
|
||||||
'model_year_engine_id': mye_id,
|
'model_year_engine_id': mye_id,
|
||||||
'brand': d[1],
|
'brand': d[1],
|
||||||
'model': d[2],
|
'model': d[2],
|
||||||
'year': d[3],
|
'year': d[3],
|
||||||
'engine': d[4],
|
'engine': d[4],
|
||||||
|
'engine_code': '',
|
||||||
|
'source': source,
|
||||||
|
'confidence': float(confidence),
|
||||||
|
'created_at': str(created_at),
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
# Text-only QWEN record
|
||||||
|
result.append({
|
||||||
|
'id': compat_id,
|
||||||
|
'model_year_engine_id': None,
|
||||||
|
'brand': make or '',
|
||||||
|
'model': model or '',
|
||||||
|
'year': year,
|
||||||
|
'engine': engine or '',
|
||||||
|
'engine_code': engine_code or '',
|
||||||
'source': source,
|
'source': source,
|
||||||
'confidence': float(confidence),
|
'confidence': float(confidence),
|
||||||
'created_at': str(created_at),
|
'created_at': str(created_at),
|
||||||
@@ -374,6 +423,9 @@ def batch_add_compatibilities(tenant_conn, inventory_id, mye_ids, source='manual
|
|||||||
def save_qwen_fitment(tenant_conn, inventory_id, fitment_result):
|
def save_qwen_fitment(tenant_conn, inventory_id, fitment_result):
|
||||||
"""Save QWEN fitment results into inventory_vehicle_compat.
|
"""Save QWEN fitment results into inventory_vehicle_compat.
|
||||||
|
|
||||||
|
Supports both TecDoc-linked vehicles (mye_id present) and text-only
|
||||||
|
QWEN vehicles (mye_id=None) when the master DB lacks the vehicle.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
tenant_conn: Connection to tenant DB.
|
tenant_conn: Connection to tenant DB.
|
||||||
inventory_id: The inventory item ID.
|
inventory_id: The inventory item ID.
|
||||||
@@ -390,14 +442,30 @@ def save_qwen_fitment(tenant_conn, inventory_id, fitment_result):
|
|||||||
cur = tenant_conn.cursor()
|
cur = tenant_conn.cursor()
|
||||||
for v in vehicles:
|
for v in vehicles:
|
||||||
mye_id = v.get('mye_id')
|
mye_id = v.get('mye_id')
|
||||||
if not mye_id:
|
if mye_id is not None and mye_id:
|
||||||
continue
|
# TecDoc-linked vehicle
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
INSERT INTO inventory_vehicle_compat
|
INSERT INTO inventory_vehicle_compat
|
||||||
(inventory_id, model_year_engine_id, source, confidence, created_at)
|
(inventory_id, model_year_engine_id, source, confidence, created_at)
|
||||||
VALUES (%s, %s, 'qwen_ai', %s, NOW())
|
VALUES (%s, %s, 'qwen_ai', %s, NOW())
|
||||||
ON CONFLICT (inventory_id, model_year_engine_id) DO NOTHING
|
ON CONFLICT (inventory_id, model_year_engine_id, make, model, year) DO NOTHING
|
||||||
""", (inventory_id, mye_id, fitment_result.get('confidence', 0)))
|
""", (inventory_id, mye_id, fitment_result.get('confidence', 0)))
|
||||||
|
else:
|
||||||
|
# Text-only QWEN vehicle (master DB doesn't have this vehicle)
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO inventory_vehicle_compat
|
||||||
|
(inventory_id, model_year_engine_id, make, model, year, engine, engine_code, source, confidence, created_at)
|
||||||
|
VALUES (%s, NULL, %s, %s, %s, %s, %s, 'qwen_ai', %s, NOW())
|
||||||
|
ON CONFLICT (inventory_id, model_year_engine_id, make, model, year) DO NOTHING
|
||||||
|
""", (
|
||||||
|
inventory_id,
|
||||||
|
v.get('make', '') or '',
|
||||||
|
v.get('model', '') or '',
|
||||||
|
v.get('year', 0) or 0,
|
||||||
|
v.get('engine', '') or '',
|
||||||
|
v.get('engine_code', '') or '',
|
||||||
|
fitment_result.get('confidence', 0),
|
||||||
|
))
|
||||||
if cur.rowcount > 0:
|
if cur.rowcount > 0:
|
||||||
inserted += 1
|
inserted += 1
|
||||||
tenant_conn.commit()
|
tenant_conn.commit()
|
||||||
|
|||||||
1768
pos/services/marketplace_external_service.py
Normal file
1768
pos/services/marketplace_external_service.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -136,15 +136,15 @@ def upload_inventory_csv(master_conn, bodega_id: int, csv_text: str) -> dict:
|
|||||||
Expected columns (case-insensitive, whitespace-tolerant):
|
Expected columns (case-insensitive, whitespace-tolerant):
|
||||||
part_number, stock, price
|
part_number, stock, price
|
||||||
Optional:
|
Optional:
|
||||||
min_order, warehouse_location, currency
|
name, min_order, warehouse_location, currency
|
||||||
|
|
||||||
Resolution rules:
|
Resolution rules:
|
||||||
- part_number matches `parts.oem_part_number` exactly (case-sensitive).
|
- part_number matches `parts.oem_part_number` or `part_cross_references.cross_reference_number`.
|
||||||
- Parts not found in the master catalog are skipped and reported.
|
- If matched → linked to catalog (part_id set, seller fields NULL).
|
||||||
- Existing rows for (bodega_id, part_id, warehouse_location) are updated
|
- If NOT matched → created as seller listing (part_id NULL, seller_part_number set).
|
||||||
via UPSERT; new rows are inserted.
|
- Existing rows are updated via UPSERT on the composite unique key.
|
||||||
|
|
||||||
Returns a summary dict: {ok, inserted, updated, skipped, errors}
|
Returns a summary dict: {ok, inserted, updated, skipped, errors, oem_count, seller_count}
|
||||||
"""
|
"""
|
||||||
reader = csv.DictReader(io.StringIO(csv_text))
|
reader = csv.DictReader(io.StringIO(csv_text))
|
||||||
# Normalize header names
|
# Normalize header names
|
||||||
@@ -166,9 +166,15 @@ def upload_inventory_csv(master_conn, bodega_id: int, csv_text: str) -> dict:
|
|||||||
cur.close()
|
cur.close()
|
||||||
return {'ok': False, 'error': f'bodega_id {bodega_id} no existe'}
|
return {'ok': False, 'error': f'bodega_id {bodega_id} no existe'}
|
||||||
|
|
||||||
|
# Pre-load cross-reference map for fast lookup
|
||||||
|
cur.execute("SELECT cross_reference_number, oem_part_id FROM part_cross_references")
|
||||||
|
xref_map = {row[0].strip(): row[1] for row in cur.fetchall()}
|
||||||
|
|
||||||
inserted = 0
|
inserted = 0
|
||||||
updated = 0
|
updated = 0
|
||||||
skipped = 0
|
skipped = 0
|
||||||
|
oem_count = 0
|
||||||
|
seller_count = 0
|
||||||
errors = []
|
errors = []
|
||||||
|
|
||||||
for i, row in enumerate(reader, start=2): # start=2 because row 1 is headers
|
for i, row in enumerate(reader, start=2): # start=2 because row 1 is headers
|
||||||
@@ -176,6 +182,7 @@ def upload_inventory_csv(master_conn, bodega_id: int, csv_text: str) -> dict:
|
|||||||
part_number = norm.get('part_number', '')
|
part_number = norm.get('part_number', '')
|
||||||
stock_str = norm.get('stock', '0')
|
stock_str = norm.get('stock', '0')
|
||||||
price_str = norm.get('price', '0')
|
price_str = norm.get('price', '0')
|
||||||
|
part_name = norm.get('name', '')
|
||||||
|
|
||||||
if not part_number:
|
if not part_number:
|
||||||
errors.append(f'Fila {i}: part_number vacio')
|
errors.append(f'Fila {i}: part_number vacio')
|
||||||
@@ -190,17 +197,20 @@ def upload_inventory_csv(master_conn, bodega_id: int, csv_text: str) -> dict:
|
|||||||
skipped += 1
|
skipped += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Resolve part_number → part_id
|
# Resolve part_number → part_id (OEM catalog or cross-reference)
|
||||||
|
part_id = None
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"SELECT id_part FROM parts WHERE oem_part_number = %s LIMIT 1",
|
"SELECT id_part FROM parts WHERE oem_part_number = %s LIMIT 1",
|
||||||
(part_number,)
|
(part_number,)
|
||||||
)
|
)
|
||||||
row_part = cur.fetchone()
|
row_part = cur.fetchone()
|
||||||
if not row_part:
|
if row_part:
|
||||||
errors.append(f'Fila {i}: part_number "{part_number}" no encontrado en catalogo')
|
|
||||||
skipped += 1
|
|
||||||
continue
|
|
||||||
part_id = row_part[0]
|
part_id = row_part[0]
|
||||||
|
else:
|
||||||
|
# Try cross-reference
|
||||||
|
xref_id = xref_map.get(part_number)
|
||||||
|
if xref_id:
|
||||||
|
part_id = xref_id
|
||||||
|
|
||||||
# Resolve user_id from the bodega (use bodega_id as fallback if null)
|
# Resolve user_id from the bodega (use bodega_id as fallback if null)
|
||||||
user_id = norm.get('user_id') or bodega_id # backward compat
|
user_id = norm.get('user_id') or bodega_id # backward compat
|
||||||
@@ -213,24 +223,48 @@ def upload_inventory_csv(master_conn, bodega_id: int, csv_text: str) -> dict:
|
|||||||
currency = (norm.get('currency') or 'MXN').upper()
|
currency = (norm.get('currency') or 'MXN').upper()
|
||||||
min_order = int(norm.get('min_order') or 1)
|
min_order = int(norm.get('min_order') or 1)
|
||||||
|
|
||||||
# UPSERT on (user_id, part_id, warehouse_location) — the existing
|
# UPSERT on composite unique (bodega_id, part_id, seller_part_number, warehouse_location)
|
||||||
# unique constraint. Don't block if user_id FK fails.
|
|
||||||
try:
|
try:
|
||||||
|
if part_id:
|
||||||
|
# OEM-matched listing
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
INSERT INTO warehouse_inventory
|
INSERT INTO warehouse_inventory
|
||||||
(user_id, part_id, price, stock_quantity, min_order_quantity,
|
(user_id, part_id, seller_part_number, seller_part_name,
|
||||||
|
price, stock_quantity, min_order_quantity,
|
||||||
warehouse_location, bodega_id, currency, updated_at)
|
warehouse_location, bodega_id, currency, updated_at)
|
||||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, NOW())
|
VALUES (%s, %s, NULL, NULL, %s, %s, %s, %s, %s, %s, NOW())
|
||||||
ON CONFLICT (user_id, part_id, warehouse_location)
|
ON CONFLICT (bodega_id, part_id, warehouse_location) WHERE part_id IS NOT NULL
|
||||||
DO UPDATE SET
|
DO UPDATE SET
|
||||||
price = EXCLUDED.price,
|
price = EXCLUDED.price,
|
||||||
stock_quantity = EXCLUDED.stock_quantity,
|
stock_quantity = EXCLUDED.stock_quantity,
|
||||||
min_order_quantity = EXCLUDED.min_order_quantity,
|
min_order_quantity = EXCLUDED.min_order_quantity,
|
||||||
bodega_id = EXCLUDED.bodega_id,
|
user_id = EXCLUDED.user_id,
|
||||||
currency = EXCLUDED.currency,
|
currency = EXCLUDED.currency,
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
RETURNING (xmax = 0) AS inserted
|
RETURNING (xmax = 0) AS inserted
|
||||||
""", (user_id, part_id, price, stock, min_order, location, bodega_id, currency))
|
""", (user_id, part_id, price, stock, min_order, location, bodega_id, currency))
|
||||||
|
oem_count += 1
|
||||||
|
else:
|
||||||
|
# Seller listing (no catalog match)
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO warehouse_inventory
|
||||||
|
(user_id, part_id, seller_part_number, seller_part_name,
|
||||||
|
price, stock_quantity, min_order_quantity,
|
||||||
|
warehouse_location, bodega_id, currency, updated_at)
|
||||||
|
VALUES (%s, NULL, %s, %s, %s, %s, %s, %s, %s, %s, NOW())
|
||||||
|
ON CONFLICT (bodega_id, seller_part_number, warehouse_location) WHERE part_id IS NULL
|
||||||
|
DO UPDATE SET
|
||||||
|
price = EXCLUDED.price,
|
||||||
|
stock_quantity = EXCLUDED.stock_quantity,
|
||||||
|
min_order_quantity = EXCLUDED.min_order_quantity,
|
||||||
|
seller_part_name = EXCLUDED.seller_part_name,
|
||||||
|
user_id = EXCLUDED.user_id,
|
||||||
|
currency = EXCLUDED.currency,
|
||||||
|
updated_at = NOW()
|
||||||
|
RETURNING (xmax = 0) AS inserted
|
||||||
|
""", (user_id, part_number, part_name or part_number, price, stock, min_order, location, bodega_id, currency))
|
||||||
|
seller_count += 1
|
||||||
|
|
||||||
was_insert = cur.fetchone()[0]
|
was_insert = cur.fetchone()[0]
|
||||||
if was_insert:
|
if was_insert:
|
||||||
inserted += 1
|
inserted += 1
|
||||||
@@ -250,6 +284,8 @@ def upload_inventory_csv(master_conn, bodega_id: int, csv_text: str) -> dict:
|
|||||||
'inserted': inserted,
|
'inserted': inserted,
|
||||||
'updated': updated,
|
'updated': updated,
|
||||||
'skipped': skipped,
|
'skipped': skipped,
|
||||||
|
'oem_count': oem_count,
|
||||||
|
'seller_count': seller_count,
|
||||||
'errors': errors[:20], # cap to avoid huge responses
|
'errors': errors[:20], # cap to avoid huge responses
|
||||||
'total_errors': len(errors),
|
'total_errors': len(errors),
|
||||||
}
|
}
|
||||||
@@ -262,70 +298,114 @@ def search_inventory(master_conn, *, query: str = None, brand: str = None,
|
|||||||
Returns parts WITH stock > 0 from VERIFIED bodegas only.
|
Returns parts WITH stock > 0 from VERIFIED bodegas only.
|
||||||
Aggregates identical parts across bodegas so the buyer sees each part once
|
Aggregates identical parts across bodegas so the buyer sees each part once
|
||||||
with a list of bodegas that have it in stock.
|
with a list of bodegas that have it in stock.
|
||||||
|
|
||||||
|
Includes both OEM-matched parts (part_id IS NOT NULL) and seller listings
|
||||||
|
(part_id IS NULL) in a single unified result set.
|
||||||
"""
|
"""
|
||||||
cur = master_conn.cursor()
|
cur = master_conn.cursor()
|
||||||
|
|
||||||
clauses = ["wi.stock_quantity > 0", "b.verified = TRUE"]
|
like = f'%{query}%' if query else None
|
||||||
params = []
|
city_lower = city.lower() if city else None
|
||||||
|
params_common = []
|
||||||
|
|
||||||
|
# Build city filter once
|
||||||
|
city_clause = ""
|
||||||
|
if city_lower:
|
||||||
|
city_clause = "AND LOWER(b.city) = LOWER(%s)"
|
||||||
|
params_common.append(city)
|
||||||
|
|
||||||
|
# ─── Part A: OEM-matched parts (JOIN with parts catalog) ──────────
|
||||||
|
clauses_oem = ["wi.stock_quantity > 0", "b.verified = TRUE", "wi.part_id IS NOT NULL"]
|
||||||
|
params_oem = []
|
||||||
|
|
||||||
if query:
|
if query:
|
||||||
clauses.append("(p.oem_part_number ILIKE %s OR p.name_part ILIKE %s OR COALESCE(p.name_es, '') ILIKE %s)")
|
clauses_oem.append("(p.oem_part_number ILIKE %s OR p.name_part ILIKE %s OR COALESCE(p.name_es, '') ILIKE %s)")
|
||||||
like = f'%{query}%'
|
params_oem.extend([like, like, like])
|
||||||
params.extend([like, like, like])
|
|
||||||
|
|
||||||
if brand:
|
if brand:
|
||||||
# Search by vehicle brand via vehicle_parts → model_year_engine → models → brands.
|
clauses_oem.append("""
|
||||||
# Too slow for this MVP. Instead, match on aftermarket manufacturer name.
|
|
||||||
clauses.append("""
|
|
||||||
EXISTS (
|
EXISTS (
|
||||||
SELECT 1 FROM aftermarket_parts ap
|
SELECT 1 FROM aftermarket_parts ap
|
||||||
JOIN manufacturers m ON m.id_manufacture = ap.manufacturer_id
|
JOIN manufacturers m ON m.id_manufacture = ap.manufacturer_id
|
||||||
WHERE ap.oem_part_id = p.id_part AND UPPER(m.name_manufacture) = UPPER(%s)
|
WHERE ap.oem_part_id = p.id_part AND UPPER(m.name_manufacture) = UPPER(%s)
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
params.append(brand)
|
params_oem.append(brand)
|
||||||
|
|
||||||
if city:
|
where_oem = " AND ".join(clauses_oem)
|
||||||
clauses.append("LOWER(b.city) = LOWER(%s)")
|
|
||||||
params.append(city)
|
|
||||||
|
|
||||||
where_sql = " AND ".join(clauses)
|
# ─── Part B: Seller listings (no parts catalog join) ──────────────
|
||||||
|
clauses_seller = ["wi.stock_quantity > 0", "b.verified = TRUE", "wi.part_id IS NULL"]
|
||||||
|
params_seller = []
|
||||||
|
|
||||||
cur.execute(f"""
|
if query:
|
||||||
|
clauses_seller.append("(wi.seller_part_number ILIKE %s OR wi.seller_part_name ILIKE %s)")
|
||||||
|
params_seller.extend([like, like])
|
||||||
|
|
||||||
|
where_seller = " AND ".join(clauses_seller)
|
||||||
|
|
||||||
|
# Combined query with UNION ALL
|
||||||
|
sql = f"""
|
||||||
|
SELECT * FROM (
|
||||||
|
-- OEM-matched parts
|
||||||
SELECT
|
SELECT
|
||||||
p.id_part,
|
p.id_part AS id,
|
||||||
p.oem_part_number,
|
p.oem_part_number AS part_number,
|
||||||
COALESCE(p.name_es, p.name_part) AS name,
|
COALESCE(p.name_es, p.name_part) AS name,
|
||||||
p.image_url,
|
p.image_url,
|
||||||
COUNT(DISTINCT b.id_bodega) AS bodega_count,
|
COUNT(DISTINCT b.id_bodega) AS bodega_count,
|
||||||
MIN(wi.price) AS min_price,
|
MIN(wi.price) AS min_price,
|
||||||
MAX(wi.price) AS max_price,
|
MAX(wi.price) AS max_price,
|
||||||
SUM(wi.stock_quantity) AS total_stock,
|
SUM(wi.stock_quantity) AS total_stock,
|
||||||
-- List of bodega names that have this part in stock
|
ARRAY_AGG(DISTINCT b.name ORDER BY b.name) AS bodega_names,
|
||||||
ARRAY_AGG(DISTINCT b.name ORDER BY b.name) AS bodega_names
|
'oem' AS listing_type
|
||||||
FROM warehouse_inventory wi
|
FROM warehouse_inventory wi
|
||||||
JOIN bodegas b ON b.id_bodega = wi.bodega_id
|
JOIN bodegas b ON b.id_bodega = wi.bodega_id
|
||||||
JOIN parts p ON p.id_part = wi.part_id
|
JOIN parts p ON p.id_part = wi.part_id
|
||||||
WHERE {where_sql}
|
WHERE {where_oem} {city_clause}
|
||||||
GROUP BY p.id_part, p.oem_part_number, p.name_es, p.name_part, p.image_url
|
GROUP BY p.id_part, p.oem_part_number, p.name_es, p.name_part, p.image_url
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
-- Seller listings
|
||||||
|
SELECT
|
||||||
|
wi.id_inventory AS id,
|
||||||
|
wi.seller_part_number AS part_number,
|
||||||
|
wi.seller_part_name AS name,
|
||||||
|
NULL AS image_url,
|
||||||
|
COUNT(DISTINCT b.id_bodega) AS bodega_count,
|
||||||
|
MIN(wi.price) AS min_price,
|
||||||
|
MAX(wi.price) AS max_price,
|
||||||
|
SUM(wi.stock_quantity) AS total_stock,
|
||||||
|
ARRAY_AGG(DISTINCT b.name ORDER BY b.name) AS bodega_names,
|
||||||
|
'seller' AS listing_type
|
||||||
|
FROM warehouse_inventory wi
|
||||||
|
JOIN bodegas b ON b.id_bodega = wi.bodega_id
|
||||||
|
WHERE {where_seller} {city_clause}
|
||||||
|
GROUP BY wi.id_inventory, wi.seller_part_number, wi.seller_part_name
|
||||||
|
) combined
|
||||||
ORDER BY total_stock DESC
|
ORDER BY total_stock DESC
|
||||||
LIMIT %s
|
LIMIT %s
|
||||||
""", params + [limit])
|
"""
|
||||||
|
|
||||||
|
all_params = params_oem + params_common + params_seller + params_common + [limit]
|
||||||
|
cur.execute(sql, all_params)
|
||||||
|
|
||||||
rows = cur.fetchall()
|
rows = cur.fetchall()
|
||||||
cur.close()
|
cur.close()
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
'id_part': r[0],
|
'id': r[0],
|
||||||
'oem_part_number': r[1],
|
'part_number': r[1],
|
||||||
'name': r[2],
|
'name': r[2],
|
||||||
'image_url': r[3],
|
'image_url': r[3],
|
||||||
'bodega_count': r[4],
|
'bodega_count': r[4],
|
||||||
'min_price': float(r[5]) if r[5] is not None else None,
|
'min_price': float(r[5]) if r[5] is not None else None,
|
||||||
'max_price': float(r[6]) if r[6] is not None else None,
|
'max_price': float(r[6]) if r[6] is not None else None,
|
||||||
'total_stock_hint': 'En stock' if (r[7] or 0) > 0 else 'Consultar',
|
'total_stock_hint': 'En stock' if (r[7] or 0) > 0 else 'Consultar',
|
||||||
'bodega_names': r[8], # may expose; adjust if sensitive
|
'bodega_names': r[8],
|
||||||
|
'listing_type': r[9],
|
||||||
}
|
}
|
||||||
for r in rows
|
for r in rows
|
||||||
]
|
]
|
||||||
@@ -358,6 +438,33 @@ def get_bodegas_with_part(master_conn, part_id: int) -> list[dict]:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_bodegas_with_listing(master_conn, wi_id: int) -> list[dict]:
|
||||||
|
"""Return the list of verified bodegas that have a specific seller listing
|
||||||
|
(warehouse_inventory row with part_id IS NULL) in stock.
|
||||||
|
"""
|
||||||
|
cur = master_conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
SELECT b.id_bodega, b.name, b.city, b.whatsapp_phone,
|
||||||
|
wi.price, wi.stock_quantity, wi.min_order_quantity, wi.currency
|
||||||
|
FROM warehouse_inventory wi
|
||||||
|
JOIN bodegas b ON b.id_bodega = wi.bodega_id
|
||||||
|
WHERE wi.id_inventory = %s AND wi.stock_quantity > 0 AND b.verified = TRUE
|
||||||
|
ORDER BY wi.price ASC
|
||||||
|
""", (wi_id,))
|
||||||
|
rows = cur.fetchall()
|
||||||
|
cur.close()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
'id_bodega': r[0], 'name': r[1], 'city': r[2], 'whatsapp_phone': r[3],
|
||||||
|
'price': float(r[4]) if r[4] is not None else None,
|
||||||
|
'stock_hint': 'En stock',
|
||||||
|
'min_order': r[6] or 1,
|
||||||
|
'currency': r[7] or 'MXN',
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
# PURCHASE ORDERS
|
# PURCHASE ORDERS
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
@@ -397,12 +504,15 @@ def create_po_draft(master_conn, *, buyer_tenant_id: int, buyer_user_id: int,
|
|||||||
# Insert items
|
# Insert items
|
||||||
total = 0.0
|
total = 0.0
|
||||||
for item in items:
|
for item in items:
|
||||||
part_id = int(item['part_id'])
|
part_id = item.get('part_id')
|
||||||
|
wi_id = item.get('wi_id')
|
||||||
quantity = int(item['quantity'])
|
quantity = int(item['quantity'])
|
||||||
if quantity < 1:
|
if quantity < 1:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Lookup part info + price
|
if part_id:
|
||||||
|
# OEM-matched part
|
||||||
|
part_id = int(part_id)
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT p.oem_part_number, COALESCE(p.name_es, p.name_part), wi.price
|
SELECT p.oem_part_number, COALESCE(p.name_es, p.name_part), wi.price
|
||||||
FROM parts p
|
FROM parts p
|
||||||
@@ -420,10 +530,35 @@ def create_po_draft(master_conn, *, buyer_tenant_id: int, buyer_user_id: int,
|
|||||||
|
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
INSERT INTO purchase_order_items
|
INSERT INTO purchase_order_items
|
||||||
(po_id, part_id, oem_part_number, part_name, quantity, unit_price, subtotal, notes)
|
(po_id, part_id, oem_part_number, part_name, quantity, unit_price, subtotal, notes, is_seller_listing)
|
||||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, FALSE)
|
||||||
""", (po_id, part_id, oem, name, quantity, unit_price, subtotal, item.get('notes')))
|
""", (po_id, part_id, oem, name, quantity, unit_price, subtotal, item.get('notes')))
|
||||||
|
|
||||||
|
elif wi_id:
|
||||||
|
# Seller listing (no catalog match)
|
||||||
|
wi_id = int(wi_id)
|
||||||
|
cur.execute("""
|
||||||
|
SELECT seller_part_number, seller_part_name, price
|
||||||
|
FROM warehouse_inventory
|
||||||
|
WHERE id_inventory = %s AND bodega_id = %s LIMIT 1
|
||||||
|
""", (wi_id, bodega_id))
|
||||||
|
r = cur.fetchone()
|
||||||
|
if not r:
|
||||||
|
continue
|
||||||
|
seller_pn, seller_name, db_price = r
|
||||||
|
unit_price = float(item.get('unit_price') or db_price or 0)
|
||||||
|
subtotal = round(unit_price * quantity, 2)
|
||||||
|
total += subtotal
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO purchase_order_items
|
||||||
|
(po_id, part_id, oem_part_number, part_name, quantity, unit_price, subtotal, notes, is_seller_listing)
|
||||||
|
VALUES (%s, NULL, %s, %s, %s, %s, %s, %s, TRUE)
|
||||||
|
""", (po_id, seller_pn, seller_name or seller_pn, quantity, unit_price, subtotal, item.get('notes')))
|
||||||
|
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
|
||||||
# Update header total
|
# Update header total
|
||||||
cur.execute("UPDATE purchase_orders SET total_amount = %s WHERE id_po = %s",
|
cur.execute("UPDATE purchase_orders SET total_amount = %s WHERE id_po = %s",
|
||||||
(round(total, 2), po_id))
|
(round(total, 2), po_id))
|
||||||
|
|||||||
327
pos/services/meli_service.py
Normal file
327
pos/services/meli_service.py
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
"""MercadoLibre API client with OAuth2 auto-refresh.
|
||||||
|
|
||||||
|
Endpoints used:
|
||||||
|
- GET /users/me
|
||||||
|
- POST /items
|
||||||
|
- PUT /items/{id}
|
||||||
|
- GET /items/{id}
|
||||||
|
- GET /orders/search
|
||||||
|
- GET /orders/{id}
|
||||||
|
- POST /shipments/{id}/dispatch
|
||||||
|
- POST /oauth/token
|
||||||
|
|
||||||
|
References:
|
||||||
|
https://developers.mercadolibre.com.ar/es_ar/api-docs-es
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
import requests
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
BASE_URL = "https://api.mercadolibre.com"
|
||||||
|
AUTH_URL = "https://api.mercadolibre.com/oauth/token"
|
||||||
|
|
||||||
|
|
||||||
|
class MeliError(Exception):
|
||||||
|
def __init__(self, message, status_code=None, response_body=None):
|
||||||
|
super().__init__(message)
|
||||||
|
self.status_code = status_code
|
||||||
|
self.response_body = response_body
|
||||||
|
|
||||||
|
|
||||||
|
class MeliAuthError(MeliError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class MeliService:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
access_token: str,
|
||||||
|
refresh_token: Optional[str] = None,
|
||||||
|
client_id: Optional[str] = None,
|
||||||
|
client_secret: Optional[str] = None,
|
||||||
|
):
|
||||||
|
self.access_token = access_token
|
||||||
|
self.refresh_token = refresh_token
|
||||||
|
self.client_id = client_id
|
||||||
|
self.client_secret = client_secret
|
||||||
|
self._session = requests.Session()
|
||||||
|
self._session.headers.update({"Authorization": f"Bearer {access_token}"})
|
||||||
|
|
||||||
|
# ─── Low-level request ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _request(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
params: Optional[dict] = None,
|
||||||
|
json_payload: Optional[dict] = None,
|
||||||
|
retry_on_401: bool = True,
|
||||||
|
) -> dict:
|
||||||
|
url = f"{BASE_URL}{path}"
|
||||||
|
resp = self._session.request(
|
||||||
|
method, url, params=params, json=json_payload, timeout=30
|
||||||
|
)
|
||||||
|
|
||||||
|
if resp.status_code == 401 and retry_on_401 and self.refresh_token:
|
||||||
|
self._refresh_token()
|
||||||
|
# Retry once with new token
|
||||||
|
self._session.headers.update(
|
||||||
|
{"Authorization": f"Bearer {self.access_token}"}
|
||||||
|
)
|
||||||
|
resp = self._session.request(
|
||||||
|
method, url, params=params, json=json_payload, timeout=30
|
||||||
|
)
|
||||||
|
|
||||||
|
if resp.status_code == 401:
|
||||||
|
raise MeliAuthError(
|
||||||
|
"Unauthorized. Token may be expired or invalid.",
|
||||||
|
status_code=401,
|
||||||
|
response_body=resp.text,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not resp.ok:
|
||||||
|
raise MeliError(
|
||||||
|
f"Meli API error {resp.status_code}: {resp.text}",
|
||||||
|
status_code=resp.status_code,
|
||||||
|
response_body=resp.text,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Some endpoints return 204 No Content
|
||||||
|
if resp.status_code == 204:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return resp.json()
|
||||||
|
except Exception:
|
||||||
|
return {"raw": resp.text}
|
||||||
|
|
||||||
|
def _refresh_token(self) -> dict:
|
||||||
|
if not self.client_id or not self.client_secret or not self.refresh_token:
|
||||||
|
raise MeliAuthError("Missing credentials for token refresh")
|
||||||
|
payload = {
|
||||||
|
"grant_type": "refresh_token",
|
||||||
|
"client_id": self.client_id,
|
||||||
|
"client_secret": self.client_secret,
|
||||||
|
"refresh_token": self.refresh_token,
|
||||||
|
}
|
||||||
|
resp = requests.post(AUTH_URL, data=payload, timeout=30)
|
||||||
|
if not resp.ok:
|
||||||
|
raise MeliAuthError(
|
||||||
|
f"Token refresh failed: {resp.status_code} {resp.text}",
|
||||||
|
status_code=resp.status_code,
|
||||||
|
response_body=resp.text,
|
||||||
|
)
|
||||||
|
data = resp.json()
|
||||||
|
self.access_token = data["access_token"]
|
||||||
|
if "refresh_token" in data:
|
||||||
|
self.refresh_token = data["refresh_token"]
|
||||||
|
return data
|
||||||
|
|
||||||
|
# ─── Auth / User ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_user(self) -> dict:
|
||||||
|
return self._request("GET", "/users/me")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def exchange_code(
|
||||||
|
code: str, client_id: str, client_secret: str, redirect_uri: str
|
||||||
|
) -> dict:
|
||||||
|
"""Exchange authorization code for tokens."""
|
||||||
|
payload = {
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"client_id": client_id,
|
||||||
|
"client_secret": client_secret,
|
||||||
|
"code": code,
|
||||||
|
"redirect_uri": redirect_uri,
|
||||||
|
}
|
||||||
|
resp = requests.post(AUTH_URL, data=payload, timeout=30)
|
||||||
|
if not resp.ok:
|
||||||
|
raise MeliAuthError(
|
||||||
|
f"Code exchange failed: {resp.status_code} {resp.text}",
|
||||||
|
status_code=resp.status_code,
|
||||||
|
response_body=resp.text,
|
||||||
|
)
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
# ─── Images ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def upload_image(self, image_path_or_url: str) -> dict:
|
||||||
|
"""Upload an image to MercadoLibre's image hosting.
|
||||||
|
|
||||||
|
Accepts either a local file path or a URL.
|
||||||
|
Returns the ML picture dict with 'id' and 'secure_url' / 'url' keys.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import requests as raw_requests
|
||||||
|
|
||||||
|
# If it's a URL, download it first
|
||||||
|
if image_path_or_url.startswith("http://") or image_path_or_url.startswith("https://"):
|
||||||
|
img_resp = raw_requests.get(image_path_or_url, timeout=30)
|
||||||
|
if not img_resp.ok:
|
||||||
|
raise MeliError(f"Failed to download image from {image_path_or_url}: {img_resp.status_code}")
|
||||||
|
file_bytes = img_resp.content
|
||||||
|
content_type = img_resp.headers.get("Content-Type", "image/jpeg")
|
||||||
|
filename = "image.jpg"
|
||||||
|
else:
|
||||||
|
if not os.path.exists(image_path_or_url):
|
||||||
|
raise MeliError(f"Image file not found: {image_path_or_url}")
|
||||||
|
with open(image_path_or_url, "rb") as f:
|
||||||
|
file_bytes = f.read()
|
||||||
|
content_type = "image/jpeg"
|
||||||
|
filename = os.path.basename(image_path_or_url)
|
||||||
|
|
||||||
|
upload_url = f"{BASE_URL}/pictures"
|
||||||
|
files = {"file": (filename, file_bytes, content_type)}
|
||||||
|
req_headers = {"Authorization": f"Bearer {self.access_token}"}
|
||||||
|
resp = raw_requests.post(upload_url, files=files, headers=req_headers, timeout=60)
|
||||||
|
|
||||||
|
if resp.status_code == 401 and self.refresh_token:
|
||||||
|
self._refresh_token()
|
||||||
|
req_headers["Authorization"] = f"Bearer {self.access_token}"
|
||||||
|
resp = raw_requests.post(upload_url, files=files, headers=req_headers, timeout=60)
|
||||||
|
|
||||||
|
if resp.status_code == 401:
|
||||||
|
raise MeliAuthError("Unauthorized. Token may be expired.", status_code=401, response_body=resp.text)
|
||||||
|
if not resp.ok:
|
||||||
|
raise MeliError(f"Image upload failed: {resp.status_code} {resp.text}", status_code=resp.status_code, response_body=resp.text)
|
||||||
|
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
def get_listing_price(self, site_id: str, price: float, listing_type_id: str, category_id: str) -> dict:
|
||||||
|
"""Get the exact fee / net amount for a given price, listing type and category.
|
||||||
|
|
||||||
|
ML endpoint: GET /sites/{site_id}/listing_prices
|
||||||
|
Returns dict with sale_fee_amount, net_amount, etc.
|
||||||
|
"""
|
||||||
|
return self._request(
|
||||||
|
"GET",
|
||||||
|
f"/sites/{site_id}/listing_prices",
|
||||||
|
params={
|
||||||
|
"price": str(price),
|
||||||
|
"listing_type_id": listing_type_id,
|
||||||
|
"category_id": category_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# ─── Items (listings) ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def validate_item(self, payload: dict) -> dict:
|
||||||
|
"""Validate an item payload without creating it."""
|
||||||
|
return self._request("POST", "/items/validate", json_payload=payload)
|
||||||
|
|
||||||
|
def create_item(self, payload: dict) -> dict:
|
||||||
|
return self._request("POST", "/items", json_payload=payload)
|
||||||
|
|
||||||
|
def update_item(self, item_id: str, payload: dict) -> dict:
|
||||||
|
return self._request("PUT", f"/items/{item_id}", json_payload=payload)
|
||||||
|
|
||||||
|
def get_item(self, item_id: str) -> dict:
|
||||||
|
return self._request("GET", f"/items/{item_id}")
|
||||||
|
|
||||||
|
def get_user_items(self, user_id: str, status: str = None, limit: int = 50, offset: int = 0) -> dict:
|
||||||
|
"""Get all items published by a seller.
|
||||||
|
|
||||||
|
ML endpoint: GET /users/{user_id}/items/search
|
||||||
|
"""
|
||||||
|
params = {"limit": limit, "offset": offset}
|
||||||
|
if status:
|
||||||
|
params["status"] = status
|
||||||
|
return self._request("GET", f"/users/{user_id}/items/search", params=params)
|
||||||
|
|
||||||
|
def pause_item(self, item_id: str) -> dict:
|
||||||
|
return self.update_item(item_id, {"status": "paused"})
|
||||||
|
|
||||||
|
def activate_item(self, item_id: str) -> dict:
|
||||||
|
return self.update_item(item_id, {"status": "active"})
|
||||||
|
|
||||||
|
def close_item(self, item_id: str) -> dict:
|
||||||
|
return self.update_item(item_id, {"status": "closed"})
|
||||||
|
|
||||||
|
# ─── Questions & Answers ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_questions(self, item_id: str, status: str = None, offset: int = 0, limit: int = 50) -> dict:
|
||||||
|
params = {"item_id": item_id, "offset": offset, "limit": limit}
|
||||||
|
if status:
|
||||||
|
params["status"] = status
|
||||||
|
return self._request("GET", "/questions/search", params=params)
|
||||||
|
|
||||||
|
def get_question(self, question_id: str) -> dict:
|
||||||
|
return self._request("GET", f"/questions/{question_id}")
|
||||||
|
|
||||||
|
def answer_question(self, question_id: str, text: str) -> dict:
|
||||||
|
return self._request("POST", "/answers", json_payload={"question_id": question_id, "text": text})
|
||||||
|
|
||||||
|
def delete_question(self, question_id: str) -> dict:
|
||||||
|
return self._request("DELETE", f"/questions/{question_id}")
|
||||||
|
|
||||||
|
# ─── Categories ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_category(self, category_id: str) -> dict:
|
||||||
|
return self._request("GET", f"/categories/{category_id}")
|
||||||
|
|
||||||
|
def search_categories(self, site_id: str, query: str) -> dict:
|
||||||
|
# ML does not have a direct category search; we use the predictor
|
||||||
|
return self._request(
|
||||||
|
"GET",
|
||||||
|
f"/sites/{site_id}/domain_discovery/search",
|
||||||
|
params={"q": query},
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_category_attributes(self, category_id: str) -> list:
|
||||||
|
return self._request("GET", f"/categories/{category_id}/attributes")
|
||||||
|
|
||||||
|
def get_shipping_preferences(self, user_id: str) -> dict:
|
||||||
|
return self._request("GET", f"/users/{user_id}/shipping_preferences")
|
||||||
|
|
||||||
|
# ─── Orders ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_orders(
|
||||||
|
self,
|
||||||
|
seller_id: str,
|
||||||
|
status: Optional[str] = None,
|
||||||
|
date_from: Optional[str] = None,
|
||||||
|
limit: int = 50,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> dict:
|
||||||
|
params = {"seller": seller_id, "limit": limit, "offset": offset}
|
||||||
|
if status:
|
||||||
|
params["order.status"] = status
|
||||||
|
if date_from:
|
||||||
|
params["order.date_created.from"] = date_from
|
||||||
|
return self._request("GET", "/orders/search", params=params)
|
||||||
|
|
||||||
|
def get_order(self, order_id: str) -> dict:
|
||||||
|
return self._request("GET", f"/orders/{order_id}")
|
||||||
|
|
||||||
|
# ─── Shipments ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_shipment(self, shipment_id: str) -> dict:
|
||||||
|
return self._request("GET", f"/shipments/{shipment_id}")
|
||||||
|
|
||||||
|
def mark_ready_to_ship(self, shipment_id: str) -> dict:
|
||||||
|
return self._request(
|
||||||
|
"POST",
|
||||||
|
f"/shipments/{shipment_id}/dispatch",
|
||||||
|
json_payload={},
|
||||||
|
)
|
||||||
|
|
||||||
|
# ─── Notifications / Webhooks validation ─────────────────────────────
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def validate_webhook_signature(
|
||||||
|
secret: str, data: bytes, signature_header: str
|
||||||
|
) -> bool:
|
||||||
|
"""Validate MercadoLibre webhook signature.
|
||||||
|
|
||||||
|
ML sends: X-Signature: sha256=<hex_hmac>
|
||||||
|
"""
|
||||||
|
import hmac
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
if not signature_header or "=" not in signature_header:
|
||||||
|
return False
|
||||||
|
_, expected_hex = signature_header.split("=", 1)
|
||||||
|
computed = hmac.new(
|
||||||
|
secret.encode(), data, hashlib.sha256
|
||||||
|
).hexdigest()
|
||||||
|
return hmac.compare_digest(computed, expected_hex)
|
||||||
186
pos/services/part_kits.py
Normal file
186
pos/services/part_kits.py
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
"""
|
||||||
|
Smart part kits — automatic cross-sell recommendations.
|
||||||
|
|
||||||
|
When a customer adds a part to their quotation, suggest related
|
||||||
|
parts that are typically needed together for a complete job.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Spanish keywords in part name → related parts to suggest (in Spanish)
|
||||||
|
# These appear after a successful "cotizar" command.
|
||||||
|
KIT_SUGGESTIONS = {
|
||||||
|
"balata": ["disco de freno", "líquido de frenos", "balero de rueda"],
|
||||||
|
"disco de freno": ["balata", "líquido de frenos"],
|
||||||
|
"alternador": ["banda serpentina", "batería", "regulador de alternador"],
|
||||||
|
"batería": ["alternador", "cable de bujía"],
|
||||||
|
"marcha": ["batería", "solenoide de marcha"],
|
||||||
|
"bujía": ["bobina de encendido", "filtro de aire", "filtro de gasolina"],
|
||||||
|
"bobina": ["bujía", "cable de bujía"],
|
||||||
|
"bomba de agua": ["termostato", "refrigerante", "manguera de radiador"],
|
||||||
|
"radiador": ["manguera de radiador", "termostato", "tapón de radiador"],
|
||||||
|
"termostato": ["refrigerante", "manguera de radiador"],
|
||||||
|
"amortiguador": ["base de amortiguador", "goma de suspensión", "rótula"],
|
||||||
|
"rótula": ["terminal de dirección", "brazo de suspensión", "bujes"],
|
||||||
|
"terminal": ["rótula", "brazo de suspensión"],
|
||||||
|
"filtro de aceite": ["filtro de aire", "filtro de gasolina", "filtro de habitáculo"],
|
||||||
|
"filtro de aire": ["filtro de aceite", "filtro de gasolina", "bujía"],
|
||||||
|
"filtro de gasolina": ["filtro de aire", "filtro de aceite", "inyector"],
|
||||||
|
"clutch": ["collarín", "disco de clutch", "plato de presión"],
|
||||||
|
"collarín": ["clutch", "disco de clutch"],
|
||||||
|
"banda de distribución": ["bomba de agua", "tensor", "polea loca"],
|
||||||
|
"banda serpentina": ["tensor de banda", "polea loca"],
|
||||||
|
"foco": ["foco trasero", "cuarto"],
|
||||||
|
"faro": ["foco trasero", "cuarto"],
|
||||||
|
"aceite": ["filtro de aceite", "filtro de aire"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_kit_suggestions(part_name: str) -> list:
|
||||||
|
"""Return related part names for a given part (Spanish)."""
|
||||||
|
if not part_name:
|
||||||
|
return []
|
||||||
|
name_lower = part_name.lower()
|
||||||
|
for keyword, related in KIT_SUGGESTIONS.items():
|
||||||
|
if keyword in name_lower:
|
||||||
|
return related
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def build_kit_text(part_name: str) -> str:
|
||||||
|
"""Build a WhatsApp-friendly kit suggestion text.
|
||||||
|
|
||||||
|
Returns empty string if no kit is found.
|
||||||
|
"""
|
||||||
|
suggestions = get_kit_suggestions(part_name)
|
||||||
|
if not suggestions:
|
||||||
|
return ""
|
||||||
|
items = "\n".join(f" • {s.title()}" for s in suggestions[:3])
|
||||||
|
return (
|
||||||
|
"\n\n🔧 *¿Ya que estás en eso, checa si también necesitas:*\n"
|
||||||
|
+ items
|
||||||
|
+ '\n\n_Escribe la parte que te interese y la agregamos._'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Urgency detection ────────────────────────────────────────────────
|
||||||
|
URGENCY_KEYWORDS = [
|
||||||
|
"urgente", "urgencia", "emergencia", "ya", "ahora", "hoy",
|
||||||
|
"lo necesito", "se me paro", "no arranca", "no jala",
|
||||||
|
"rapido", "apúrate", "apurate", "prisa", "de volada",
|
||||||
|
"para hoy", "para ahora", "lo mas pronto", "lo más pronto",
|
||||||
|
"inmediato", "express", "exprés",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def is_urgent(text: str) -> bool:
|
||||||
|
"""Detect if the customer message signals urgency."""
|
||||||
|
if not text:
|
||||||
|
return False
|
||||||
|
t = text.lower()
|
||||||
|
return any(kw in t for kw in URGENCY_KEYWORDS)
|
||||||
|
|
||||||
|
|
||||||
|
def urgency_note() -> str:
|
||||||
|
return (
|
||||||
|
"\n\n⚡ NOTA DE URGENCIA: El cliente necesita la pieza lo antes posible. "
|
||||||
|
"Prioriza stock local y ofrece entrega express (2-4 horas) o recolección inmediata en tienda. "
|
||||||
|
"Si no hay stock exacto, ofrece alternativa disponible inmediatamente."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Abandoned quotation follow-up ────────────────────────────────────
|
||||||
|
FOLLOW_UP_MINUTES = 15
|
||||||
|
|
||||||
|
|
||||||
|
def should_send_followup(phone: str, tenant_conn) -> str:
|
||||||
|
"""Check if we should send a follow-up message for an abandoned quotation.
|
||||||
|
|
||||||
|
Returns the follow-up text if yes, empty string if no.
|
||||||
|
"""
|
||||||
|
if not tenant_conn or not phone:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
cur = tenant_conn.cursor()
|
||||||
|
# 1. Check if there's an active quotation for this phone
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id FROM quotations
|
||||||
|
WHERE notes LIKE %s AND status = 'active'
|
||||||
|
ORDER BY created_at DESC LIMIT 1
|
||||||
|
""", (f'%WA:{phone}%',))
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
cur.close()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# 2. Check last bot message mentioning "cotización" or "cotizar"
|
||||||
|
cur.execute("""
|
||||||
|
SELECT created_at, message_text
|
||||||
|
FROM whatsapp_messages
|
||||||
|
WHERE phone = %s AND direction = 'outgoing'
|
||||||
|
AND (message_text ILIKE '%cotización%' OR message_text ILIKE '%cotizar%')
|
||||||
|
ORDER BY created_at DESC LIMIT 1
|
||||||
|
""", (phone,))
|
||||||
|
last_quote_msg = cur.fetchone()
|
||||||
|
cur.close()
|
||||||
|
|
||||||
|
if not last_quote_msg:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
last_time = last_quote_msg[0]
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
if last_time.tzinfo is None:
|
||||||
|
last_time = last_time.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
minutes_since = (now - last_time).total_seconds() / 60
|
||||||
|
if minutes_since >= FOLLOW_UP_MINUTES:
|
||||||
|
return (
|
||||||
|
"👋 *¿Todo bien?*\n\n"
|
||||||
|
"Veo que estabas armando tu cotización. ¿Te falta algo más o quieres que te la envíe ahora?\n\n"
|
||||||
|
"_Escribe *enviar cotización* para ver el total, o dime si necesitas otra parte._"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[WA-AI] Follow-up check failed: {e}")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
# ── Customer purchase history awareness ──────────────────────────────
|
||||||
|
def get_purchase_history(phone: str, tenant_conn, limit: int = 3) -> str:
|
||||||
|
"""Build a short text summary of recent confirmed quotations for this customer.
|
||||||
|
|
||||||
|
Returns empty string if no history.
|
||||||
|
"""
|
||||||
|
if not tenant_conn or not phone:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
cur = tenant_conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
SELECT q.id, q.created_at, q.total,
|
||||||
|
ARRAY_AGG(qi.name ORDER BY qi.name) AS items
|
||||||
|
FROM quotations q
|
||||||
|
JOIN quotation_items qi ON qi.quotation_id = q.id
|
||||||
|
WHERE q.notes LIKE %s AND q.status = 'converted'
|
||||||
|
GROUP BY q.id, q.created_at, q.total
|
||||||
|
ORDER BY q.created_at DESC
|
||||||
|
LIMIT %s
|
||||||
|
""", (f'%WA:{phone}%', limit))
|
||||||
|
rows = cur.fetchall()
|
||||||
|
cur.close()
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
parts = []
|
||||||
|
for qid, created, total, items in rows:
|
||||||
|
if created.tzinfo is None:
|
||||||
|
created = created.replace(tzinfo=timezone.utc)
|
||||||
|
months_ago = (now - created).days // 30
|
||||||
|
time_str = f"hace {months_ago} meses" if months_ago > 0 else "recientemente"
|
||||||
|
item_list = ", ".join(items[:3])
|
||||||
|
parts.append(f"- {time_str}: {item_list} (total ${float(total):,.2f})")
|
||||||
|
|
||||||
|
return "HISTORIAL DE COMPRAS DEL CLIENTE:\n" + "\n".join(parts)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[WA-AI] Purchase history failed: {e}")
|
||||||
|
return ""
|
||||||
@@ -193,9 +193,23 @@ def process_sale(conn, sale_data):
|
|||||||
amount_paid = float(sale_data.get('amount_paid', 0))
|
amount_paid = float(sale_data.get('amount_paid', 0))
|
||||||
payment_details = sale_data.get('payment_details', [])
|
payment_details = sale_data.get('payment_details', [])
|
||||||
notes = sale_data.get('notes')
|
notes = sale_data.get('notes')
|
||||||
branch_id = _safe_g('branch_id')
|
branch_id = sale_data.get('branch_id') or _safe_g('branch_id')
|
||||||
employee_id = _safe_g('employee_id')
|
employee_id = _safe_g('employee_id')
|
||||||
|
|
||||||
|
# Fallback to the main branch if none resolved (e.g. token without branch_id)
|
||||||
|
if not branch_id:
|
||||||
|
cur.execute("SELECT id FROM branches WHERE is_main = true AND is_active = true LIMIT 1")
|
||||||
|
row = cur.fetchone()
|
||||||
|
if row:
|
||||||
|
branch_id = row[0]
|
||||||
|
else:
|
||||||
|
cur.execute("SELECT id FROM branches WHERE is_active = true ORDER BY id LIMIT 1")
|
||||||
|
row = cur.fetchone()
|
||||||
|
branch_id = row[0] if row else None
|
||||||
|
|
||||||
|
if not branch_id:
|
||||||
|
raise ValueError("No hay sucursal activa disponible para registrar la venta")
|
||||||
|
|
||||||
# ── Multi-currency support ───────────────────────────────────────────
|
# ── Multi-currency support ───────────────────────────────────────────
|
||||||
currency = sale_data.get('currency', 'MXN')
|
currency = sale_data.get('currency', 'MXN')
|
||||||
if currency not in ('MXN', 'USD'):
|
if currency not in ('MXN', 'USD'):
|
||||||
@@ -440,6 +454,42 @@ 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
|
||||||
|
|
||||||
|
# Dropshipping webhook hook (non-blocking)
|
||||||
|
try:
|
||||||
|
from services import dropshipping_service as ds_svc
|
||||||
|
from services.webhook_service import dispatch_webhooks_bulk
|
||||||
|
webhook_urls = ds_svc.get_webhook_targets(conn, 'sale_made')
|
||||||
|
if webhook_urls:
|
||||||
|
payload_items = []
|
||||||
|
for item in enriched_items:
|
||||||
|
remaining = item['stock_before'] - item['quantity']
|
||||||
|
payload_items.append({
|
||||||
|
'sku': item['part_number'],
|
||||||
|
'name': item['name'],
|
||||||
|
'quantity_sold': item['quantity'],
|
||||||
|
'stock_remaining': remaining,
|
||||||
|
'unit_price': item['unit_price'],
|
||||||
|
})
|
||||||
|
threading.Thread(
|
||||||
|
target=dispatch_webhooks_bulk,
|
||||||
|
args=(webhook_urls, 'sale_made', {
|
||||||
|
'sale_id': sale_id,
|
||||||
|
'items': payload_items,
|
||||||
|
'total': totals['total'],
|
||||||
|
'created_at': str(created_at),
|
||||||
|
}),
|
||||||
|
daemon=True
|
||||||
|
).start()
|
||||||
|
except Exception:
|
||||||
|
pass # Webhook errors never block sales
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'id': sale_id,
|
'id': sale_id,
|
||||||
'branch_id': branch_id,
|
'branch_id': branch_id,
|
||||||
|
|||||||
127
pos/services/quote_image.py
Normal file
127
pos/services/quote_image.py
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import io
|
||||||
|
import base64
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
def generate_quote_image(quote_items, totals, tenant_name="Autopartes", logo_text="NEXUS"):
|
||||||
|
"""
|
||||||
|
Generate a visually appealing quote image.
|
||||||
|
|
||||||
|
quote_items: list of dicts with keys: name, sku, qty, price, total
|
||||||
|
totals: dict with keys: subtotal, tax, total
|
||||||
|
Returns: base64 encoded PNG string
|
||||||
|
"""
|
||||||
|
# Dimensions
|
||||||
|
WIDTH = 800
|
||||||
|
HEADER_H = 120
|
||||||
|
FOOTER_H = 100
|
||||||
|
ITEM_H = 60
|
||||||
|
PADDING = 30
|
||||||
|
|
||||||
|
total_height = HEADER_H + len(quote_items) * ITEM_H + FOOTER_H + PADDING * 3
|
||||||
|
|
||||||
|
# Colors
|
||||||
|
BG_COLOR = (250, 250, 252)
|
||||||
|
PRIMARY = (0, 82, 155) # Dark blue
|
||||||
|
ACCENT = (230, 57, 70) # Red accent
|
||||||
|
TEXT_DARK = (30, 30, 30)
|
||||||
|
TEXT_MED = (80, 80, 80)
|
||||||
|
TEXT_LIGHT = (150, 150, 150)
|
||||||
|
WHITE = (255, 255, 255)
|
||||||
|
ROW_ALT = (245, 247, 250)
|
||||||
|
|
||||||
|
img = Image.new('RGB', (WIDTH, total_height), BG_COLOR)
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
|
# Try to load fonts, fallback to default
|
||||||
|
try:
|
||||||
|
font_title = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 32)
|
||||||
|
font_sub = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 18)
|
||||||
|
font_item = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 20)
|
||||||
|
font_bold = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 22)
|
||||||
|
font_small = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 14)
|
||||||
|
except Exception:
|
||||||
|
font_title = ImageFont.load_default()
|
||||||
|
font_sub = font_title
|
||||||
|
font_item = font_title
|
||||||
|
font_bold = font_title
|
||||||
|
font_small = font_title
|
||||||
|
|
||||||
|
# --- Header ---
|
||||||
|
draw.rectangle([0, 0, WIDTH, HEADER_H], fill=PRIMARY)
|
||||||
|
|
||||||
|
# Logo text
|
||||||
|
draw.text((PADDING, 25), logo_text, font=font_title, fill=WHITE)
|
||||||
|
draw.text((PADDING, 70), tenant_name, font=font_sub, fill=(200, 210, 230))
|
||||||
|
|
||||||
|
# Date and Quote label
|
||||||
|
from datetime import datetime
|
||||||
|
date_str = datetime.now().strftime("%d/%m/%Y %H:%M")
|
||||||
|
draw.text((WIDTH - PADDING - 200, 30), "COTIZACIÓN", font=font_title, fill=WHITE)
|
||||||
|
draw.text((WIDTH - PADDING - 200, 75), date_str, font=font_sub, fill=(200, 210, 230))
|
||||||
|
|
||||||
|
# --- Items Header ---
|
||||||
|
y = HEADER_H + PADDING
|
||||||
|
draw.rectangle([PADDING, y, WIDTH - PADDING, y + ITEM_H], fill=(230, 235, 240))
|
||||||
|
draw.text((PADDING + 10, y + 18), "PRODUCTO", font=font_bold, fill=TEXT_DARK)
|
||||||
|
draw.text((WIDTH - PADDING - 220, y + 18), "CANT.", font=font_bold, fill=TEXT_DARK)
|
||||||
|
draw.text((WIDTH - PADDING - 130, y + 18), "P.UNIT", font=font_bold, fill=TEXT_DARK)
|
||||||
|
draw.text((WIDTH - PADDING - 50, y + 18), "TOTAL", font=font_bold, fill=TEXT_DARK)
|
||||||
|
y += ITEM_H
|
||||||
|
|
||||||
|
# --- Items ---
|
||||||
|
for idx, item in enumerate(quote_items):
|
||||||
|
row_y = y + idx * ITEM_H
|
||||||
|
bg = ROW_ALT if idx % 2 == 0 else WHITE
|
||||||
|
draw.rectangle([PADDING, row_y, WIDTH - PADDING, row_y + ITEM_H], fill=bg)
|
||||||
|
|
||||||
|
name = item.get('name', 'Producto')
|
||||||
|
sku = item.get('sku', '')
|
||||||
|
qty = str(item.get('qty', 1))
|
||||||
|
price = f"${item.get('price', 0):,.2f}"
|
||||||
|
total = f"${item.get('total', 0):,.2f}"
|
||||||
|
|
||||||
|
# Truncate name if too long
|
||||||
|
name_display = name
|
||||||
|
if len(name_display) > 35:
|
||||||
|
name_display = name_display[:32] + "..."
|
||||||
|
|
||||||
|
draw.text((PADDING + 10, row_y + 8), name_display, font=font_item, fill=TEXT_DARK)
|
||||||
|
draw.text((PADDING + 10, row_y + 32), f"SKU: {sku}", font=font_small, fill=TEXT_MED)
|
||||||
|
|
||||||
|
draw.text((WIDTH - PADDING - 220, row_y + 18), qty, font=font_item, fill=TEXT_DARK)
|
||||||
|
draw.text((WIDTH - PADDING - 130, row_y + 18), price, font=font_item, fill=TEXT_DARK)
|
||||||
|
draw.text((WIDTH - PADDING - 50, row_y + 18), total, font=font_item, fill=TEXT_DARK)
|
||||||
|
|
||||||
|
y += len(quote_items) * ITEM_H + PADDING
|
||||||
|
|
||||||
|
# --- Totals ---
|
||||||
|
draw.line([(PADDING, y), (WIDTH - PADDING, y)], fill=(200, 200, 200), width=2)
|
||||||
|
y += 20
|
||||||
|
|
||||||
|
subtotal = totals.get('subtotal', 0)
|
||||||
|
tax = totals.get('tax', 0)
|
||||||
|
total = totals.get('total', 0)
|
||||||
|
|
||||||
|
draw.text((WIDTH - PADDING - 300, y), "Subtotal:", font=font_sub, fill=TEXT_MED)
|
||||||
|
draw.text((WIDTH - PADDING - 50, y), f"${subtotal:,.2f}", font=font_sub, fill=TEXT_DARK)
|
||||||
|
y += 30
|
||||||
|
|
||||||
|
draw.text((WIDTH - PADDING - 300, y), "IVA (16%):", font=font_sub, fill=TEXT_MED)
|
||||||
|
draw.text((WIDTH - PADDING - 50, y), f"${tax:,.2f}", font=font_sub, fill=TEXT_DARK)
|
||||||
|
y += 35
|
||||||
|
|
||||||
|
draw.text((WIDTH - PADDING - 300, y), "TOTAL:", font=font_bold, fill=ACCENT)
|
||||||
|
draw.text((WIDTH - PADDING - 50, y), f"${total:,.2f}", font=font_bold, fill=ACCENT)
|
||||||
|
y += 50
|
||||||
|
|
||||||
|
# --- Footer ---
|
||||||
|
draw.rectangle([0, total_height - FOOTER_H, WIDTH, total_height], fill=PRIMARY)
|
||||||
|
footer_text = "Validez: 5 días hábiles | Envíos a todo México | Contacto: ventas@nexusautoparts.com"
|
||||||
|
draw.text((PADDING, total_height - FOOTER_H + 35), footer_text, font=font_small, fill=(200, 210, 230))
|
||||||
|
|
||||||
|
# Convert to base64
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
img.save(buffer, format='PNG')
|
||||||
|
buffer.seek(0)
|
||||||
|
return base64.b64encode(buffer.read()).decode('utf-8')
|
||||||
|
|
||||||
@@ -42,16 +42,22 @@ def get_vehicle_fitment(part_number, name, brand):
|
|||||||
{'role': 'user', 'content': prompt}
|
{'role': 'user', 'content': prompt}
|
||||||
],
|
],
|
||||||
'temperature': 0.2,
|
'temperature': 0.2,
|
||||||
'max_tokens': 4096,
|
'max_tokens': 8192,
|
||||||
},
|
},
|
||||||
timeout=45,
|
timeout=120,
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
raw = response.json()
|
raw = response.json()
|
||||||
|
finish_reason = None
|
||||||
if raw.get('choices') and len(raw['choices']) > 0:
|
if raw.get('choices') and len(raw['choices']) > 0:
|
||||||
msg = raw['choices'][0].get('message', {})
|
choice = raw['choices'][0]
|
||||||
|
msg = choice.get('message', {})
|
||||||
|
finish_reason = choice.get('finish_reason')
|
||||||
if msg:
|
if msg:
|
||||||
content = msg.get('content') or ''
|
content = msg.get('content') or ''
|
||||||
|
if not content:
|
||||||
|
# Fallback for reasoning models that return output in reasoning_content
|
||||||
|
content = msg.get('reasoning_content') or ''
|
||||||
if content:
|
if content:
|
||||||
break
|
break
|
||||||
except requests.RequestException as exc:
|
except requests.RequestException as exc:
|
||||||
@@ -62,6 +68,8 @@ def get_vehicle_fitment(part_number, name, brand):
|
|||||||
|
|
||||||
if not content:
|
if not content:
|
||||||
err_msg = f'QWEN request failed: {last_error}' if last_error else 'Empty response from QWEN after 3 attempts'
|
err_msg = f'QWEN request failed: {last_error}' if last_error else 'Empty response from QWEN after 3 attempts'
|
||||||
|
if finish_reason == 'length':
|
||||||
|
err_msg += ' (response truncated by token limit — consider reducing prompt or increasing max_tokens)'
|
||||||
return {'vehicles': [], 'confidence': 0, 'notes': err_msg}
|
return {'vehicles': [], 'confidence': 0, 'notes': err_msg}
|
||||||
|
|
||||||
# Parse JSON from QWEN response (sometimes wrapped in markdown)
|
# Parse JSON from QWEN response (sometimes wrapped in markdown)
|
||||||
@@ -91,32 +99,21 @@ def _build_prompt(part_number, name, brand):
|
|||||||
- Nombre/descripcion: {name}
|
- Nombre/descripcion: {name}
|
||||||
- Marca del fabricante: {brand_str}
|
- Marca del fabricante: {brand_str}
|
||||||
|
|
||||||
Devuelve UNICAMENTE un JSON valido (sin markdown, sin backticks, sin texto adicional) con esta estructura exacta:
|
Devuelve UNICAMENTE un JSON valido (sin markdown, sin backticks, sin texto adicional) con esta estructura:
|
||||||
{{
|
{{"vehicles":[{{"make":"Toyota","model":"Corolla","year_range":"2014-2019","engine":"1.8L","engine_code":"2ZR-FE","notes":""}}],"confidence":0.92,"notes":""}}
|
||||||
"vehicles": [
|
|
||||||
{{
|
|
||||||
"make": "Toyota",
|
|
||||||
"model": "Corolla",
|
|
||||||
"year": 2015,
|
|
||||||
"engine": "1.8L 16V",
|
|
||||||
"engine_code": "2ZR-FE",
|
|
||||||
"notes": "Sedan y hatchback"
|
|
||||||
}}
|
|
||||||
],
|
|
||||||
"confidence": 0.92,
|
|
||||||
"notes": "Compatible con plataforma E170. Verificar traccion delantera."
|
|
||||||
}}
|
|
||||||
|
|
||||||
Reglas obligatorias:
|
REGLAS OBLIGATORIAS:
|
||||||
1. "make" = marca del vehiculo (ej: Toyota, Nissan, Ford, Volkswagen, Chevrolet, Honda, Hyundai, Kia, Mazda, Subaru).
|
1. "make" = marca del vehiculo.
|
||||||
2. "model" = modelo exacto. Si hay variantes (ej: Civic Sedan vs Civic Coupe), incluye la variante.
|
2. "model" = modelo exacto (incluye variante si aplica).
|
||||||
3. "year" = ano numerico (int). Si hay rango de anos (ej: 2003-2008), genera una entrada POR CADA ANO del rango. NO uses rangos.
|
3. USA "year_range" = string "YYYY-YYYY" cuando el MISMO modelo/motor abarca multiples anos consecutivos. Esto ahorra tokens y permite mas resultados.
|
||||||
4. "engine" = descripcion del motor (ej: "1.8L", "2.0L TDI", "V6 3.5L", "1.6L Turbo"). Si no conoces el motor, usa "desconocido".
|
4. USA "year" = int SOLO cuando sea un ano aislado sin rangos adyacentes.
|
||||||
5. "engine_code" = codigo exacto del motor SI LO CONOCES (ej: "2ZR-FE", "K24Z7", "EA888"). Si no lo conoces, usa "" (string vacio).
|
5. "engine" = descripcion corta del motor (ej: "1.8L", "V6 3.5L"). Si no lo conoces, usa "".
|
||||||
6. Devuelve TODOS los vehiculos compatibles que conozcas. Minimo 1, maximo 100. Para piezas genericas (bujias, filtros, balatas, amortiguadores) incluye TODOS los modelos aplicables.
|
6. "engine_code" = codigo exacto SI LO CONOCES. Si no, usa "".
|
||||||
7. "confidence" entre 0.0 y 1.0. Usa valores altos (>0.85) solo si estas muy seguro.
|
7. "notes" = string vacio "" para ahorrar tokens, salvo que haya una advertencia critica.
|
||||||
8. Incluye marcas y modelos populares en Mexico (Nissan Tsuru, VW Sedan/Vocho, Chevy Monza, Ford Ka, etc.) cuando apliquen.
|
8. Devuelve TODOS los vehiculos compatibles que conozcas. Minimo 1, maximo 200. Para piezas genericas (filtros de aceite, bujias, balatas, amortiguadores) incluye TODOS los modelos aplicables.
|
||||||
9. Si la pieza es universal o de alta compatibilidad, indicalo en "notes".
|
9. "confidence" entre 0.0 y 1.0. Valores >0.85 solo si estas muy seguro.
|
||||||
|
10. Incluye marcas y modelos populares en Mexico cuando apliquen.
|
||||||
|
11. Si la pieza es universal, indicalo en "notes".
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
@@ -153,25 +150,39 @@ def _extract_vehicles(parsed):
|
|||||||
|
|
||||||
|
|
||||||
def _normalize_vehicle(v):
|
def _normalize_vehicle(v):
|
||||||
"""Normalize vehicle dict from QWEN to standard keys."""
|
"""Normalize vehicle dict from QWEN to standard keys.
|
||||||
|
|
||||||
|
Supports:
|
||||||
|
- year: int or str (single year)
|
||||||
|
- year_range: str like "2003-2008" or "2003-2008"
|
||||||
|
- legacy: year as range string
|
||||||
|
"""
|
||||||
make = v.get('make') or v.get('marca') or ''
|
make = v.get('make') or v.get('marca') or ''
|
||||||
model = v.get('model') or v.get('modelo') or ''
|
model = v.get('model') or v.get('modelo') or ''
|
||||||
year_raw = v.get('year') or v.get('ano') or v.get('año') or v.get('years') or v.get('anos') or ''
|
|
||||||
engine = v.get('engine') or v.get('motor') or ''
|
engine = v.get('engine') or v.get('motor') or ''
|
||||||
engine_code = v.get('engine_code') or v.get('codigo_motor') or v.get('motor_code') or ''
|
engine_code = v.get('engine_code') or v.get('codigo_motor') or v.get('motor_code') or ''
|
||||||
|
|
||||||
# Parse year (may be int, string, or range like "2003-2008")
|
|
||||||
years = []
|
years = []
|
||||||
|
|
||||||
|
# Prefer explicit year_range
|
||||||
|
year_range = v.get('year_range') or v.get('rango_ano') or ''
|
||||||
|
if isinstance(year_range, str):
|
||||||
|
m = re.match(r'(\d{4})\s*[-–]\s*(\d{4})', year_range)
|
||||||
|
if m:
|
||||||
|
start, end = int(m.group(1)), int(m.group(2))
|
||||||
|
years = list(range(start, end + 1))
|
||||||
|
|
||||||
|
# Fallback to year (int or str)
|
||||||
|
if not years:
|
||||||
|
year_raw = v.get('year') or v.get('ano') or v.get('año') or v.get('years') or v.get('anos') or ''
|
||||||
if isinstance(year_raw, int):
|
if isinstance(year_raw, int):
|
||||||
years = [year_raw]
|
years = [year_raw]
|
||||||
elif isinstance(year_raw, str):
|
elif isinstance(year_raw, str):
|
||||||
# Try range "2003-2008"
|
|
||||||
m = re.match(r'(\d{4})\s*[-–]\s*(\d{4})', year_raw)
|
m = re.match(r'(\d{4})\s*[-–]\s*(\d{4})', year_raw)
|
||||||
if m:
|
if m:
|
||||||
start, end = int(m.group(1)), int(m.group(2))
|
start, end = int(m.group(1)), int(m.group(2))
|
||||||
years = list(range(start, end + 1))
|
years = list(range(start, end + 1))
|
||||||
else:
|
else:
|
||||||
# Try single year
|
|
||||||
m2 = re.match(r'(\d{4})', year_raw)
|
m2 = re.match(r'(\d{4})', year_raw)
|
||||||
if m2:
|
if m2:
|
||||||
years = [int(m2.group(1))]
|
years = [int(m2.group(1))]
|
||||||
@@ -200,16 +211,30 @@ def _validate_vehicles(vehicles):
|
|||||||
1. Exact engine_code match (most precise)
|
1. Exact engine_code match (most precise)
|
||||||
2. Displacement-based match (e.g. all 1.8L engines for that make/model/year)
|
2. Displacement-based match (e.g. all 1.8L engines for that make/model/year)
|
||||||
3. Broad make/model/year match (all engines for that make/model/year)
|
3. Broad make/model/year match (all engines for that make/model/year)
|
||||||
|
|
||||||
|
If the master DB does not contain the vehicle (e.g. North-American models
|
||||||
|
missing from TecDoc), the vehicle is returned with mye_id=None so it can
|
||||||
|
be stored as a text-only QWEN record.
|
||||||
"""
|
"""
|
||||||
from tenant_db import get_master_conn
|
from tenant_db import get_master_conn
|
||||||
try:
|
try:
|
||||||
master = get_master_conn()
|
master = get_master_conn()
|
||||||
cur = master.cursor()
|
cur = master.cursor()
|
||||||
except Exception:
|
except Exception:
|
||||||
return []
|
# Master DB unreachable — return all vehicles as unmatched text
|
||||||
|
return [
|
||||||
|
{'make': v.get('make') or v.get('marca') or '',
|
||||||
|
'model': v.get('model') or v.get('modelo') or '',
|
||||||
|
'year': v.get('year') or v.get('ano') or v.get('año') or 0,
|
||||||
|
'engine': v.get('engine') or v.get('motor') or '',
|
||||||
|
'engine_code': v.get('engine_code') or v.get('codigo_motor') or '',
|
||||||
|
'mye_id': None}
|
||||||
|
for v in vehicles
|
||||||
|
]
|
||||||
|
|
||||||
validated = []
|
validated = []
|
||||||
seen_mye = set()
|
seen_mye = set()
|
||||||
|
seen_text = set() # (make, model, year) for text-only dedup
|
||||||
|
|
||||||
for v in vehicles:
|
for v in vehicles:
|
||||||
make, model, years, engine, engine_code = _normalize_vehicle(v)
|
make, model, years, engine, engine_code = _normalize_vehicle(v)
|
||||||
@@ -285,6 +310,7 @@ def _validate_vehicles(vehicles):
|
|||||||
matched_myes = [r[0] for r in cur.fetchall()]
|
matched_myes = [r[0] for r in cur.fetchall()]
|
||||||
|
|
||||||
# Deduplicate and add to results
|
# Deduplicate and add to results
|
||||||
|
if matched_myes:
|
||||||
for mye_id in matched_myes:
|
for mye_id in matched_myes:
|
||||||
if mye_id not in seen_mye:
|
if mye_id not in seen_mye:
|
||||||
seen_mye.add(mye_id)
|
seen_mye.add(mye_id)
|
||||||
@@ -296,6 +322,19 @@ def _validate_vehicles(vehicles):
|
|||||||
'engine_code': engine_code,
|
'engine_code': engine_code,
|
||||||
'mye_id': mye_id,
|
'mye_id': mye_id,
|
||||||
})
|
})
|
||||||
|
else:
|
||||||
|
# No match in master DB — store as text-only QWEN record
|
||||||
|
text_key = (make.upper(), model.upper(), year)
|
||||||
|
if text_key not in seen_text:
|
||||||
|
seen_text.add(text_key)
|
||||||
|
validated.append({
|
||||||
|
'make': make,
|
||||||
|
'model': model,
|
||||||
|
'year': year,
|
||||||
|
'engine': engine,
|
||||||
|
'engine_code': engine_code,
|
||||||
|
'mye_id': None,
|
||||||
|
})
|
||||||
|
|
||||||
cur.close()
|
cur.close()
|
||||||
master.close()
|
master.close()
|
||||||
|
|||||||
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
|
||||||
|
|
||||||
|
|
||||||
@@ -135,41 +135,34 @@ def _ensure_sessions_table(tenant_conn):
|
|||||||
cur.close()
|
cur.close()
|
||||||
|
|
||||||
|
|
||||||
def set_last_shown_part(phone, part_info):
|
def set_last_shown_part(tenant_conn, phone, part_info):
|
||||||
"""Store the last part shown to this phone number.
|
"""Store the last part shown to this phone number.
|
||||||
|
|
||||||
part_info: dict with keys inventory_id, part_number, name, brand,
|
part_info: dict with keys inventory_id, part_number, name, brand,
|
||||||
price, stock, unit
|
price, stock, unit
|
||||||
"""
|
"""
|
||||||
# In-memory fallback for when tenant_conn is not available
|
|
||||||
from tenant_db import get_tenant_conn
|
|
||||||
try:
|
try:
|
||||||
conn = get_tenant_conn(11)
|
_ensure_sessions_table(tenant_conn)
|
||||||
_ensure_sessions_table(conn)
|
cur = tenant_conn.cursor()
|
||||||
cur = conn.cursor()
|
|
||||||
import json
|
import json
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
INSERT INTO whatsapp_sessions (phone, last_shown, updated_at)
|
INSERT INTO whatsapp_sessions (phone, last_shown, updated_at)
|
||||||
VALUES (%s, %s, NOW())
|
VALUES (%s, %s, NOW())
|
||||||
ON CONFLICT (phone) DO UPDATE SET last_shown = EXCLUDED.last_shown, updated_at = NOW()
|
ON CONFLICT (phone) DO UPDATE SET last_shown = EXCLUDED.last_shown, updated_at = NOW()
|
||||||
""", (phone, json.dumps(part_info)))
|
""", (phone, json.dumps(part_info)))
|
||||||
conn.commit()
|
tenant_conn.commit()
|
||||||
cur.close()
|
cur.close()
|
||||||
conn.close()
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[WA-SESSION] Failed to persist last_shown for {phone}: {e}")
|
print(f"[WA-SESSION] Failed to persist last_shown for {phone}: {e}")
|
||||||
|
|
||||||
|
|
||||||
def get_last_shown_part(phone):
|
def get_last_shown_part(tenant_conn, phone):
|
||||||
from tenant_db import get_tenant_conn
|
|
||||||
try:
|
try:
|
||||||
conn = get_tenant_conn(11)
|
_ensure_sessions_table(tenant_conn)
|
||||||
_ensure_sessions_table(conn)
|
cur = tenant_conn.cursor()
|
||||||
cur = conn.cursor()
|
|
||||||
cur.execute("SELECT last_shown FROM whatsapp_sessions WHERE phone = %s", (phone,))
|
cur.execute("SELECT last_shown FROM whatsapp_sessions WHERE phone = %s", (phone,))
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
cur.close()
|
cur.close()
|
||||||
conn.close()
|
|
||||||
if row and row[0]:
|
if row and row[0]:
|
||||||
return row[0]
|
return row[0]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -177,54 +170,45 @@ def get_last_shown_part(phone):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def clear_last_shown(phone):
|
def clear_last_shown(tenant_conn, phone):
|
||||||
from tenant_db import get_tenant_conn
|
|
||||||
try:
|
try:
|
||||||
conn = get_tenant_conn(11)
|
_ensure_sessions_table(tenant_conn)
|
||||||
_ensure_sessions_table(conn)
|
cur = tenant_conn.cursor()
|
||||||
cur = conn.cursor()
|
|
||||||
cur.execute("DELETE FROM whatsapp_sessions WHERE phone = %s", (phone,))
|
cur.execute("DELETE FROM whatsapp_sessions WHERE phone = %s", (phone,))
|
||||||
conn.commit()
|
tenant_conn.commit()
|
||||||
cur.close()
|
cur.close()
|
||||||
conn.close()
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[WA-SESSION] Failed to clear last_shown for {phone}: {e}")
|
print(f"[WA-SESSION] Failed to clear last_shown for {phone}: {e}")
|
||||||
|
|
||||||
|
|
||||||
def set_vehicle(phone, vehicle):
|
def set_vehicle(tenant_conn, phone, vehicle):
|
||||||
"""Store the detected vehicle for this phone number.
|
"""Store the detected vehicle for this phone number.
|
||||||
|
|
||||||
vehicle: dict with keys brand, model, year
|
vehicle: dict with keys brand, model, year
|
||||||
"""
|
"""
|
||||||
from tenant_db import get_tenant_conn
|
|
||||||
try:
|
try:
|
||||||
conn = get_tenant_conn(11)
|
_ensure_sessions_table(tenant_conn)
|
||||||
_ensure_sessions_table(conn)
|
cur = tenant_conn.cursor()
|
||||||
cur = conn.cursor()
|
|
||||||
import json
|
import json
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
INSERT INTO whatsapp_sessions (phone, vehicle, updated_at)
|
INSERT INTO whatsapp_sessions (phone, vehicle, updated_at)
|
||||||
VALUES (%s, %s, NOW())
|
VALUES (%s, %s, NOW())
|
||||||
ON CONFLICT (phone) DO UPDATE SET vehicle = EXCLUDED.vehicle, updated_at = NOW()
|
ON CONFLICT (phone) DO UPDATE SET vehicle = EXCLUDED.vehicle, updated_at = NOW()
|
||||||
""", (phone, json.dumps(vehicle)))
|
""", (phone, json.dumps(vehicle)))
|
||||||
conn.commit()
|
tenant_conn.commit()
|
||||||
cur.close()
|
cur.close()
|
||||||
conn.close()
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[WA-SESSION] Failed to persist vehicle for {phone}: {e}")
|
print(f"[WA-SESSION] Failed to persist vehicle for {phone}: {e}")
|
||||||
|
|
||||||
|
|
||||||
def get_vehicle(phone):
|
def get_vehicle(tenant_conn, phone):
|
||||||
"""Retrieve the stored vehicle for this phone number."""
|
"""Retrieve the stored vehicle for this phone number."""
|
||||||
from tenant_db import get_tenant_conn
|
|
||||||
try:
|
try:
|
||||||
conn = get_tenant_conn(11)
|
_ensure_sessions_table(tenant_conn)
|
||||||
_ensure_sessions_table(conn)
|
cur = tenant_conn.cursor()
|
||||||
cur = conn.cursor()
|
|
||||||
cur.execute("SELECT vehicle FROM whatsapp_sessions WHERE phone = %s", (phone,))
|
cur.execute("SELECT vehicle FROM whatsapp_sessions WHERE phone = %s", (phone,))
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
cur.close()
|
cur.close()
|
||||||
conn.close()
|
|
||||||
if row and row[0]:
|
if row and row[0]:
|
||||||
return row[0]
|
return row[0]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -232,17 +216,14 @@ def get_vehicle(phone):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def clear_session(phone):
|
def clear_session(tenant_conn, phone):
|
||||||
"""Clear all session data (last_shown + vehicle) for this phone."""
|
"""Clear all session data (last_shown + vehicle) for this phone."""
|
||||||
from tenant_db import get_tenant_conn
|
|
||||||
try:
|
try:
|
||||||
conn = get_tenant_conn(11)
|
_ensure_sessions_table(tenant_conn)
|
||||||
_ensure_sessions_table(conn)
|
cur = tenant_conn.cursor()
|
||||||
cur = conn.cursor()
|
|
||||||
cur.execute("DELETE FROM whatsapp_sessions WHERE phone = %s", (phone,))
|
cur.execute("DELETE FROM whatsapp_sessions WHERE phone = %s", (phone,))
|
||||||
conn.commit()
|
tenant_conn.commit()
|
||||||
cur.close()
|
cur.close()
|
||||||
conn.close()
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[WA-SESSION] Failed to clear session for {phone}: {e}")
|
print(f"[WA-SESSION] Failed to clear session for {phone}: {e}")
|
||||||
|
|
||||||
@@ -361,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
65
pos/services/webhook_service.py
Normal file
65
pos/services/webhook_service.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
"""Webhook dispatch service for dropshipping and external integrations.
|
||||||
|
|
||||||
|
Sends POST requests to configured target URLs with retry logic.
|
||||||
|
Can be called synchronously or enqueued via Celery.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import requests
|
||||||
|
import threading
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _send_post(url: str, payload: dict, headers: Optional[dict] = None, timeout: int = 10):
|
||||||
|
"""Send a POST request and return (success, status_code, response_text)."""
|
||||||
|
default_headers = {"Content-Type": "application/json"}
|
||||||
|
if headers:
|
||||||
|
default_headers.update(headers)
|
||||||
|
try:
|
||||||
|
resp = requests.post(url, json=payload, headers=default_headers, timeout=timeout)
|
||||||
|
success = 200 <= resp.status_code < 300
|
||||||
|
if not success:
|
||||||
|
logger.warning("Webhook %s returned %s: %s", url, resp.status_code, resp.text[:200])
|
||||||
|
return success, resp.status_code, resp.text
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
logger.warning("Webhook %s timed out after %ss", url, timeout)
|
||||||
|
return False, 0, "timeout"
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Webhook %s failed: %s", url, e)
|
||||||
|
return False, 0, str(e)
|
||||||
|
|
||||||
|
|
||||||
|
def dispatch_webhook_sync(target_url: str, event_type: str, payload: dict, secret: Optional[str] = None):
|
||||||
|
"""Send webhook synchronously (use inside Celery tasks for async)."""
|
||||||
|
full_payload = {
|
||||||
|
"event": event_type,
|
||||||
|
"data": payload,
|
||||||
|
}
|
||||||
|
headers = {}
|
||||||
|
if secret:
|
||||||
|
headers["X-Webhook-Secret"] = secret
|
||||||
|
success, status, body = _send_post(target_url, full_payload, headers=headers)
|
||||||
|
return {"success": success, "status": status, "body": body[:500]}
|
||||||
|
|
||||||
|
|
||||||
|
def dispatch_webhooks_bulk(target_urls: list[str], event_type: str, payload: dict, secret: Optional[str] = None):
|
||||||
|
"""Dispatch to multiple URLs concurrently using threads."""
|
||||||
|
results = []
|
||||||
|
threads = []
|
||||||
|
|
||||||
|
def _send(url):
|
||||||
|
result = dispatch_webhook_sync(url, event_type, payload, secret=secret)
|
||||||
|
results.append({"url": url, **result})
|
||||||
|
|
||||||
|
for url in target_urls:
|
||||||
|
t = threading.Thread(target=_send, args=(url,))
|
||||||
|
t.start()
|
||||||
|
threads.append(t)
|
||||||
|
|
||||||
|
for t in threads:
|
||||||
|
t.join(timeout=15)
|
||||||
|
|
||||||
|
return results
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"""WhatsApp service via Baileys Bridge (self-hosted, free).
|
"""WhatsApp service via Baileys Bridge (self-hosted, free).
|
||||||
|
|
||||||
Simple REST bridge at localhost:21465 that wraps WhatsApp Web via Baileys.
|
Simple REST bridge that wraps WhatsApp Web via Baileys.
|
||||||
|
Supports per-tenant configuration via bridge_url parameter.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
@@ -9,47 +10,56 @@ from config import WHATSAPP_BRIDGE_URL
|
|||||||
HEADERS = {'Content-Type': 'application/json'}
|
HEADERS = {'Content-Type': 'application/json'}
|
||||||
|
|
||||||
|
|
||||||
def get_status():
|
def _get_url(bridge_url=None):
|
||||||
|
return bridge_url or WHATSAPP_BRIDGE_URL
|
||||||
|
|
||||||
|
|
||||||
|
def get_status(bridge_url=None):
|
||||||
|
url = _get_url(bridge_url)
|
||||||
try:
|
try:
|
||||||
return requests.get(f'{WHATSAPP_BRIDGE_URL}/status', timeout=5).json()
|
return requests.get(f'{url}/status', timeout=5).json()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {'state': 'error', 'error': str(e)}
|
return {'state': 'error', 'error': str(e)}
|
||||||
|
|
||||||
|
|
||||||
def get_qr():
|
def get_qr(bridge_url=None):
|
||||||
|
url = _get_url(bridge_url)
|
||||||
try:
|
try:
|
||||||
return requests.get(f'{WHATSAPP_BRIDGE_URL}/qr', timeout=5).json()
|
return requests.get(f'{url}/qr', timeout=5).json()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {'state': 'error', 'error': str(e)}
|
return {'state': 'error', 'error': str(e)}
|
||||||
|
|
||||||
|
|
||||||
def connect():
|
def connect(bridge_url=None):
|
||||||
|
url = _get_url(bridge_url)
|
||||||
try:
|
try:
|
||||||
return requests.post(f'{WHATSAPP_BRIDGE_URL}/connect', headers=HEADERS, timeout=5).json()
|
return requests.post(f'{url}/connect', headers=HEADERS, timeout=5).json()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {'state': 'error', 'error': str(e)}
|
return {'state': 'error', 'error': str(e)}
|
||||||
|
|
||||||
|
|
||||||
def send_message(phone, text):
|
def send_message(phone, text, bridge_url=None):
|
||||||
|
url = _get_url(bridge_url)
|
||||||
try:
|
try:
|
||||||
return requests.post(f'{WHATSAPP_BRIDGE_URL}/send', headers=HEADERS, json={'phone': phone, 'message': text}, timeout=15).json()
|
return requests.post(f'{url}/send', headers=HEADERS, json={'phone': phone, 'message': text}, timeout=15).json()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {'error': str(e)}
|
return {'error': str(e)}
|
||||||
|
|
||||||
|
|
||||||
def send_quote(phone, quote_data):
|
def send_quote(phone, quote_data, bridge_url=None):
|
||||||
lines = [f"*Cotizacion #{quote_data.get('id', '')}*", ""]
|
lines = [f"*Cotizacion #{quote_data.get('id', '')}*", ""]
|
||||||
for item in quote_data.get('items', []):
|
for item in quote_data.get('items', []):
|
||||||
lines.append(f"- {item.get('quantity', 1)}x {item.get('name', '')} ${item.get('subtotal', 0):,.2f}")
|
lines.append(f"- {item.get('quantity', 1)}x {item.get('name', '')} ${item.get('subtotal', 0):,.2f}")
|
||||||
lines.append(f"\nSubtotal: ${quote_data.get('subtotal', 0):,.2f}")
|
lines.append(f"\nSubtotal: ${quote_data.get('subtotal', 0):,.2f}")
|
||||||
lines.append(f"IVA: ${quote_data.get('tax_total', 0):,.2f}")
|
lines.append(f"IVA: ${quote_data.get('tax_total', 0):,.2f}")
|
||||||
lines.append(f"*Total: ${quote_data.get('total', 0):,.2f}*")
|
lines.append(f"*Total: ${quote_data.get('total', 0):,.2f}*")
|
||||||
return send_message(phone, "\n".join(lines))
|
return send_message(phone, "\n".join(lines), bridge_url=bridge_url)
|
||||||
|
|
||||||
|
|
||||||
def logout():
|
def logout(bridge_url=None):
|
||||||
|
url = _get_url(bridge_url)
|
||||||
try:
|
try:
|
||||||
return requests.post(f'{WHATSAPP_BRIDGE_URL}/logout', headers=HEADERS, timeout=5).json()
|
return requests.post(f'{url}/logout', headers=HEADERS, timeout=5).json()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {'error': str(e)}
|
return {'error': str(e)}
|
||||||
|
|
||||||
@@ -72,14 +82,25 @@ def process_incoming(webhook_data):
|
|||||||
media_base64 — base64 string if media, else None
|
media_base64 — base64 string if media, else None
|
||||||
media_mimetype — e.g. 'image/jpeg', 'audio/ogg'
|
media_mimetype — e.g. 'image/jpeg', 'audio/ogg'
|
||||||
is_voice_note — True for WhatsApp voice notes (audioMessage ptt)
|
is_voice_note — True for WhatsApp voice notes (audioMessage ptt)
|
||||||
|
push_name — display name from WhatsApp
|
||||||
"""
|
"""
|
||||||
data = webhook_data.get('data', {})
|
data = webhook_data.get('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).
|
||||||
@@ -94,6 +115,7 @@ def process_incoming(webhook_data):
|
|||||||
# - For 'text' messages → conversation or extendedTextMessage
|
# - For 'text' messages → conversation or extendedTextMessage
|
||||||
# - For 'image'/'video' → the caption (may be empty)
|
# - For 'image'/'video' → the caption (may be empty)
|
||||||
# - For 'audio' → empty (filled in later by Whisper transcription)
|
# - For 'audio' → empty (filled in later by Whisper transcription)
|
||||||
|
# - For 'location' → synthetic text with coordinates
|
||||||
if media_kind == 'text':
|
if media_kind == 'text':
|
||||||
text = (
|
text = (
|
||||||
message.get('conversation', '')
|
message.get('conversation', '')
|
||||||
@@ -103,9 +125,14 @@ def process_incoming(webhook_data):
|
|||||||
else:
|
else:
|
||||||
text = media_caption
|
text = media_caption
|
||||||
|
|
||||||
|
# Location fields (from bridge classification)
|
||||||
|
latitude = data.get('latitude')
|
||||||
|
longitude = data.get('longitude')
|
||||||
|
|
||||||
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', ''),
|
||||||
@@ -114,4 +141,20 @@ def process_incoming(webhook_data):
|
|||||||
'media_mimetype': media_mimetype,
|
'media_mimetype': media_mimetype,
|
||||||
'is_voice_note': is_voice_note,
|
'is_voice_note': is_voice_note,
|
||||||
'push_name': push_name,
|
'push_name': push_name,
|
||||||
|
'latitude': latitude,
|
||||||
|
'longitude': longitude,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def send_image(phone, caption, base64_image, bridge_url=None):
|
||||||
|
"""Send an image message via the Baileys bridge."""
|
||||||
|
url = _get_url(bridge_url)
|
||||||
|
try:
|
||||||
|
return requests.post(
|
||||||
|
f'{url}/send-image',
|
||||||
|
headers=HEADERS,
|
||||||
|
json={'phone': phone, 'caption': caption, 'base64': base64_image},
|
||||||
|
timeout=15
|
||||||
|
).json()
|
||||||
|
except Exception as e:
|
||||||
|
return {'error': str(e)}
|
||||||
|
|||||||
@@ -19,8 +19,6 @@
|
|||||||
font-size: var(--text-body-sm);
|
font-size: var(--text-body-sm);
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
background-color: var(--color-bg-base);
|
background-color: var(--color-bg-base);
|
||||||
transition: background-color var(--duration-normal) var(--ease-in-out),
|
|
||||||
color var(--duration-normal) var(--ease-in-out);
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,6 +324,7 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
margin-left: 260px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-header {
|
.page-header {
|
||||||
|
|||||||
2
pos/static/css/accounting.min.css
vendored
2
pos/static/css/accounting.min.css
vendored
@@ -19,8 +19,6 @@
|
|||||||
font-size: var(--text-body-sm);
|
font-size: var(--text-body-sm);
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
background-color: var(--color-bg-base);
|
background-color: var(--color-bg-base);
|
||||||
transition: background-color var(--duration-normal) var(--ease-in-out),
|
|
||||||
color var(--duration-normal) var(--ease-in-out);
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,6 @@
|
|||||||
font-size: var(--text-body);
|
font-size: var(--text-body);
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
background-color: var(--color-bg-base);
|
background-color: var(--color-bg-base);
|
||||||
transition: background-color var(--duration-normal) var(--ease-in-out),
|
|
||||||
color var(--duration-normal) var(--ease-in-out);
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,7 +74,7 @@
|
|||||||
MAIN CONTENT
|
MAIN CONTENT
|
||||||
========================================================================= */
|
========================================================================= */
|
||||||
|
|
||||||
.main-content { flex: 1; display: flex; flex-direction: column; overflow: hidden; min-width: 0; }
|
.main-content { flex: 1; display: flex; flex-direction: column; overflow: hidden; min-width: 0; margin-left: 260px; }
|
||||||
|
|
||||||
/* Header with breadcrumb + search */
|
/* Header with breadcrumb + search */
|
||||||
.content-header {
|
.content-header {
|
||||||
|
|||||||
2
pos/static/css/catalog.min.css
vendored
2
pos/static/css/catalog.min.css
vendored
@@ -12,8 +12,6 @@
|
|||||||
font-size: var(--text-body);
|
font-size: var(--text-body);
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
background-color: var(--color-bg-base);
|
background-color: var(--color-bg-base);
|
||||||
transition: background-color var(--duration-normal) var(--ease-in-out),
|
|
||||||
color var(--duration-normal) var(--ease-in-out);
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,8 +19,6 @@
|
|||||||
font-size: var(--text-body-sm);
|
font-size: var(--text-body-sm);
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
background-color: var(--color-bg-base);
|
background-color: var(--color-bg-base);
|
||||||
transition: background-color var(--duration-normal) var(--ease-in-out),
|
|
||||||
color var(--duration-normal) var(--ease-in-out);
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,6 +324,7 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
margin-left: 260px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-header {
|
.page-header {
|
||||||
@@ -1028,6 +1027,7 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.device-card {
|
.device-card {
|
||||||
@@ -1040,6 +1040,11 @@
|
|||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
transition: var(--transition-normal);
|
transition: var(--transition-normal);
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="modern"] .device-card {
|
[data-theme="modern"] .device-card {
|
||||||
@@ -1072,7 +1077,7 @@
|
|||||||
stroke-linejoin: round;
|
stroke-linejoin: round;
|
||||||
}
|
}
|
||||||
|
|
||||||
.device-card__body { flex: 1; }
|
.device-card__body { flex: 1; min-width: 0; overflow-wrap: break-word; }
|
||||||
|
|
||||||
.device-card__name {
|
.device-card__name {
|
||||||
font-weight: var(--font-weight-semibold);
|
font-weight: var(--font-weight-semibold);
|
||||||
|
|||||||
2
pos/static/css/config.min.css
vendored
2
pos/static/css/config.min.css
vendored
@@ -19,8 +19,6 @@
|
|||||||
font-size: var(--text-body-sm);
|
font-size: var(--text-body-sm);
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
background-color: var(--color-bg-base);
|
background-color: var(--color-bg-base);
|
||||||
transition: background-color var(--duration-normal) var(--ease-in-out),
|
|
||||||
color var(--duration-normal) var(--ease-in-out);
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user