Compare commits
84 Commits
bfa7bc2997
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 74118a3247 | |||
| 14219e7117 | |||
| 6b80add102 | |||
| ad04572305 | |||
| ee7e1d49e5 | |||
| 49bbc37117 | |||
| f5711ae22f | |||
| 85ecf52561 | |||
| 584b87f82c | |||
| b635e44302 | |||
| e201dce290 | |||
| ce66212223 | |||
| d67887284d | |||
| 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 | |||
| 71f3b1cdec | |||
| 159d0ed625 | |||
| 50c0dbe7d4 | |||
| 0b1dc89faf | |||
| dbf45e374b | |||
| 07b9b9130a | |||
| ae2273f864 | |||
| d9741b21f6 | |||
| e38148e8d5 | |||
| 912fe4cef5 | |||
| a7334513ac | |||
| 2f8b9dd5aa | |||
| 60dd8162f7 |
@@ -57,6 +57,13 @@ METABASE_ADMIN_EMAIL=admin@nexus.local
|
||||
METABASE_ADMIN_PASS=change-me-to-a-strong-password
|
||||
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
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
67
.github/workflows/ci.yml
vendored
Normal file
67
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,67 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
pull_request:
|
||||
branches: [main, master]
|
||||
|
||||
jobs:
|
||||
lint-and-test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.11", "3.13"]
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
pip install -r pos/requirements.txt
|
||||
pip install -r requirements-dev.txt
|
||||
|
||||
- name: Determine changed Python files
|
||||
id: changed
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||
BASE="${{ github.event.pull_request.base.sha }}"
|
||||
else
|
||||
BASE="HEAD~1"
|
||||
fi
|
||||
FILES=$(git diff --name-only --diff-filter=ACMRT "$BASE" HEAD | grep '\.py$' || true)
|
||||
echo "files=$FILES" >> "$GITHUB_OUTPUT"
|
||||
echo "Changed Python files:"
|
||||
echo "$FILES"
|
||||
|
||||
- name: Lint changed files with ruff
|
||||
run: |
|
||||
FILES="${{ steps.changed.outputs.files }}"
|
||||
if [ -z "$FILES" ]; then
|
||||
echo "No Python files changed. Skipping lint."
|
||||
exit 0
|
||||
fi
|
||||
ruff check $FILES
|
||||
ruff format --check $FILES
|
||||
|
||||
- name: Run console unit tests
|
||||
run: |
|
||||
python -m pytest console/tests/test_core.py console/tests/test_utils.py -v
|
||||
|
||||
# Playwright E2E tests require the full stack (PostgreSQL, Redis, etc.).
|
||||
# Enable this job once a test environment is available in CI.
|
||||
# - name: Run E2E tests
|
||||
# run: |
|
||||
# npm ci
|
||||
# npx playwright install --with-deps chromium
|
||||
# npx playwright test
|
||||
@@ -195,7 +195,9 @@ Ver [docs/INSTALACION.md](docs/INSTALACION.md) para instrucciones detalladas.
|
||||
| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | Arquitectura del sistema |
|
||||
| [docs/DATABASE.md](docs/DATABASE.md) | Esquema de base de datos |
|
||||
|
||||
|
||||
---
|
||||
|
||||
**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"
|
||||
)
|
||||
|
||||
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)
|
||||
SQLITE_PATH = os.path.join(
|
||||
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>
|
||||
</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>
|
||||
|
||||
<!-- Main Content -->
|
||||
@@ -660,6 +668,35 @@
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -121,6 +121,9 @@ function showSection(sectionId) {
|
||||
case 'users':
|
||||
loadUsers();
|
||||
break;
|
||||
case 'tenants':
|
||||
loadTenants();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2074,3 +2077,99 @@ async function toggleUserActive(userId, currentActive) {
|
||||
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 name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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>
|
||||
(function(){
|
||||
var t = localStorage.getItem('nexus-theme') || 'industrial';
|
||||
@@ -41,7 +41,7 @@
|
||||
<canvas id="heroCanvas"></canvas>
|
||||
<div class="hero-content">
|
||||
<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">
|
||||
<span id="typewriterText"></span><span class="typewriter-cursor"></span>
|
||||
</div>
|
||||
@@ -78,59 +78,46 @@
|
||||
<section class="product">
|
||||
<div class="container">
|
||||
<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-card product-card--orange nx-reveal">
|
||||
<h3>Ventas & POS</h3>
|
||||
<h3>Catalogo Completo + POS + Inventario</h3>
|
||||
<ul>
|
||||
<li>Punto de venta completo con F-keys y escaner</li>
|
||||
<li>Caja registradora multi-caja, cortes X/Z</li>
|
||||
<li>Cotizaciones, apartados, devoluciones</li>
|
||||
<li>Clientes con credito y 3 niveles de precio</li>
|
||||
<li>Facturacion CFDI 4.0 (Ingreso, Egreso, Pago)</li>
|
||||
<li>Impresion termica ESC/POS</li>
|
||||
<li>Contabilidad con polizas automaticas</li>
|
||||
<li>Reportes: ventas, ABC, cortes, utilidad</li>
|
||||
<li>Catalogo completo: 1.5M+ partes OEM y 304K+ aftermarket</li>
|
||||
<li>Punto de venta completo con escaner y teclas rapidas</li>
|
||||
<li>Inventario append-only con toma fisica y alertas de stock</li>
|
||||
<li>Navegacion por vehiculo: Marca > Modelo > Ano > Motor</li>
|
||||
<li>Decodificador VIN + busqueda por placas MX</li>
|
||||
<li>Facturacion CFDI 4.0 integrada</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="product-card product-card--cyan nx-reveal">
|
||||
<h3>Catalogo & Inventario</h3>
|
||||
<h3>Agente AI para WhatsApp</h3>
|
||||
<ul>
|
||||
<li>Catalogo TecDoc: 1.5M+ partes OEM</li>
|
||||
<li>304K+ partes aftermarket con cross-refs</li>
|
||||
<li>Navegacion: Ano > Marca > Modelo > Motor</li>
|
||||
<li>VIN decoder + busqueda por placas MX</li>
|
||||
<li>Inventario append-only, toma fisica</li>
|
||||
<li>Imagenes de productos con upload masivo</li>
|
||||
<li>Traduccion automatica EN > ES (326 partes)</li>
|
||||
<li>Marketplace B2B: bodegas ↔ talleres</li>
|
||||
<li>Atiende consultas de autopartes 24/7 automaticamente</li>
|
||||
<li>Genera cotizaciones inteligentes desde la conversacion</li>
|
||||
<li>Reconoce piezas por foto con Vision AI</li>
|
||||
<li>Transcripcion de notas de voz a texto</li>
|
||||
<li>Envia catalogos y cotizaciones directo al cliente</li>
|
||||
<li>Reduce llamadas y aumenta conversiones</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="product-card product-card--green nx-reveal">
|
||||
<h3>IA & Plataforma</h3>
|
||||
<h3>Vinculacion con Mercado Libre</h3>
|
||||
<ul>
|
||||
<li>Chatbot IA: diagnostico, cotizacion inteligente</li>
|
||||
<li>Entrada por voz (Web Speech API)</li>
|
||||
<li>Reconocimiento de partes por foto (Vision AI)</li>
|
||||
<li>WhatsApp Business integrado (envio de cotizaciones)</li>
|
||||
<li>Gestion de flotillas y mantenimiento</li>
|
||||
<li>PWA + App Android, modo kiosko</li>
|
||||
<li>Offline-first con sync automatico</li>
|
||||
<li>2 temas, 2 idiomas (ES/EN), 2 monedas (MXN/USD)</li>
|
||||
<li>Publica tu inventario en Mercado Libre en minutos</li>
|
||||
<li>Sincronizacion automatica de stock y precios</li>
|
||||
<li>Descarga ordenes y conviertelas en ventas del POS</li>
|
||||
<li>Gestiona listados, preguntas y ventas desde un solo lugar</li>
|
||||
<li>Empieza a vender en linea sin complicaciones</li>
|
||||
<li>Mas canales, mas ventas, mismo inventario</li>
|
||||
</ul>
|
||||
</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>
|
||||
</section>
|
||||
|
||||
@@ -152,12 +139,12 @@
|
||||
<div class="step nx-reveal">
|
||||
<div class="step-number">2</div>
|
||||
<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 class="step nx-reveal">
|
||||
<div class="step-number">3</div>
|
||||
<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>
|
||||
@@ -176,7 +163,7 @@
|
||||
<div class="diff-grid nx-stagger">
|
||||
<div class="diff-card nx-reveal">
|
||||
<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>
|
||||
</div>
|
||||
<div class="diff-card nx-reveal">
|
||||
@@ -191,13 +178,13 @@
|
||||
</div>
|
||||
<div class="diff-card nx-reveal">
|
||||
<div class="diff-icon">🚀</div>
|
||||
<h4>Marketplace B2B</h4>
|
||||
<p>Conecta bodegas con talleres. Mas ventas, menos llamadas.</p>
|
||||
<h4>Venta en Linea</h4>
|
||||
<p>Conecta tu inventario con Mercado Libre y vende 24/7.</p>
|
||||
</div>
|
||||
<div class="diff-card nx-reveal">
|
||||
<div class="diff-icon">🖥</div>
|
||||
<h4>Hardware incluido</h4>
|
||||
<p>Rack 3D con servidor. Renta todo por $2,000/mes.</p>
|
||||
<h4>Hardware opcional</h4>
|
||||
<p>Mini rack 3D con servidor. Disponible como add-on.</p>
|
||||
</div>
|
||||
<div class="diff-card nx-reveal">
|
||||
<div class="diff-icon">🌐</div>
|
||||
@@ -227,41 +214,46 @@
|
||||
<section class="pricing">
|
||||
<div class="container">
|
||||
<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-card nx-reveal">
|
||||
<h4>Basico</h4>
|
||||
<div class="pricing-price">$999</div>
|
||||
<div class="pricing-period">MXN / mes — solo software</div>
|
||||
<h4>POS Basico</h4>
|
||||
<div class="pricing-price">$650</div>
|
||||
<div class="pricing-period">MXN / mes</div>
|
||||
<ul>
|
||||
<li>POS + Inventario</li>
|
||||
<li>Catalogo TecDoc</li>
|
||||
<li>CFDI 4.0</li>
|
||||
<li>Punto de venta completo</li>
|
||||
<li>Inventario y catalogo de partes</li>
|
||||
<li>Facturacion CFDI 4.0</li>
|
||||
<li>Reportes basicos</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="pricing-card featured nx-reveal">
|
||||
<h4>Pro</h4>
|
||||
<div class="pricing-price">$2,000</div>
|
||||
<div class="pricing-period">MXN / mes — hardware incluido</div>
|
||||
<h4>Sistema Completo</h4>
|
||||
<div class="pricing-price">$1,660</div>
|
||||
<div class="pricing-period">MXN / mes</div>
|
||||
<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>Chatbot IA + WhatsApp</li>
|
||||
<li>Marketplace B2B</li>
|
||||
<li>🖥 Mini PC + rack 3D + red incluidos</li>
|
||||
<li>Multi-sucursal y flotillas</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="pricing-card nx-reveal">
|
||||
<h4>Enterprise</h4>
|
||||
<div class="pricing-price">$3,999</div>
|
||||
<div class="pricing-period">MXN / mes — hardware incluido</div>
|
||||
</div>
|
||||
<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);">
|
||||
<p><strong>Paga anual y ahorra 2 meses.</strong> Aplica a meses sin intereses (MSI).</p>
|
||||
</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>
|
||||
<li>Todo Pro +</li>
|
||||
<li>Flotillas + Multi-bodega</li>
|
||||
<li>API dedicada</li>
|
||||
<li>Soporte prioritario</li>
|
||||
<li>🖥 Hardware dedicado por sucursal</li>
|
||||
<li>Mini PC con POS preinstalado</li>
|
||||
<li>Switch + Access Point + UPS</li>
|
||||
<li>Rack 3D personalizado</li>
|
||||
<li>Solo conectar y empezar a vender</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -281,12 +273,12 @@
|
||||
<div class="contact-card nx-reveal">
|
||||
<div class="contact-icon">✉</div>
|
||||
<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 class="contact-card nx-reveal">
|
||||
<div class="contact-icon">📱</div>
|
||||
<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 class="contact-card nx-reveal">
|
||||
<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)
|
||||
from config import DB_URL
|
||||
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
|
||||
|
||||
sys.path.insert(0, os.path.join(_base, '..', 'pos'))
|
||||
@@ -4628,6 +4629,76 @@ def part_aftermarket(part_id):
|
||||
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)
|
||||
# ============================================================================
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
# Nexus POS — Resumen de Fases Implementadas
|
||||
|
||||
**Fecha:** 2026-04-29
|
||||
**Versión DB:** v3.2
|
||||
**Tests:** 73/73 pasando (pytest)
|
||||
**Fecha:** 2026-06-15
|
||||
**Versión DB:** v4.4
|
||||
**Tests:** 92/92 pasando (pytest: 61 consola + 20 Facturapi + 11 Taller; POS requieren PostgreSQL)
|
||||
**Commit:** `d678872` (HEAD + cambios sin commitear)
|
||||
|
||||
---
|
||||
|
||||
@@ -199,6 +200,123 @@ METABASE_URL=http://localhost:3000
|
||||
| — | **Stubs BNPL / ERP / WhatsApp Cloud / Supplier Portal** | 2026-04-29 | `2cfe4b3` |
|
||||
| — | **nexus-pos.service systemd** | 2026-04-29 | `c766571` |
|
||||
| — | **QWEN 3.6 AI Vehicle Fitment** | 2026-04-29 | `623c57b` |
|
||||
| — | **Migración CFDI de Horux a Facturapi** | 2026-06-14 | `8796cad` |
|
||||
| — | **Setup/estado masivo de organizaciones Facturapi** | 2026-06-15 | — |
|
||||
| — | **Módulo de Taller (Workshop Lite)** | 2026-06-15 | — |
|
||||
|
||||
## 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 |
|
||||
|
||||
---
|
||||
|
||||
## FASE 8: Migración CFDI a Facturapi
|
||||
|
||||
**Commit:** `8796cad` (2026-06-14)
|
||||
**Migración DB:** `v4.3_facturapi.sql`
|
||||
|
||||
| Feature | Archivos | Capacidades |
|
||||
|---------|----------|-------------|
|
||||
| **Timbrado vía Facturapi** | `facturapi_service.py`, `cfdi_facturapi_builder.py`, `cfdi_queue.py` | Payloads JSON para Facturapi en lugar de XML unsigned; timbrado, descarga XML/PDF, cancelación SAT |
|
||||
| **Organizaciones Facturapi** | `invoicing_bp.py` | `POST /pos/api/invoicing/facturapi/setup` crea/liga organización; `GET /pos/api/invoicing/facturapi/status` muestra estado del PAC |
|
||||
| **Subida de CSD** | `invoicing_bp.py`, `invoicing.html`, `invoicing.js` | Upload de `.cer` y `.key` con contraseña directo a Facturapi |
|
||||
| **Migración de datos** | `v4.3_facturapi.sql`, `scripts/apply_facturapi_to_all_tenants.py` | Renombra `xml_unsigned` → `payload_unsigned`, agrega `external_id`, inserta keys de config |
|
||||
| **Setup masivo** | `scripts/setup_facturapi_orgs.py` | Crea organizaciones Facturapi para todos los tenants activos usando `FACTURAPI_USER_KEY` |
|
||||
| **Status masivo** | `scripts/check_facturapi_tenants.py` | Reporte tabular/JSON/CSV del estado de configuración Facturapi por tenant |
|
||||
| **Tests unitarios** | `pos/tests/test_facturapi_service.py` | 20 tests con mocks; sin llamadas a red ni PostgreSQL |
|
||||
| **CI** | `.github/workflows/ci.yml` | Lint con ruff sobre archivos cambiados + tests de consola en Python 3.11 y 3.13 |
|
||||
|
||||
### Variables de entorno
|
||||
|
||||
```bash
|
||||
# Modo automático (recomendado para multi-tenant)
|
||||
FACTURAPI_USER_KEY=sk_user_xxxxxxxxxxxxxxxx
|
||||
|
||||
# Modo manual por tenant (sobreescribe lo anterior)
|
||||
# Se almacena en tenant_config: cfdi_facturapi_key, cfdi_facturapi_org_id
|
||||
```
|
||||
|
||||
### Uso
|
||||
|
||||
```bash
|
||||
# 1. Aplicar migración y key a todos los tenants
|
||||
export FACTURAPI_SECRET_KEY=sk_user_xxx
|
||||
python3 scripts/apply_facturapi_to_all_tenants.py
|
||||
|
||||
# 2. Crear organizaciones Facturapi
|
||||
export FACTURAPI_USER_KEY=sk_user_xxx
|
||||
python3 scripts/setup_facturapi_orgs.py
|
||||
|
||||
# 3. Ver estado
|
||||
python3 scripts/check_facturapi_tenants.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## FASE 9: Módulo de Taller (Workshop Lite)
|
||||
|
||||
**Commit:** (en progreso)
|
||||
**Migración DB:** `v4.4_workshop.sql`
|
||||
|
||||
| Feature | Archivos | Capacidades |
|
||||
|---------|----------|-------------|
|
||||
| **Migración DB** | `v4.4_workshop.sql` | `service_orders.sale_id`, tabla `service_catalog`, columna `reserved_quantity`, tipos `SO_RESERVE`/`SO_RELEASE` en `inventory_operations` |
|
||||
| **Reserva de inventario** | `service_order_engine.py` | `reserve_item()` y `release_item()` para apartar/liberar refacciones del stock de la sucursal |
|
||||
| **Conversión a venta** | `service_order_engine.py` | `convert_to_sale()` crea una venta en `sales` con refacciones + mano de obra, descuenta inventario y guarda `sale_id` |
|
||||
| **Catálogo de servicios** | `service_order_engine.py`, `service_order_bp.py` | Conceptos reutilizables de mano de obra (ej. "Cambio de aceite") |
|
||||
| **Endpoints taller** | `service_order_bp.py` | `POST /:id/items/:item_id/reserve`, `POST /:id/convert-to-sale`, `PUT /:id/assign-mechanic`, CRUD `/service-catalog` |
|
||||
| **Interfaz Kanban** | `workshop.html`, `workshop.js`, `workshop.css` | Vista por columnas, tarjetas de orden, modal de detalle, cambio de estado, agregar refacciones/mano de obra |
|
||||
| **Impresión de orden** | `thermal_printer.py`, `service_order_bp.py`, `printer.js`, `workshop.js` | Ticket ESC/POS optimizado para impresoras térmicas 80 mm (58 mm compatible) |
|
||||
| **Navegación** | `sidebar.js`, plantillas inline | Entrada "Taller" en el menú de gestión |
|
||||
| **Tests** | `pos/tests/test_service_order_integration.py` | 11 tests con cursores mocks; validan reserva, liberación, conversión a venta y catálogo |
|
||||
|
||||
### Flujo de uso
|
||||
|
||||
1. El paquetero crea la orden desde `/pos/workshop` (cliente, vehículo, mecánico, falla).
|
||||
2. El mecánico diagnostica y agrega refacciones y mano deobra.
|
||||
3. Se reservan las refacciones del inventario de la sucursal.
|
||||
4. Cuando el vehículo está listo, se convierte la orden en venta.
|
||||
5. Desde facturación se timbra el CFDI de la venta generada.
|
||||
|
||||
---
|
||||
|
||||
@@ -215,7 +333,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`) |
|
||||
| 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`) |
|
||||
| 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
|
||||
|
||||
|
||||
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`
|
||||
@@ -58,3 +58,24 @@ def 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
|
||||
|
||||
@@ -311,6 +311,56 @@ def get_tenant_login_url(subdomain):
|
||||
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()
|
||||
|
||||
@@ -661,3 +661,42 @@ body {
|
||||
.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);
|
||||
}
|
||||
|
||||
@@ -188,6 +188,7 @@ async function loadDemos() {
|
||||
<td><a href="https://${escapeHtml(d.subdomain)}.nexusautoparts.com.mx/pos/login" target="_blank" style="color:var(--accent)">${escapeHtml(d.subdomain)}</a></td>
|
||||
<td>${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>
|
||||
@@ -254,6 +255,7 @@ async function loadTenants(withStats = false) {
|
||||
<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>
|
||||
@@ -475,5 +477,60 @@ 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);
|
||||
|
||||
@@ -28,6 +28,10 @@ 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
|
||||
|
||||
@@ -316,6 +316,63 @@
|
||||
</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>
|
||||
|
||||
|
||||
@@ -19,5 +19,8 @@
|
||||
"type": "commonjs",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.59.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"playwright": "^1.60.0"
|
||||
}
|
||||
}
|
||||
|
||||
32
pos/app.py
32
pos/app.py
@@ -1,6 +1,7 @@
|
||||
from flask import Flask
|
||||
from json_provider import OrjsonProvider
|
||||
|
||||
|
||||
def create_app():
|
||||
app = Flask(__name__)
|
||||
app.json = OrjsonProvider(app)
|
||||
@@ -59,6 +60,12 @@ def create_app():
|
||||
from blueprints.marketplace_bp import 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
|
||||
app.register_blueprint(peer_bp)
|
||||
|
||||
@@ -107,6 +114,9 @@ def create_app():
|
||||
from blueprints.supplier_portal_bp import 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)
|
||||
|
||||
@@ -115,7 +125,7 @@ def create_app():
|
||||
def health():
|
||||
return {'status': 'ok'}
|
||||
|
||||
from flask import render_template, send_from_directory, jsonify, g
|
||||
from flask import g, jsonify, render_template, send_from_directory
|
||||
|
||||
@app.route('/favicon.ico')
|
||||
def favicon():
|
||||
@@ -128,6 +138,10 @@ def create_app():
|
||||
tenant_name=getattr(g, 'tenant_name', 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')
|
||||
def pos_catalog():
|
||||
return render_template('catalog.html')
|
||||
@@ -168,6 +182,10 @@ def create_app():
|
||||
def pos_fleet():
|
||||
return render_template('fleet.html')
|
||||
|
||||
@app.route('/pos/workshop')
|
||||
def pos_workshop():
|
||||
return render_template('workshop.html')
|
||||
|
||||
@app.route('/pos/quotations')
|
||||
def pos_quotations():
|
||||
return render_template('quotations.html')
|
||||
@@ -180,6 +198,18 @@ def create_app():
|
||||
def pos_marketplace():
|
||||
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>')
|
||||
def pos_static(filename):
|
||||
return send_from_directory('static', filename)
|
||||
|
||||
@@ -741,3 +741,45 @@ def close_period():
|
||||
cur.close()
|
||||
conn.close()
|
||||
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
|
||||
|
||||
|
||||
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):
|
||||
"""Helper: open master + tenant connections, call fn, close both.
|
||||
fn receives (master_conn, tenant_conn, branch_id).
|
||||
@@ -71,6 +90,34 @@ def _master_only(fn):
|
||||
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) ───
|
||||
|
||||
@catalog_bp.route('/brands', methods=['GET'])
|
||||
@@ -79,10 +126,11 @@ def brands():
|
||||
from services.catalog_modes import normalize_mode
|
||||
year_id = request.args.get('year_id', type=int)
|
||||
mode = normalize_mode(request.args.get('mode'))
|
||||
def _do(master):
|
||||
data = catalog_service.get_brands(master, year_id=year_id, mode=mode)
|
||||
def _do(master, tenant, branch_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_brands(master, year_id=year_id, mode=mode, mye_ids=mye_ids)
|
||||
return jsonify({'data': data, 'mode': mode})
|
||||
return _master_only(_do)
|
||||
return _with_conns(_do)
|
||||
|
||||
|
||||
@catalog_bp.route('/models', methods=['GET'])
|
||||
@@ -92,22 +140,30 @@ def models():
|
||||
year_id = request.args.get('year_id', type=int)
|
||||
if not brand_id:
|
||||
return jsonify({'error': 'brand_id required'}), 400
|
||||
def _do(master):
|
||||
data = catalog_service.get_models(master, brand_id, year_id=year_id)
|
||||
def _do(master, tenant, branch_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 _master_only(_do)
|
||||
return _with_conns(_do)
|
||||
|
||||
|
||||
@catalog_bp.route('/years', methods=['GET'])
|
||||
@require_auth('catalog.view')
|
||||
def years():
|
||||
model_id = request.args.get('model_id', type=int)
|
||||
if not model_id:
|
||||
model_id_param = request.args.get('model_id', '')
|
||||
if not model_id_param:
|
||||
return jsonify({'error': 'model_id required'}), 400
|
||||
def _do(master):
|
||||
data = catalog_service.get_years(master, model_id)
|
||||
try:
|
||||
model_ids = [int(x) for x in model_id_param.split(',') if x]
|
||||
except ValueError:
|
||||
return jsonify({'error': 'model_id must be a comma-separated list of integers'}), 400
|
||||
if not model_ids:
|
||||
return jsonify({'error': 'model_id required'}), 400
|
||||
def _do(master, tenant, branch_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_ids, mye_ids=mye_ids)
|
||||
return jsonify({'data': data})
|
||||
return _master_only(_do)
|
||||
return _with_conns(_do)
|
||||
|
||||
|
||||
@catalog_bp.route('/years-all', methods=['GET'])
|
||||
@@ -126,14 +182,21 @@ def years_all():
|
||||
@catalog_bp.route('/engines', methods=['GET'])
|
||||
@require_auth('catalog.view')
|
||||
def engines():
|
||||
model_id = request.args.get('model_id', type=int)
|
||||
model_id_param = request.args.get('model_id', '')
|
||||
year_id = request.args.get('year_id', type=int)
|
||||
if not model_id or not year_id:
|
||||
if not model_id_param or not year_id:
|
||||
return jsonify({'error': 'model_id and year_id required'}), 400
|
||||
def _do(master):
|
||||
data = catalog_service.get_engines(master, model_id, year_id)
|
||||
try:
|
||||
model_ids = [int(x) for x in model_id_param.split(',') if x]
|
||||
except ValueError:
|
||||
return jsonify({'error': 'model_id must be a comma-separated list of integers'}), 400
|
||||
if not model_ids:
|
||||
return jsonify({'error': 'model_id required'}), 400
|
||||
def _do(master, tenant, branch_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_ids, year_id, mye_ids=mye_ids)
|
||||
return jsonify({'data': data})
|
||||
return _master_only(_do)
|
||||
return _with_conns(_do)
|
||||
|
||||
|
||||
@catalog_bp.route('/categories', methods=['GET'])
|
||||
@@ -150,13 +213,14 @@ def categories():
|
||||
mode = normalize_mode(request.args.get('mode'))
|
||||
if not mye_id:
|
||||
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':
|
||||
data = catalog_service.get_nexpart_groups_for_vehicle(master, mye_id)
|
||||
data = catalog_service.get_nexpart_groups_for_vehicle(master, mye_id, tenant)
|
||||
else:
|
||||
data = catalog_service.get_categories(master, mye_id)
|
||||
return jsonify({'data': data, 'mode': mode})
|
||||
return _master_only(_do)
|
||||
data = catalog_service.get_categories(master, mye_id, allowed_brands)
|
||||
return jsonify({'data': data, 'mode': mode, 'allowed_brands': allowed_brands or []})
|
||||
return _with_conns(_do)
|
||||
|
||||
|
||||
@catalog_bp.route('/groups', methods=['GET'])
|
||||
@@ -174,17 +238,17 @@ def groups():
|
||||
mode = normalize_mode(request.args.get('mode'))
|
||||
if not mye_id:
|
||||
return jsonify({'error': 'mye_id required'}), 400
|
||||
def _do(master):
|
||||
def _do(master, tenant, branch_id):
|
||||
if mode == 'local':
|
||||
if not category_slug:
|
||||
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:
|
||||
if not category_id:
|
||||
return jsonify({'error': 'category_id required for oem mode'}), 400
|
||||
data = catalog_service.get_groups(master, mye_id, category_id)
|
||||
return jsonify({'data': data, 'mode': mode})
|
||||
return _master_only(_do)
|
||||
return _with_conns(_do)
|
||||
|
||||
|
||||
# ─── Parts with stock enrichment (master + tenant) ───
|
||||
@@ -205,19 +269,19 @@ def part_types():
|
||||
mode = normalize_mode(request.args.get('mode'))
|
||||
if not mye_id:
|
||||
return jsonify({'error': 'mye_id required'}), 400
|
||||
def _do(master):
|
||||
def _do(master, tenant, branch_id):
|
||||
if mode == 'local':
|
||||
if not group_slug or not subgroup_slug:
|
||||
return jsonify({'error': 'group_slug and subgroup_slug required for local mode'}), 400
|
||||
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:
|
||||
if not group_id:
|
||||
return jsonify({'error': 'group_id required for oem mode'}), 400
|
||||
data = catalog_service.get_part_types(master, mye_id, group_id)
|
||||
return jsonify({'data': data, 'mode': mode})
|
||||
return _master_only(_do)
|
||||
return _with_conns(_do)
|
||||
|
||||
|
||||
@catalog_bp.route('/shop-supplies/groups', methods=['GET'])
|
||||
@@ -261,8 +325,8 @@ def shop_supplies_parts():
|
||||
group_slug = request.args.get('group_slug')
|
||||
subgroup_slug = request.args.get('subgroup_slug')
|
||||
part_type_slug = request.args.get('part_type_slug')
|
||||
page = request.args.get('page', 1, type=int)
|
||||
per_page = request.args.get('per_page', 30, type=int)
|
||||
page = max(1, request.args.get('page', 1, type=int) or 1)
|
||||
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:
|
||||
return jsonify({'error': 'group_slug, subgroup_slug, part_type_slug required'}), 400
|
||||
def _do(master, tenant, branch_id):
|
||||
@@ -298,8 +362,8 @@ def parts():
|
||||
nexpart_subgroup = request.args.get('nexpart_subgroup')
|
||||
nexpart_part_type = request.args.get('nexpart_part_type')
|
||||
|
||||
page = request.args.get('page', 1, type=int)
|
||||
per_page = request.args.get('per_page', 30, type=int)
|
||||
page = max(1, request.args.get('page', 1, type=int) or 1)
|
||||
per_page = max(1, min(request.args.get('per_page', 30, type=int) or 30, 100))
|
||||
mode = normalize_mode(request.args.get('mode'))
|
||||
|
||||
if not mye_id:
|
||||
@@ -317,19 +381,34 @@ def parts():
|
||||
return blocked
|
||||
|
||||
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:
|
||||
result = catalog_service.get_parts_for_nexpart_triple(
|
||||
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':
|
||||
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:
|
||||
result = catalog_service.get_parts(
|
||||
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 _with_conns(_do)
|
||||
|
||||
@@ -358,8 +437,11 @@ def search():
|
||||
limit = request.args.get('limit', 50, type=int)
|
||||
mye_id = request.args.get('mye_id', type=int)
|
||||
def _do(master, tenant, branch_id):
|
||||
data = catalog_service.smart_search(master, q, tenant, branch_id, limit, mye_id)
|
||||
return jsonify({'data': data})
|
||||
allowed_brands = _get_allowed_brands(tenant) if tenant else None
|
||||
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)
|
||||
|
||||
|
||||
@@ -635,10 +717,20 @@ def brand_categories():
|
||||
if not brand:
|
||||
return jsonify({'error': 'brand parameter required'}), 400
|
||||
|
||||
def _query(master):
|
||||
def _query(master, tenant, branch_id):
|
||||
cur = master.cursor()
|
||||
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,
|
||||
COALESCE(NULLIF(pc.name_es, ''), pc.name_part_category) as name,
|
||||
pc.slug,
|
||||
@@ -648,20 +740,22 @@ def brand_categories():
|
||||
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
|
||||
{brand_filter}
|
||||
GROUP BY pc.id_part_category, pc.name_part_category, pc.name_es, pc.slug
|
||||
ORDER BY part_count DESC
|
||||
""", (brand,))
|
||||
""", params)
|
||||
rows = cur.fetchall()
|
||||
return jsonify({
|
||||
'brand': brand,
|
||||
'categories': [
|
||||
{'id': r[0], 'name': r[1], 'slug': r[2], 'part_count': r[3]}
|
||||
for r in rows
|
||||
]
|
||||
],
|
||||
'allowed_brands': allowed_brands or []
|
||||
})
|
||||
finally:
|
||||
cur.close()
|
||||
return _master_only(_query)
|
||||
return _with_conns(_query)
|
||||
|
||||
|
||||
@catalog_bp.route('/brand-parts', methods=['GET'])
|
||||
@@ -680,21 +774,110 @@ def brand_parts():
|
||||
def _query(master, tenant, branch_id):
|
||||
cur = master.cursor()
|
||||
try:
|
||||
# Build dynamic filters
|
||||
params = [brand]
|
||||
allowed_brands = _get_allowed_brands(tenant) if tenant else None
|
||||
|
||||
cat_filter = ""
|
||||
search_filter = ""
|
||||
params = [brand]
|
||||
|
||||
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])
|
||||
|
||||
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:
|
||||
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])
|
||||
|
||||
# Get parts from the brand catalog
|
||||
query_params = list(params)
|
||||
cur.execute(f"""
|
||||
SELECT DISTINCT p.id_part, p.oem_part_number,
|
||||
@@ -715,7 +898,7 @@ def brand_parts():
|
||||
part_rows = cur.fetchall()
|
||||
part_ids = [r[0] for r in part_rows]
|
||||
|
||||
# Count total
|
||||
count_params = list(params)
|
||||
cur.execute(f"""
|
||||
SELECT COUNT(DISTINCT p.id_part)
|
||||
FROM part_vehicle_preview pvp
|
||||
@@ -725,15 +908,14 @@ def brand_parts():
|
||||
WHERE pvp.name_brand = %s
|
||||
{cat_filter}
|
||||
{search_filter}
|
||||
""", params)
|
||||
""", count_params)
|
||||
total = cur.fetchone()[0]
|
||||
|
||||
# Enrich with local stock if available
|
||||
local_stock = {}
|
||||
if tenant and part_ids:
|
||||
try:
|
||||
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:
|
||||
pass
|
||||
|
||||
@@ -759,6 +941,7 @@ def brand_parts():
|
||||
'total': total,
|
||||
'limit': limit,
|
||||
'offset': offset,
|
||||
'allowed_brands': []
|
||||
})
|
||||
finally:
|
||||
cur.close()
|
||||
@@ -784,7 +967,8 @@ def mye_parts():
|
||||
def _query(master, tenant, branch_id):
|
||||
cur = master.cursor()
|
||||
try:
|
||||
# Build dynamic filters
|
||||
allowed_brands = _get_allowed_brands(tenant) if tenant else None
|
||||
|
||||
cat_filter = ""
|
||||
search_filter = ""
|
||||
params = [mye_id]
|
||||
@@ -793,12 +977,103 @@ def mye_parts():
|
||||
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])
|
||||
|
||||
# Get parts
|
||||
query_params = list(params)
|
||||
cur.execute(f"""
|
||||
SELECT DISTINCT p.id_part, p.oem_part_number,
|
||||
@@ -819,7 +1094,7 @@ def mye_parts():
|
||||
part_rows = cur.fetchall()
|
||||
part_ids = [r[0] for r in part_rows]
|
||||
|
||||
# Count total
|
||||
count_params = list(params)
|
||||
cur.execute(f"""
|
||||
SELECT COUNT(DISTINCT p.id_part)
|
||||
FROM vehicle_parts vp
|
||||
@@ -829,15 +1104,14 @@ def mye_parts():
|
||||
WHERE vp.model_year_engine_id = %s
|
||||
{cat_filter}
|
||||
{search_filter}
|
||||
""", params)
|
||||
""", count_params)
|
||||
total = cur.fetchone()[0]
|
||||
|
||||
# Enrich with local stock if available
|
||||
local_stock = {}
|
||||
if tenant and part_ids:
|
||||
try:
|
||||
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:
|
||||
pass
|
||||
|
||||
@@ -863,6 +1137,7 @@ def mye_parts():
|
||||
'total': total,
|
||||
'limit': limit,
|
||||
'offset': offset,
|
||||
'allowed_brands': []
|
||||
})
|
||||
finally:
|
||||
cur.close()
|
||||
|
||||
@@ -13,15 +13,51 @@ config_bp = Blueprint('config', __name__, url_prefix='/pos/api/config')
|
||||
def list_branches():
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
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 = []
|
||||
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()
|
||||
conn.close()
|
||||
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'])
|
||||
@require_auth('config.edit')
|
||||
def create_branch():
|
||||
@@ -47,10 +83,23 @@ def create_branch():
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
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("""
|
||||
INSERT INTO branches (name, address, phone)
|
||||
VALUES (%s, %s, %s) RETURNING id
|
||||
""", (data['name'], data.get('address'), data.get('phone')))
|
||||
INSERT INTO branches (
|
||||
name, address, phone, is_main,
|
||||
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]
|
||||
conn.commit()
|
||||
cur.close()
|
||||
@@ -58,6 +107,49 @@ def create_branch():
|
||||
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'])
|
||||
@require_auth('config.view')
|
||||
def list_employees():
|
||||
@@ -469,21 +561,12 @@ _ALLOWED_PART_BRANDS = [
|
||||
@config_bp.route('/available-brands', methods=['GET'])
|
||||
@require_auth()
|
||||
def get_available_brands():
|
||||
"""Return whitelisted aftermarket manufacturer names from master DB."""
|
||||
from tenant_db import get_master_conn
|
||||
conn = get_master_conn()
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT DISTINCT m.name_manufacture
|
||||
FROM manufacturers m
|
||||
JOIN aftermarket_parts ap ON ap.manufacturer_id = m.id_manufacture
|
||||
WHERE m.name_manufacture IS NOT NULL AND m.name_manufacture != ''
|
||||
AND LOWER(m.name_manufacture) = ANY(%s)
|
||||
ORDER BY m.name_manufacture ASC
|
||||
""", ([b.lower() for b in _ALLOWED_PART_BRANDS],))
|
||||
brands = [r[0] for r in cur.fetchall()]
|
||||
cur.close()
|
||||
conn.close()
|
||||
"""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})
|
||||
|
||||
|
||||
@@ -577,3 +660,88 @@ def update_whatsapp_config():
|
||||
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
|
||||
cur.execute(f"""
|
||||
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.branch_id
|
||||
FROM customers c
|
||||
@@ -64,11 +65,12 @@ def list_customers():
|
||||
for r in cur.fetchall():
|
||||
customers.append({
|
||||
'id': r[0], 'name': r[1], 'rfc': r[2], 'razon_social': r[3],
|
||||
'phone': r[4], 'email': r[5], 'price_tier': r[6],
|
||||
'credit_limit': float(r[7]) if r[7] else 0,
|
||||
'credit_balance': float(r[8]) if r[8] else 0,
|
||||
'vehicle_info': r[9],
|
||||
'branch_id': r[10],
|
||||
'phone': r[4], 'email': r[5], 'address': r[6], 'cp': r[7],
|
||||
'price_tier': r[8],
|
||||
'credit_limit': float(r[9]) if r[9] else 0,
|
||||
'credit_balance': float(r[10]) if r[10] else 0,
|
||||
'vehicle_info': r[11],
|
||||
'branch_id': r[12],
|
||||
})
|
||||
|
||||
cur.close()
|
||||
@@ -91,7 +93,7 @@ def get_customer(customer_id):
|
||||
cur.execute("""
|
||||
SELECT id, branch_id, name, rfc, razon_social, regimen_fiscal, uso_cfdi,
|
||||
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
|
||||
""", (customer_id,))
|
||||
row = cur.fetchone()
|
||||
@@ -103,7 +105,7 @@ def get_customer(customer_id):
|
||||
customer = dict(zip(cols, row))
|
||||
|
||||
# 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:
|
||||
customer[k] = float(customer[k])
|
||||
|
||||
@@ -159,8 +161,9 @@ def create_customer():
|
||||
cur.execute("""
|
||||
INSERT INTO customers
|
||||
(branch_id, name, rfc, razon_social, regimen_fiscal, uso_cfdi,
|
||||
cp, email, phone, address, price_tier, credit_limit, vehicle_info)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||
cp, email, phone, address, price_tier, credit_limit,
|
||||
max_discount_pct, vehicle_info)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||
RETURNING id
|
||||
""", (
|
||||
branch_id, data['name'], data.get('rfc'), data.get('razon_social'),
|
||||
@@ -168,6 +171,7 @@ def create_customer():
|
||||
data.get('cp'), data.get('email'), data.get('phone'),
|
||||
data.get('address'), data.get('price_tier', 1),
|
||||
data.get('credit_limit', 0),
|
||||
data.get('max_discount_pct', 0),
|
||||
json.dumps(data['vehicle_info']) if data.get('vehicle_info') else None
|
||||
))
|
||||
customer_id = cur.fetchone()[0]
|
||||
@@ -213,7 +217,7 @@ def update_customer(customer_id):
|
||||
# Build dynamic update
|
||||
allowed = ['name', 'rfc', 'razon_social', 'regimen_fiscal', 'uso_cfdi',
|
||||
'cp', 'email', 'phone', 'address', 'price_tier', 'credit_limit',
|
||||
'vehicle_info', 'is_active', 'branch_id']
|
||||
'max_discount_pct', 'vehicle_info', 'is_active', 'branch_id']
|
||||
sets = []
|
||||
vals = []
|
||||
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 tenant_db import get_tenant_conn
|
||||
|
||||
|
||||
class DecimalEncoder(json.JSONEncoder):
|
||||
@@ -25,83 +26,95 @@ class DecimalEncoder(json.JSONEncoder):
|
||||
@require_auth()
|
||||
def get_stats():
|
||||
"""Summary stats for today and this month."""
|
||||
from tenant_db import get_tenant_db
|
||||
db = get_tenant_db()
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
today = datetime.utcnow().date()
|
||||
month_start = today.replace(day=1)
|
||||
|
||||
try:
|
||||
# Sales today
|
||||
today_sales = db.execute(
|
||||
cur.execute(
|
||||
"""SELECT COUNT(*) as count, COALESCE(SUM(total), 0) as total
|
||||
FROM sales WHERE DATE(created_at) = %s""", (today,)
|
||||
).fetchone()
|
||||
)
|
||||
today_sales = cur.fetchone()
|
||||
|
||||
# Sales this month
|
||||
month_sales = db.execute(
|
||||
cur.execute(
|
||||
"""SELECT COUNT(*) as count, COALESCE(SUM(total), 0) as total
|
||||
FROM sales WHERE DATE(created_at) >= %s""", (month_start,)
|
||||
).fetchone()
|
||||
)
|
||||
month_sales = cur.fetchone()
|
||||
|
||||
# Top 5 products today
|
||||
top_products = db.execute(
|
||||
"""SELECT p.name, SUM(si.quantity) as qty, SUM(si.total) as revenue
|
||||
cur.execute(
|
||||
"""SELECT si.name, SUM(si.quantity) as qty, SUM(si.subtotal) as revenue
|
||||
FROM sale_items si
|
||||
JOIN sales s ON si.sale_id = s.id_sale
|
||||
JOIN parts p ON si.part_id = p.id_part
|
||||
JOIN sales s ON si.sale_id = s.id
|
||||
WHERE DATE(s.created_at) = %s
|
||||
GROUP BY p.name
|
||||
GROUP BY si.name
|
||||
ORDER BY revenue DESC
|
||||
LIMIT 5""", (today,)
|
||||
).fetchall()
|
||||
)
|
||||
top_products = cur.fetchall()
|
||||
|
||||
# Hourly sales today (0-23)
|
||||
hourly = db.execute(
|
||||
cur.execute(
|
||||
"""SELECT EXTRACT(HOUR FROM created_at)::int as hour,
|
||||
COUNT(*) as count, COALESCE(SUM(total), 0) as total
|
||||
FROM sales WHERE DATE(created_at) = %s
|
||||
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({
|
||||
'today': {
|
||||
'sales_count': today_sales['count'],
|
||||
'sales_total': today_sales['total'],
|
||||
'sales_count': today_sales[0],
|
||||
'sales_total': float(today_sales[1]) if today_sales[1] is not None else 0,
|
||||
},
|
||||
'month': {
|
||||
'sales_count': month_sales['count'],
|
||||
'sales_total': month_sales['total'],
|
||||
'sales_count': month_sales[0],
|
||||
'sales_total': float(month_sales[1]) if month_sales[1] is not None else 0,
|
||||
},
|
||||
'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
|
||||
],
|
||||
'hourly_sales': [
|
||||
{'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)
|
||||
],
|
||||
}, cls=DecimalEncoder)
|
||||
})
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
@dashboard_stats_bp.route('/stats/employees', methods=['GET'])
|
||||
@require_auth()
|
||||
def get_employee_stats():
|
||||
"""Sales per employee today."""
|
||||
from tenant_db import get_tenant_db
|
||||
db = get_tenant_db()
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
today = datetime.utcnow().date()
|
||||
rows = db.execute(
|
||||
"""SELECT e.name, COUNT(s.id_sale) as sales, COALESCE(SUM(s.total), 0) as total
|
||||
try:
|
||||
cur.execute(
|
||||
"""SELECT e.name, COUNT(s.id) as sales, COALESCE(SUM(s.total), 0) as total
|
||||
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
|
||||
GROUP BY e.name
|
||||
ORDER BY total DESC""", (today,)
|
||||
).fetchall()
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
return jsonify({
|
||||
'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
|
||||
]
|
||||
}, 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()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,89 +5,133 @@ All CFDI business logic lives in services (cfdi_builder, cfdi_queue).
|
||||
This blueprint is the HTTP layer that validates input and returns JSON.
|
||||
"""
|
||||
|
||||
import json
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
import base64
|
||||
from datetime import datetime
|
||||
|
||||
from flask import Blueprint, g, jsonify, request
|
||||
from middleware import require_auth
|
||||
from tenant_db import get_tenant_conn
|
||||
from services.cfdi_builder import build_ingreso_xml, build_egreso_xml, build_pago_xml
|
||||
from services.cfdi_queue import (
|
||||
enqueue_cfdi, process_queue, retry_failed,
|
||||
cancel_cfdi, get_queue_status,
|
||||
)
|
||||
from services import facturapi_service
|
||||
from services.audit import log_action
|
||||
from services.cfdi_facturapi_builder import (
|
||||
build_egreso_payload,
|
||||
build_ingreso_payload,
|
||||
)
|
||||
from services.cfdi_queue import (
|
||||
cancel_cfdi,
|
||||
enqueue_cfdi,
|
||||
get_queue_status,
|
||||
process_queue,
|
||||
retry_failed,
|
||||
)
|
||||
from tenant_db import get_tenant_conn
|
||||
|
||||
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):
|
||||
"""Load tenant CFDI configuration from tenant_config table.
|
||||
def _get_issuer_config(cur, branch_id=None):
|
||||
"""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 = {}
|
||||
cur.execute("SELECT key, value FROM tenant_config WHERE key LIKE 'cfdi_%' OR key LIKE 'tenant_%'")
|
||||
for row in cur.fetchall():
|
||||
config[row[0]] = row[1]
|
||||
|
||||
return {
|
||||
'rfc': config.get('tenant_rfc', ''),
|
||||
'razon_social': config.get('tenant_razon_social', ''),
|
||||
'regimen_fiscal': config.get('cfdi_regimen_fiscal', '601'),
|
||||
'cp': config.get('tenant_cp', '00000'),
|
||||
'serie': config.get('cfdi_serie', 'A'),
|
||||
'horux_api_url': config.get('cfdi_horux_api_url', ''),
|
||||
'horux_api_key': config.get('cfdi_horux_api_key', ''),
|
||||
result = {
|
||||
"rfc": config.get("tenant_rfc", ""),
|
||||
"razon_social": config.get("tenant_razon_social", ""),
|
||||
"regimen_fiscal": config.get("cfdi_regimen_fiscal", "601"),
|
||||
"cp": config.get("tenant_cp", "00000"),
|
||||
"serie": config.get("cfdi_serie", "A"),
|
||||
"facturapi_key": config.get("cfdi_facturapi_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):
|
||||
"""Load a sale with its items for CFDI generation."""
|
||||
cur.execute("""
|
||||
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,))
|
||||
""",
|
||||
(sale_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
|
||||
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]),
|
||||
"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]),
|
||||
}
|
||||
|
||||
cur.execute("""
|
||||
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,))
|
||||
""",
|
||||
(sale_id,),
|
||||
)
|
||||
|
||||
sale['items'] = []
|
||||
sale["items"] = []
|
||||
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',
|
||||
})
|
||||
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",
|
||||
}
|
||||
)
|
||||
|
||||
return sale
|
||||
|
||||
@@ -96,24 +140,32 @@ def _get_customer(cur, customer_id):
|
||||
"""Load customer data for CFDI receptor."""
|
||||
if not customer_id:
|
||||
return None
|
||||
cur.execute("""
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, name, rfc, razon_social, regimen_fiscal, uso_cfdi, cp
|
||||
FROM customers WHERE id = %s
|
||||
""", (customer_id,))
|
||||
""",
|
||||
(customer_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
'id': row[0], 'name': row[1], 'rfc': row[2],
|
||||
'razon_social': row[3], 'regimen_fiscal': row[4],
|
||||
'uso_cfdi': row[5] or 'G03', 'cp': row[6],
|
||||
"id": row[0],
|
||||
"name": row[1],
|
||||
"rfc": row[2],
|
||||
"razon_social": row[3],
|
||||
"regimen_fiscal": row[4],
|
||||
"uso_cfdi": row[5] or "G03",
|
||||
"cp": row[6],
|
||||
}
|
||||
|
||||
|
||||
# ─── Generate CFDI ─────────────────────────────────
|
||||
|
||||
@invoicing_bp.route('/invoice', methods=['POST'])
|
||||
@require_auth('invoicing.create')
|
||||
|
||||
@invoicing_bp.route("/invoice", methods=["POST"])
|
||||
@require_auth("invoicing.create")
|
||||
def generate_invoice():
|
||||
"""Generate a CFDI for a sale and enqueue for timbrado.
|
||||
|
||||
@@ -124,57 +176,66 @@ def generate_invoice():
|
||||
}
|
||||
"""
|
||||
data = request.get_json() or {}
|
||||
sale_id = data.get('sale_id')
|
||||
cfdi_type = data.get('type', 'ingreso')
|
||||
sale_id = data.get("sale_id")
|
||||
cfdi_type = data.get("type", "ingreso")
|
||||
|
||||
if not sale_id:
|
||||
return jsonify({'error': 'sale_id is required'}), 400
|
||||
return jsonify({"error": "sale_id is required"}), 400
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
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)
|
||||
if not sale:
|
||||
return jsonify({'error': 'Sale not found'}), 404
|
||||
return jsonify({"error": "Sale not found"}), 404
|
||||
|
||||
if sale['status'] == 'cancelled':
|
||||
return jsonify({'error': 'Cannot invoice a cancelled sale'}), 400
|
||||
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
|
||||
|
||||
customer = _get_customer(cur, sale.get('customer_id'))
|
||||
if sale["status"] == "cancelled":
|
||||
return jsonify({"error": "Cannot invoice a cancelled sale"}), 400
|
||||
|
||||
customer = _get_customer(cur, sale.get("customer_id"))
|
||||
|
||||
# Check if this sale already has a stamped CFDI
|
||||
cur.execute("""
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, status FROM cfdi_queue
|
||||
WHERE sale_id = %s AND type = %s AND status NOT IN ('cancelled', 'failed')
|
||||
""", (sale_id, cfdi_type))
|
||||
""",
|
||||
(sale_id, cfdi_type),
|
||||
)
|
||||
existing = cur.fetchone()
|
||||
if existing:
|
||||
return jsonify({
|
||||
'error': f'Sale #{sale_id} already has a {cfdi_type} CFDI (queue #{existing[0]}, status: {existing[1]})'
|
||||
}), 409
|
||||
return jsonify(
|
||||
{
|
||||
"error": f"Sale #{sale_id} already has a {cfdi_type} CFDI (queue #{existing[0]}, status: {existing[1]})"
|
||||
}
|
||||
), 409
|
||||
|
||||
# Build XML
|
||||
if cfdi_type == 'ingreso':
|
||||
xml = build_ingreso_xml(sale, tenant_config, customer)
|
||||
elif cfdi_type == 'egreso':
|
||||
original_uuid = data.get('original_uuid')
|
||||
# Build Facturapi payload
|
||||
if cfdi_type == "ingreso":
|
||||
payload = build_ingreso_payload(sale, tenant_config, customer)
|
||||
elif cfdi_type == "egreso":
|
||||
original_uuid = data.get("original_uuid")
|
||||
if not original_uuid:
|
||||
return jsonify({'error': 'original_uuid required for egreso'}), 400
|
||||
xml = build_egreso_xml(sale, tenant_config, customer, original_uuid)
|
||||
return jsonify({"error": "original_uuid required for egreso"}), 400
|
||||
payload = build_egreso_payload(sale, tenant_config, customer, original_uuid)
|
||||
else:
|
||||
return jsonify({'error': f'Invalid CFDI type: {cfdi_type}'}), 400
|
||||
return jsonify({"error": f"Invalid CFDI type: {cfdi_type}"}), 400
|
||||
|
||||
# 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'],
|
||||
new_value={'sale_id': sale_id, 'type': cfdi_type,
|
||||
'folio': result['provisional_folio']})
|
||||
log_action(
|
||||
conn,
|
||||
"CFDI_GENERATED",
|
||||
"cfdi_queue",
|
||||
result["id"],
|
||||
new_value={"sale_id": sale_id, "type": cfdi_type, "folio": result["provisional_folio"]},
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
cur.close()
|
||||
@@ -185,18 +246,19 @@ def generate_invoice():
|
||||
conn.rollback()
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({'error': str(e)}), 400
|
||||
return jsonify({"error": str(e)}), 400
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
# ─── Queue Management ──────────────────────────────
|
||||
|
||||
@invoicing_bp.route('/queue', methods=['GET'])
|
||||
@require_auth('invoicing.view')
|
||||
|
||||
@invoicing_bp.route("/queue", methods=["GET"])
|
||||
@require_auth("invoicing.view")
|
||||
def list_queue():
|
||||
"""List CFDI queue items.
|
||||
|
||||
@@ -205,11 +267,11 @@ def list_queue():
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
|
||||
filters = {
|
||||
'status': request.args.get('status'),
|
||||
'sale_id': request.args.get('sale_id'),
|
||||
'type': request.args.get('type'),
|
||||
'page': request.args.get('page', 1),
|
||||
'per_page': request.args.get('per_page', 50),
|
||||
"status": request.args.get("status"),
|
||||
"sale_id": request.args.get("sale_id"),
|
||||
"type": request.args.get("type"),
|
||||
"page": request.args.get("page", 1),
|
||||
"per_page": request.args.get("per_page", 50),
|
||||
}
|
||||
|
||||
result = get_queue_status(conn, filters)
|
||||
@@ -217,35 +279,46 @@ def list_queue():
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@invoicing_bp.route('/queue/<int:cfdi_id>', methods=['GET'])
|
||||
@require_auth('invoicing.view')
|
||||
@invoicing_bp.route("/queue/<int:cfdi_id>", methods=["GET"])
|
||||
@require_auth("invoicing.view")
|
||||
def get_queue_item(cfdi_id):
|
||||
"""Get CFDI queue item detail (includes XML)."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute("""
|
||||
SELECT q.id, q.sale_id, q.type, q.xml_unsigned, q.xml_signed,
|
||||
cur.execute(
|
||||
"""
|
||||
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.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
|
||||
""", (cfdi_id,))
|
||||
""",
|
||||
(cfdi_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
|
||||
if not row:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'CFDI queue item not found'}), 404
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({"error": "CFDI queue item not found"}), 404
|
||||
|
||||
item = {
|
||||
'id': row[0], 'sale_id': row[1], 'type': row[2],
|
||||
'xml_unsigned': row[3], 'xml_signed': row[4],
|
||||
'uuid_fiscal': row[5], 'status': row[6],
|
||||
'retry_count': row[7], 'provisional_folio': row[8],
|
||||
'error_message': row[9], 'cancel_motive': row[10],
|
||||
'cancel_replacement_uuid': row[11],
|
||||
'created_at': str(row[12]) if row[12] else None,
|
||||
'stamped_at': str(row[13]) if row[13] else None,
|
||||
"id": row[0],
|
||||
"sale_id": row[1],
|
||||
"type": row[2],
|
||||
"payload_unsigned": row[3],
|
||||
"xml_signed": row[4],
|
||||
"uuid_fiscal": row[5],
|
||||
"status": row[6],
|
||||
"retry_count": row[7],
|
||||
"provisional_folio": row[8],
|
||||
"error_message": row[9],
|
||||
"cancel_motive": row[10],
|
||||
"cancel_replacement_uuid": row[11],
|
||||
"created_at": str(row[12]) if row[12] else None,
|
||||
"stamped_at": str(row[13]) if row[13] else None,
|
||||
"external_id": row[14],
|
||||
}
|
||||
|
||||
cur.close()
|
||||
@@ -253,29 +326,26 @@ def get_queue_item(cfdi_id):
|
||||
return jsonify(item)
|
||||
|
||||
|
||||
@invoicing_bp.route('/queue/process', methods=['POST'])
|
||||
@require_auth('invoicing.create')
|
||||
@invoicing_bp.route("/queue/process", methods=["POST"])
|
||||
@require_auth("invoicing.create")
|
||||
def trigger_process_queue():
|
||||
"""Manually trigger processing of pending CFDI queue items."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
tenant_config = _get_tenant_config(cur)
|
||||
horux_url = tenant_config.get('horux_api_url')
|
||||
horux_key = tenant_config.get('horux_api_key')
|
||||
|
||||
if not horux_url or not horux_key:
|
||||
tenant_config = _get_issuer_config(cur)
|
||||
if not tenant_config.get("facturapi_key"):
|
||||
cur.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_count = retry_failed(conn)
|
||||
|
||||
# Process the queue
|
||||
result = process_queue(conn, horux_url, horux_key)
|
||||
result['retries_reset'] = reset_count
|
||||
result = process_queue(conn, tenant_config)
|
||||
result["retries_reset"] = reset_count
|
||||
|
||||
cur.close()
|
||||
conn.close()
|
||||
@@ -285,13 +355,14 @@ def trigger_process_queue():
|
||||
conn.rollback()
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
# ─── Cancel CFDI ────────────────────────────────────
|
||||
|
||||
@invoicing_bp.route('/cancel/<int:cfdi_id>', methods=['POST'])
|
||||
@require_auth('invoicing.delete')
|
||||
|
||||
@invoicing_bp.route("/cancel/<int:cfdi_id>", methods=["POST"])
|
||||
@require_auth("invoicing.delete")
|
||||
def cancel_invoice(cfdi_id):
|
||||
"""Cancel a CFDI with SAT motive code.
|
||||
|
||||
@@ -302,29 +373,36 @@ def cancel_invoice(cfdi_id):
|
||||
|
||||
Only owner and admin can cancel CFDIs.
|
||||
"""
|
||||
if g.employee_role not in ('owner', 'admin'):
|
||||
return jsonify({'error': 'Only owner or admin can cancel CFDIs'}), 403
|
||||
if g.employee_role not in ("owner", "admin"):
|
||||
return jsonify({"error": "Only owner or admin can cancel CFDIs"}), 403
|
||||
|
||||
data = request.get_json() or {}
|
||||
motive = data.get('motive')
|
||||
replacement_uuid = data.get('replacement_uuid')
|
||||
motive = data.get("motive")
|
||||
replacement_uuid = data.get("replacement_uuid")
|
||||
|
||||
if not motive:
|
||||
return jsonify({'error': 'motive is required'}), 400
|
||||
return jsonify({"error": "motive is required"}), 400
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
tenant_config = _get_tenant_config(cur)
|
||||
tenant_config = _get_issuer_config(cur)
|
||||
result = cancel_cfdi(
|
||||
conn, cfdi_id, motive, replacement_uuid,
|
||||
tenant_config.get('horux_api_url'),
|
||||
tenant_config.get('horux_api_key'),
|
||||
conn,
|
||||
cfdi_id,
|
||||
motive,
|
||||
replacement_uuid,
|
||||
tenant_config=tenant_config,
|
||||
)
|
||||
|
||||
log_action(conn, 'CFDI_CANCELLED', 'cfdi_queue', cfdi_id,
|
||||
new_value={'motive': motive, 'replacement_uuid': replacement_uuid})
|
||||
log_action(
|
||||
conn,
|
||||
"CFDI_CANCELLED",
|
||||
"cfdi_queue",
|
||||
cfdi_id,
|
||||
new_value={"motive": motive, "replacement_uuid": replacement_uuid},
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
cur.close()
|
||||
@@ -335,18 +413,19 @@ def cancel_invoice(cfdi_id):
|
||||
conn.rollback()
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({'error': str(e)}), 400
|
||||
return jsonify({"error": str(e)}), 400
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
# ─── PDF Generation ─────────────────────────────────
|
||||
|
||||
@invoicing_bp.route('/<int:sale_id>/pdf', methods=['GET'])
|
||||
@require_auth('invoicing.view')
|
||||
|
||||
@invoicing_bp.route("/<int:sale_id>/pdf", methods=["GET"])
|
||||
@require_auth("invoicing.view")
|
||||
def get_sale_pdf(sale_id):
|
||||
"""Generate a PDF representation of the sale/CFDI.
|
||||
|
||||
@@ -359,41 +438,381 @@ def get_sale_pdf(sale_id):
|
||||
|
||||
sale = _get_sale_with_items(cur, sale_id)
|
||||
if not sale:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Sale not found'}), 404
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({"error": "Sale not found"}), 404
|
||||
|
||||
tenant_config = _get_tenant_config(cur)
|
||||
customer = _get_customer(cur, sale.get('customer_id'))
|
||||
tenant_config = _get_issuer_config(cur, sale.get("branch_id"))
|
||||
customer = _get_customer(cur, sale.get("customer_id"))
|
||||
|
||||
# Check if there's a stamped CFDI
|
||||
cur.execute("""
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT uuid_fiscal, provisional_folio, status, stamped_at
|
||||
FROM cfdi_queue
|
||||
WHERE sale_id = %s AND type = 'ingreso' AND status = 'stamped'
|
||||
ORDER BY stamped_at DESC LIMIT 1
|
||||
""", (sale_id,))
|
||||
""",
|
||||
(sale_id,),
|
||||
)
|
||||
cfdi_row = cur.fetchone()
|
||||
|
||||
cfdi_info = None
|
||||
if cfdi_row:
|
||||
cfdi_info = {
|
||||
'uuid_fiscal': cfdi_row[0],
|
||||
'provisional_folio': cfdi_row[1],
|
||||
'status': cfdi_row[2],
|
||||
'stamped_at': str(cfdi_row[3]) if cfdi_row[3] else None,
|
||||
"uuid_fiscal": cfdi_row[0],
|
||||
"provisional_folio": cfdi_row[1],
|
||||
"status": cfdi_row[2],
|
||||
"stamped_at": str(cfdi_row[3]) if cfdi_row[3] else None,
|
||||
}
|
||||
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
return jsonify({
|
||||
'sale': sale,
|
||||
'tenant': {
|
||||
'rfc': tenant_config.get('rfc', ''),
|
||||
'razon_social': tenant_config.get('razon_social', ''),
|
||||
'regimen_fiscal': tenant_config.get('regimen_fiscal', ''),
|
||||
'cp': tenant_config.get('cp', ''),
|
||||
return jsonify(
|
||||
{
|
||||
"sale": sale,
|
||||
"tenant": {
|
||||
"rfc": tenant_config.get("rfc", ""),
|
||||
"razon_social": tenant_config.get("razon_social", ""),
|
||||
"regimen_fiscal": tenant_config.get("regimen_fiscal", ""),
|
||||
"cp": tenant_config.get("cp", ""),
|
||||
},
|
||||
'customer': customer,
|
||||
'cfdi': cfdi_info,
|
||||
})
|
||||
"customer": customer,
|
||||
"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}"'},
|
||||
)
|
||||
|
||||
|
||||
@invoicing_bp.route("/facturapi/csd", methods=["POST"])
|
||||
@require_auth("invoicing.create")
|
||||
def facturapi_upload_csd():
|
||||
"""Upload CSD (Certificado de Sello Digital) to Facturapi.
|
||||
|
||||
Multipart form with:
|
||||
- certificate: .cer file
|
||||
- private_key: .key file
|
||||
- password: CSD password
|
||||
"""
|
||||
if "certificate" not in request.files or "private_key" not in request.files:
|
||||
return jsonify({"error": "certificate and private_key files are required"}), 400
|
||||
|
||||
password = (request.form.get("password") or "").strip()
|
||||
if not password:
|
||||
return jsonify({"error": "password is required"}), 400
|
||||
|
||||
cer_file = request.files["certificate"]
|
||||
key_file = request.files["private_key"]
|
||||
|
||||
if not cer_file.filename or not key_file.filename:
|
||||
return jsonify({"error": "certificate and private_key files are required"}), 400
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
tenant_config = _get_issuer_config(cur)
|
||||
|
||||
cer_b64 = base64.b64encode(cer_file.read()).decode("ascii")
|
||||
key_b64 = base64.b64encode(key_file.read()).decode("ascii")
|
||||
|
||||
result = facturapi_service.upload_csd(tenant_config, cer_b64, key_b64, password)
|
||||
|
||||
log_action(
|
||||
conn,
|
||||
"FACTURAPI_CSD_UPLOAD",
|
||||
"tenant_config",
|
||||
None,
|
||||
new_value={"org_id": tenant_config.get("facturapi_org_id")},
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"success": True,
|
||||
"message": "CSD uploaded successfully",
|
||||
"certificate": result.get("certificate"),
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@@ -190,6 +190,16 @@ def bodegas_with_part(part_id):
|
||||
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
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
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,
|
||||
get_price_for_customer, get_margin_info
|
||||
)
|
||||
from services.inventory_engine import get_stock
|
||||
from services.audit import log_action
|
||||
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
|
||||
cur.execute("""
|
||||
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
|
||||
""", (inv_ids,))
|
||||
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,
|
||||
'discount_pct': discount_pct,
|
||||
'tax_rate': tax_rate,
|
||||
'branch_id': inv[8],
|
||||
})
|
||||
return enriched
|
||||
|
||||
@@ -103,6 +103,19 @@ def create_sale():
|
||||
data = request.get_json() or {}
|
||||
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:
|
||||
sale = process_sale(conn, data)
|
||||
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'])
|
||||
@require_auth('pos.view')
|
||||
def get_sale(sale_id):
|
||||
@@ -1864,6 +1954,14 @@ def complete_layaway(layaway_id):
|
||||
new_value={'sale_id': sale['id'], 'total': total})
|
||||
|
||||
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()
|
||||
return jsonify(sale), 201
|
||||
|
||||
|
||||
@@ -3,15 +3,31 @@
|
||||
Prefix: /pos/api/service-orders
|
||||
"""
|
||||
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from flask import Blueprint, g, jsonify, request
|
||||
from middleware import require_auth
|
||||
from tenant_db import get_tenant_conn
|
||||
from services.service_order_engine import (
|
||||
create_service_order, get_service_order, list_service_orders,
|
||||
update_status, add_item, update_item, remove_item,
|
||||
add_labor, update_labor, remove_labor,
|
||||
update_service_order, get_kanban_summary,
|
||||
add_item,
|
||||
add_labor,
|
||||
assign_mechanic,
|
||||
convert_to_sale,
|
||||
create_service_catalog_item,
|
||||
create_service_order,
|
||||
delete_service_catalog_item,
|
||||
get_kanban_summary,
|
||||
get_service_order,
|
||||
list_service_catalog,
|
||||
list_service_orders,
|
||||
release_item,
|
||||
remove_item,
|
||||
remove_labor,
|
||||
reserve_item,
|
||||
update_item,
|
||||
update_labor,
|
||||
update_service_catalog_item,
|
||||
update_service_order,
|
||||
update_status,
|
||||
)
|
||||
from tenant_db import get_tenant_conn
|
||||
|
||||
service_order_bp = Blueprint('service_orders', __name__, url_prefix='/pos/api/service-orders')
|
||||
|
||||
@@ -202,3 +218,212 @@ def kanban_summary():
|
||||
return jsonify(summary)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ─── Inventory reservation ────────────────────────
|
||||
|
||||
|
||||
@service_order_bp.route('/<int:so_id>/items/<int:item_id>/reserve', methods=['POST'])
|
||||
@require_auth()
|
||||
def reserve_order_item(so_id, item_id):
|
||||
"""Reserve inventory for a service order item."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = reserve_item(conn, item_id, branch_id=g.branch_id, employee_id=g.employee_id)
|
||||
return jsonify(result)
|
||||
except ValueError as e:
|
||||
return jsonify({'error': str(e)}), 400
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@service_order_bp.route('/<int:so_id>/items/<int:item_id>/release', methods=['POST'])
|
||||
@require_auth()
|
||||
def release_order_item(so_id, item_id):
|
||||
"""Release a previous inventory reservation."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = release_item(conn, item_id, employee_id=g.employee_id)
|
||||
return jsonify(result)
|
||||
except ValueError as e:
|
||||
return jsonify({'error': str(e)}), 400
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ─── Convert to sale ──────────────────────────────
|
||||
|
||||
|
||||
@service_order_bp.route('/<int:so_id>/convert-to-sale', methods=['POST'])
|
||||
@require_auth('pos.sell')
|
||||
def convert_order_to_sale(so_id):
|
||||
"""Convert a service order into a POS sale.
|
||||
|
||||
Body: {
|
||||
payment_method: 'efectivo' | 'transferencia' | 'tarjeta' | 'mixto',
|
||||
sale_type: 'cash' | 'credit' | 'mixed',
|
||||
register_id: int (optional),
|
||||
amount_paid: float (optional),
|
||||
payment_details: [...] (optional),
|
||||
notes: str (optional)
|
||||
}
|
||||
"""
|
||||
data = request.get_json() or {}
|
||||
sale_payload = {
|
||||
'payment_method': data.get('payment_method', 'efectivo'),
|
||||
'sale_type': data.get('sale_type', 'cash'),
|
||||
'register_id': data.get('register_id'),
|
||||
'amount_paid': data.get('amount_paid'),
|
||||
'payment_details': data.get('payment_details', []),
|
||||
'notes': data.get('notes'),
|
||||
}
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = convert_to_sale(
|
||||
conn, so_id, sale_payload, employee_id=g.employee_id
|
||||
)
|
||||
return jsonify(result), 201
|
||||
except ValueError as e:
|
||||
return jsonify({'error': str(e)}), 400
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ─── Mechanic assignment ──────────────────────────
|
||||
|
||||
|
||||
@service_order_bp.route('/<int:so_id>/assign-mechanic', methods=['PUT'])
|
||||
@require_auth()
|
||||
def assign_mechanic_endpoint(so_id):
|
||||
"""Assign a mechanic/technician to a service order."""
|
||||
data = request.get_json() or {}
|
||||
employee_id = data.get('employee_id')
|
||||
if not employee_id:
|
||||
return jsonify({'error': 'employee_id is required'}), 400
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = assign_mechanic(conn, so_id, employee_id)
|
||||
return jsonify(result)
|
||||
except ValueError as e:
|
||||
return jsonify({'error': str(e)}), 400
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ─── Service catalog (reusable labor) ─────────────
|
||||
|
||||
|
||||
@service_order_bp.route('/service-catalog', methods=['GET'])
|
||||
@require_auth()
|
||||
def list_catalog():
|
||||
"""List reusable labor/service concepts."""
|
||||
active_only = request.args.get('active_only', 'true').lower() != 'false'
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
items = list_service_catalog(conn, active_only=active_only)
|
||||
return jsonify({'data': items})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@service_order_bp.route('/service-catalog', methods=['POST'])
|
||||
@require_auth()
|
||||
def create_catalog_item():
|
||||
"""Create a reusable labor concept."""
|
||||
data = request.get_json() or {}
|
||||
if not data.get('name'):
|
||||
return jsonify({'error': 'name is required'}), 400
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = create_service_catalog_item(conn, g.tenant_id, data)
|
||||
return jsonify(result), 201
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@service_order_bp.route('/service-catalog/<int:item_id>', methods=['PUT'])
|
||||
@require_auth()
|
||||
def update_catalog_item(item_id):
|
||||
"""Update a reusable labor concept."""
|
||||
data = request.get_json() or {}
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
ok = update_service_catalog_item(conn, item_id, data)
|
||||
if not ok:
|
||||
return jsonify({'error': 'No fields to update'}), 400
|
||||
return jsonify({'message': 'Catalog item updated'})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@service_order_bp.route('/service-catalog/<int:item_id>', methods=['DELETE'])
|
||||
@require_auth()
|
||||
def delete_catalog_item(item_id):
|
||||
"""Soft-delete a reusable labor concept."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
delete_service_catalog_item(conn, item_id)
|
||||
return jsonify({'message': 'Catalog item deactivated'})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ─── Thermal printing ─────────────────────────────
|
||||
|
||||
|
||||
@service_order_bp.route('/<int:so_id>/print', methods=['POST'])
|
||||
@require_auth()
|
||||
def print_service_order_ticket(so_id):
|
||||
"""Generate a printable ticket for a service order.
|
||||
|
||||
Body (optional): {printer_type: 'escpos_raw' | 'browser', width: 58 | 80}
|
||||
- escpos_raw: returns raw ESC/POS bytes (application/octet-stream)
|
||||
- browser: returns the data dict as JSON for browser-side rendering
|
||||
"""
|
||||
from flask import Response
|
||||
from services.thermal_printer import generate_service_order_ticket
|
||||
|
||||
body = request.get_json(silent=True) or {}
|
||||
printer_type = body.get('printer_type', 'escpos_raw')
|
||||
width = int(body.get('width', 80))
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
order = get_service_order(conn, so_id)
|
||||
if not order:
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({'error': 'Service order not found'}), 404
|
||||
|
||||
# Fetch business info from config
|
||||
business_info = {'name': 'NEXUS AUTOPARTS', 'rfc': '', 'address': ''}
|
||||
try:
|
||||
cur.execute(
|
||||
"SELECT key, value FROM config WHERE key IN ('business_name','rfc','address')"
|
||||
)
|
||||
for rw in cur.fetchall():
|
||||
if rw[0] == 'business_name':
|
||||
business_info['name'] = rw[1]
|
||||
else:
|
||||
business_info[rw[0]] = rw[1]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
if printer_type == 'browser':
|
||||
return jsonify(order)
|
||||
|
||||
raw = generate_service_order_ticket(order, business_info, width=width)
|
||||
return Response(
|
||||
raw,
|
||||
mimetype='application/octet-stream',
|
||||
headers={
|
||||
'Content-Disposition': f'attachment; filename=orden_{order.get("order_number", so_id)}.bin'
|
||||
},
|
||||
)
|
||||
|
||||
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 tenant_db import get_tenant_conn
|
||||
|
||||
|
||||
class DecimalEncoder(json.JSONEncoder):
|
||||
@@ -26,48 +27,47 @@ class DecimalEncoder(json.JSONEncoder):
|
||||
def get_demand():
|
||||
"""Aggregated demand by zone, part group, and time range."""
|
||||
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)
|
||||
|
||||
from tenant_db import get_tenant_db
|
||||
db = get_tenant_db()
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
since = datetime.utcnow() - timedelta(days=days)
|
||||
|
||||
try:
|
||||
params = [since]
|
||||
filters = "s.created_at >= %s"
|
||||
if group_id:
|
||||
filters += " AND p.group_id = %s"
|
||||
params.append(group_id)
|
||||
if branch_id:
|
||||
filters += " AND s.branch_id = %s"
|
||||
params.append(branch_id)
|
||||
|
||||
rows = db.execute(
|
||||
f"""SELECT g.name as group_name, b.name as branch_name,
|
||||
COUNT(DISTINCT s.id_sale) as orders,
|
||||
cur.execute(
|
||||
f"""SELECT b.name as branch_name,
|
||||
COUNT(DISTINCT s.id) as orders,
|
||||
SUM(si.quantity) as qty_requested,
|
||||
COALESCE(SUM(si.total), 0) as revenue
|
||||
COALESCE(SUM(si.subtotal), 0) as revenue
|
||||
FROM sale_items si
|
||||
JOIN sales s ON si.sale_id = s.id_sale
|
||||
JOIN parts p ON si.part_id = p.id_part
|
||||
JOIN part_groups g ON p.group_id = g.id_group
|
||||
LEFT JOIN branches b ON s.branch_id = b.id_branch
|
||||
JOIN sales s ON si.sale_id = s.id
|
||||
LEFT JOIN branches b ON s.branch_id = b.id
|
||||
WHERE {filters}
|
||||
GROUP BY g.name, b.name
|
||||
GROUP BY b.name
|
||||
ORDER BY revenue DESC
|
||||
LIMIT 100""", tuple(params)
|
||||
).fetchall()
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
|
||||
return jsonify({
|
||||
'since': since.isoformat(),
|
||||
'days': days,
|
||||
'demand': [
|
||||
{'group': row['group_name'], 'branch': row['branch_name'],
|
||||
'orders': row['orders'], 'quantity': row['qty_requested'],
|
||||
'revenue': row['revenue']}
|
||||
{'branch': row[0] or 'Sin sucursal',
|
||||
'orders': row[1], 'quantity': row[2],
|
||||
'revenue': float(row[3]) if row[3] is not None else 0}
|
||||
for row in rows
|
||||
]
|
||||
}, cls=DecimalEncoder)
|
||||
})
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
@supplier_portal_bp.route('/top-parts', methods=['GET'])
|
||||
@@ -75,31 +75,31 @@ def get_demand():
|
||||
def get_top_parts():
|
||||
"""Top moving parts for suppliers to restock."""
|
||||
days = request.args.get('days', 30, type=int)
|
||||
from tenant_db import get_tenant_db
|
||||
db = get_tenant_db()
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
since = datetime.utcnow() - timedelta(days=days)
|
||||
|
||||
rows = db.execute(
|
||||
"""SELECT p.oem_part_number, p.name, g.name as group_name,
|
||||
SUM(si.quantity) as sold, COALESCE(SUM(si.total), 0) as revenue,
|
||||
COALESCE(SUM(wi.stock_quantity), 0) as current_stock
|
||||
try:
|
||||
cur.execute(
|
||||
"""SELECT si.part_number, si.name,
|
||||
SUM(si.quantity) as sold, COALESCE(SUM(si.subtotal), 0) as revenue
|
||||
FROM sale_items si
|
||||
JOIN sales s ON si.sale_id = s.id_sale
|
||||
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
|
||||
JOIN sales s ON si.sale_id = s.id
|
||||
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
|
||||
LIMIT 50""", (since,)
|
||||
).fetchall()
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
|
||||
return jsonify({
|
||||
'since': since.isoformat(),
|
||||
'parts': [
|
||||
{'oem': row['oem_part_number'], 'name': row['name'],
|
||||
'group': row['group_name'], 'sold': row['sold'],
|
||||
'revenue': row['revenue'], 'stock': row['current_stock']}
|
||||
{'part_number': row[0], 'name': row[1],
|
||||
'sold': row[2], 'revenue': float(row[3]) if row[3] is not None else 0}
|
||||
for row in rows
|
||||
]
|
||||
}, cls=DecimalEncoder)
|
||||
})
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
@@ -15,25 +15,63 @@ from flask import Blueprint, request, jsonify, g
|
||||
from middleware import require_auth
|
||||
from tenant_db import get_tenant_conn, get_master_conn
|
||||
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')
|
||||
|
||||
|
||||
def _get_whatsapp_config(conn):
|
||||
"""Read WhatsApp bridge configuration from tenant_config.
|
||||
Returns dict with bridge_url, enabled, etc."""
|
||||
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': config.get('whatsapp_bridge_url', ''),
|
||||
'bridge_key': config.get('whatsapp_bridge_key', ''),
|
||||
'enabled': config.get('whatsapp_enabled', 'false').lower() == 'true',
|
||||
'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):
|
||||
"""Return list of MYE ids matching vehicle brand/model/year text."""
|
||||
if not master_conn or not vehicle:
|
||||
@@ -194,27 +232,9 @@ def _enrich_wa_reply_with_part(search_query, vehicle, tenant_conn, master_conn=N
|
||||
fallback_rows = _do_search(use_compat=False)
|
||||
|
||||
if not rows and not fallback_rows:
|
||||
# Truly nothing found — return a conversational message that doesn't kill the chat
|
||||
v_str = ""
|
||||
if vehicle and vehicle.get('brand'):
|
||||
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
|
||||
# Nothing found in local inventory — let the AI's original response stand.
|
||||
# The webhook will append a soft note instead of replacing the message.
|
||||
return None, None
|
||||
|
||||
# Use fallback rows if primary search returned nothing
|
||||
using_fallback = False
|
||||
@@ -331,11 +351,7 @@ def logout():
|
||||
def webhook():
|
||||
"""Receive messages from Baileys bridge (public, no auth).
|
||||
|
||||
Flow:
|
||||
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.
|
||||
Nuevo flujo: máquina de estados estructurada.
|
||||
"""
|
||||
data = request.get_json(force=True, silent=True) or {}
|
||||
|
||||
@@ -346,288 +362,223 @@ def webhook():
|
||||
if not msg.get('phone') or msg.get('from_me'):
|
||||
return jsonify({'ok': True})
|
||||
|
||||
# Resolve tenant: try query param first, then fallback to first enabled tenant
|
||||
tenant_id = request.args.get('tenant_id', type=int)
|
||||
if not tenant_id:
|
||||
# Fallback: find first tenant with whatsapp enabled
|
||||
try:
|
||||
mconn = get_master_conn()
|
||||
mcur = mconn.cursor()
|
||||
mcur.execute("""
|
||||
SELECT t.id FROM tenants t
|
||||
JOIN tenant_config c ON c.key = 'whatsapp_enabled' AND c.value = 'true'
|
||||
WHERE t.is_active = true
|
||||
ORDER BY t.id LIMIT 1
|
||||
""")
|
||||
row = mcur.fetchone()
|
||||
mcur.close()
|
||||
mconn.close()
|
||||
tenant_id = row[0] if row else None
|
||||
except Exception:
|
||||
tenant_id = None
|
||||
|
||||
tenant_conn = None
|
||||
master_conn = None
|
||||
inventory_context = None
|
||||
wa_config = {}
|
||||
try:
|
||||
tenant_conn = get_tenant_conn(tenant_id)
|
||||
master_conn = get_master_conn()
|
||||
wa_config = _get_whatsapp_config(tenant_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']
|
||||
phone = msg['phone']
|
||||
reply_to = msg.get('sender_pn') or msg.get('jid') or phone
|
||||
text = msg.get('text', '')
|
||||
media_kind = msg.get('media_kind', 'text')
|
||||
clean_phone = msg.get('phone', '')
|
||||
|
||||
# ── Check for quotation commands FIRST (before AI) ──
|
||||
if media_kind == 'text' and msg.get('text'):
|
||||
from services.wa_quotation import (
|
||||
detect_quote_intent, get_open_quotation, create_quotation,
|
||||
add_item_to_quotation, get_quotation_detail, format_quotation_wa,
|
||||
clear_quotation, confirm_quotation, get_last_shown_part, set_last_shown_part,
|
||||
)
|
||||
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, bridge_url=wa_config.get('bridge_url'))
|
||||
if tenant_conn:
|
||||
try:
|
||||
cur_save = tenant_conn.cursor()
|
||||
cur_save.execute("INSERT INTO whatsapp_messages (phone, direction, message_text) VALUES (%s, 'outgoing', %s)", (clean_phone, reply))
|
||||
tenant_conn.commit()
|
||||
cur_save.close()
|
||||
except Exception:
|
||||
pass
|
||||
if tenant_conn:
|
||||
try: tenant_conn.close()
|
||||
except Exception: pass
|
||||
return jsonify({'ok': True})
|
||||
|
||||
if intent is not None:
|
||||
# It was a quote command — send reply and skip the AI
|
||||
if reply:
|
||||
result = whatsapp_service.send_message(reply_to, reply, bridge_url=wa_config.get('bridge_url'))
|
||||
if tenant_conn:
|
||||
try:
|
||||
cur_save = tenant_conn.cursor()
|
||||
cur_save.execute("INSERT INTO whatsapp_messages (phone, direction, message_text) VALUES (%s, 'outgoing', %s)", (clean_phone, reply))
|
||||
tenant_conn.commit()
|
||||
cur_save.close()
|
||||
except Exception:
|
||||
pass
|
||||
# Clean up and return early
|
||||
if tenant_conn:
|
||||
try: tenant_conn.close()
|
||||
except Exception: pass
|
||||
return jsonify({'ok': True})
|
||||
|
||||
# Load conversation history so the AI remembers context (vehicle, parts, etc.)
|
||||
conversation_history = []
|
||||
if tenant_conn:
|
||||
conversation_history = _get_conversation_history(clean_phone, tenant_conn, limit=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.
|
||||
# Audio transcription (voice notes)
|
||||
if media_kind == 'audio' and msg.get('media_base64'):
|
||||
try:
|
||||
from services.whisper_local import transcribe_audio_base64
|
||||
transcript = transcribe_audio_base64(
|
||||
msg['media_base64'],
|
||||
mimetype=msg.get('media_mimetype') or 'audio/ogg',
|
||||
)
|
||||
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:
|
||||
print(f"[WA-AI] Voice note transcribed: {transcript[:100]}")
|
||||
from services.ai_chat import chat
|
||||
ai_resp = chat(transcript, conversation_history=conversation_history, inventory_context=inventory_context)
|
||||
reply = ai_resp.get('message', '') or ''
|
||||
# Prefix the reply so the sender knows we understood the voice note
|
||||
if reply:
|
||||
reply = f'🎙️ Entendi: "{transcript}"\n\n{reply}'
|
||||
else:
|
||||
reply = ('Recibi tu nota de voz pero no pude transcribirla. '
|
||||
'Puedes escribirme el mensaje?')
|
||||
text = transcript
|
||||
print(f"[WA-SM] Voice note transcribed: {transcript[:100]}")
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f"[WA-SM] Whisper transcription failed: {e}")
|
||||
|
||||
elif msg.get('text'):
|
||||
# Plain text message — standard chatbot flow
|
||||
from services.ai_chat import chat
|
||||
ai_resp = chat(msg['text'], conversation_history=conversation_history, inventory_context=inventory_context)
|
||||
reply = ai_resp.get('message', '') or ''
|
||||
# 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']}"
|
||||
|
||||
# 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')
|
||||
# Image without caption: provide a default text so the state machine can handle it
|
||||
if media_kind == 'image' and not text:
|
||||
text = "(imagen)"
|
||||
|
||||
# Persist detected vehicle so we don't lose context between messages
|
||||
if vehicle and isinstance(vehicle, dict) and vehicle.get('brand'):
|
||||
|
||||
# Resolve tenant
|
||||
tenant_id = request.args.get('tenant_id', type=int)
|
||||
if not tenant_id:
|
||||
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:
|
||||
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:
|
||||
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}")
|
||||
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
|
||||
|
||||
# Send reply if we produced one
|
||||
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:
|
||||
result = whatsapp_service.send_message(reply_to, reply, bridge_url=wa_config.get('bridge_url'))
|
||||
print(f"[WA-AI] Replied to {reply_to} ({media_kind}): {reply[:80]}... result={result}")
|
||||
print(f"[WA-SM] Replied to {phone}: {reply[:80]}... result={result}")
|
||||
|
||||
# Save the bot's reply to DB so it shows in the WhatsApp UI
|
||||
if tenant_conn:
|
||||
try:
|
||||
cur2 = tenant_conn.cursor()
|
||||
cur2.execute("""
|
||||
# Log outgoing
|
||||
cur = tenant_conn.cursor()
|
||||
cur.execute("""
|
||||
INSERT INTO whatsapp_messages (phone, direction, message_text)
|
||||
VALUES (%s, 'outgoing', %s)
|
||||
""", (msg['phone'], reply))
|
||||
""", (phone, reply))
|
||||
tenant_conn.commit()
|
||||
cur2.close()
|
||||
except Exception as db_err:
|
||||
print(f"[WA-AI] Failed to save bot reply to DB: {db_err}")
|
||||
cur.close()
|
||||
|
||||
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
|
||||
if tenant_conn is not None:
|
||||
finally:
|
||||
if tenant_conn:
|
||||
try:
|
||||
tenant_conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
if master_conn is not None:
|
||||
if master_conn:
|
||||
try:
|
||||
master_conn.close()
|
||||
except Exception:
|
||||
|
||||
@@ -5,7 +5,7 @@ bind = "0.0.0.0:5001"
|
||||
# gthread workers handle multiple concurrent requests per worker via threads.
|
||||
# Ideal for I/O-bound Flask apps with DB queries.
|
||||
# 4 workers × 4 threads = 16 concurrent requests.
|
||||
workers = 4
|
||||
workers = 8
|
||||
threads = 4
|
||||
worker_class = "gthread"
|
||||
worker_connections = 1000
|
||||
|
||||
@@ -29,7 +29,7 @@ def require_auth(*required_permissions):
|
||||
except jwt.InvalidTokenError:
|
||||
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
|
||||
|
||||
g.tenant_id = payload['tenant_id']
|
||||
|
||||
@@ -55,7 +55,7 @@ def _lookup_tenant_by_subdomain(subdomain):
|
||||
conn = get_master_conn()
|
||||
cur = conn.cursor()
|
||||
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,)
|
||||
)
|
||||
row = cur.fetchone()
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from tenant_db import get_master_conn, get_tenant_conn_by_dbname
|
||||
@@ -12,27 +13,45 @@ MIGRATIONS_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
# Migration registry: version -> filename
|
||||
MIGRATIONS = {
|
||||
'v1.0': 'v1.0_initial.sql',
|
||||
'v1.1': 'v1.1_pos_tables.sql',
|
||||
'v1.3': 'v1.3_fleet.sql',
|
||||
'v1.4': 'v1.4_whatsapp.sql',
|
||||
'v1.5': 'v1.5_returns.sql',
|
||||
'v1.7': 'v1.7_plates.sql',
|
||||
'v1.8': 'v1.8_performance_indexes.sql',
|
||||
'v1.9': 'v1.9_redis_cache.sql',
|
||||
'v2.0': 'v2.0_multi_currency.sql',
|
||||
'v2.1': 'v2.1_suppliers.sql',
|
||||
'v2.2': 'v2.2_alerts_warranty.sql',
|
||||
'v2.3': 'v2.3_metabase.sql',
|
||||
'v2.4': 'v2.4_crm_enhanced.sql',
|
||||
'v2.5': 'v2.5_service_orders.sql',
|
||||
'v2.6': 'v2.6_bnpl_erp.sql',
|
||||
'v2.7': 'v2.7_notifications.sql',
|
||||
'v2.8': 'v2.8_savings.sql',
|
||||
'v2.9': 'v2.9_logistics.sql',
|
||||
'v3.0': 'v3.0_public_api.sql',
|
||||
'v3.1': 'v3.1_inventory_vehicle_compat.sql',
|
||||
'v3.2': 'v3.2_db_performance.sql',
|
||||
"v1.0": "v1.0_initial.sql",
|
||||
"v1.1": "v1.1_pos_tables.sql",
|
||||
"v1.2": "v1.2_subdomain.sql",
|
||||
"v1.3": "v1.3_fleet.sql",
|
||||
"v1.4": "v1.4_whatsapp.sql",
|
||||
"v1.5": "v1.5_returns.sql",
|
||||
"v1.6": "v1.6_marketplace.sql",
|
||||
"v1.7": "v1.7_plates.sql",
|
||||
"v1.8": "v1.8_performance_indexes.sql",
|
||||
"v1.9": "v1.9_redis_cache.sql",
|
||||
"v2.0": "v2.0_multi_currency.sql",
|
||||
"v2.1": "v2.1_suppliers.sql",
|
||||
"v2.2": "v2.2_alerts_warranty.sql",
|
||||
"v2.3": "v2.3_metabase.sql",
|
||||
"v2.4": "v2.4_crm_enhanced.sql",
|
||||
"v2.5": "v2.5_service_orders.sql",
|
||||
"v2.6": "v2.6_bnpl_erp.sql",
|
||||
"v2.7": "v2.7_notifications.sql",
|
||||
"v2.8": "v2.8_savings.sql",
|
||||
"v2.9": "v2.9_logistics.sql",
|
||||
"v3.0": "v3.0_public_api.sql",
|
||||
"v3.1": "v3.1_inventory_vehicle_compat.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",
|
||||
"v4.4": "v4.4_workshop.sql",
|
||||
"v4.5": "v4.5_customer_max_discount.sql",
|
||||
}
|
||||
|
||||
|
||||
@@ -61,11 +80,19 @@ def apply_migration(db_name, version):
|
||||
print(f" ERROR: Migration file not found: {filepath}")
|
||||
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(" SKIP (manual/non-tenant migration)")
|
||||
return True
|
||||
|
||||
conn = get_tenant_conn_by_dbname(db_name)
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
with open(filepath) as f:
|
||||
cur.execute(f.read())
|
||||
cur.execute(sql)
|
||||
conn.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
@@ -92,16 +119,19 @@ def run_migrations():
|
||||
if version <= current_version:
|
||||
continue
|
||||
|
||||
print(f" Applying {version}...", end=' ')
|
||||
print(f" Applying {version}...", end=" ")
|
||||
if apply_migration(db_name, version):
|
||||
# Update version in master
|
||||
master_conn = get_master_conn()
|
||||
master_cur = master_conn.cursor()
|
||||
master_cur.execute("""
|
||||
master_cur.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))
|
||||
""",
|
||||
(tenant_id, version, version),
|
||||
)
|
||||
master_conn.commit()
|
||||
master_cur.close()
|
||||
master_conn.close()
|
||||
@@ -113,5 +143,5 @@ def run_migrations():
|
||||
print("\nDone.")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if __name__ == "__main__":
|
||||
run_migrations()
|
||||
|
||||
@@ -386,3 +386,4 @@ CREATE TABLE IF NOT EXISTS tenant_config (
|
||||
|
||||
-- Barcode sequence
|
||||
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
|
||||
-- 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.
|
||||
--
|
||||
-- Notes:
|
||||
-- - CREATE MATERIALIZED VIEW without CONCURRENTLY (first creation).
|
||||
-- - REFRESH MATERIALIZED VIEW CONCURRENTLY is possible after the unique index exists.
|
||||
-- - Run with statement_timeout = 0; this may take hours on first creation.
|
||||
-- NOTE: This migration targets the vehicle_database, not tenant databases.
|
||||
-- The runner skips files marked with ': SKIP' on the first line.
|
||||
-- To apply manually on the vehicle database, run:
|
||||
--
|
||||
-- psql <vehicle_db> -f pos/migrations/v3.3_materialized_view.sql
|
||||
--
|
||||
-- (Remove the ': SKIP' line above before manual execution.)
|
||||
|
||||
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 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';
|
||||
66
pos/migrations/v4.4_workshop.sql
Normal file
66
pos/migrations/v4.4_workshop.sql
Normal file
@@ -0,0 +1,66 @@
|
||||
-- v4.4 Workshop Lite
|
||||
-- Extends service orders with inventory reservation, sale linking and a labor catalog.
|
||||
|
||||
-- ═════════════════════════════════════════════════════════════════════════════
|
||||
-- 1. SERVICE_ORDERS: link to the sale generated from the order
|
||||
-- ═════════════════════════════════════════════════════════════════════════════
|
||||
ALTER TABLE service_orders
|
||||
ADD COLUMN IF NOT EXISTS sale_id INTEGER REFERENCES sales(id);
|
||||
|
||||
COMMENT ON COLUMN service_orders.sale_id IS 'Sale/invoice generated from this service order';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_service_orders_sale_id ON service_orders(sale_id);
|
||||
|
||||
-- ═════════════════════════════════════════════════════════════════════════════
|
||||
-- 2. SERVICE_CATALOG: reusable labor/work concepts for mechanics
|
||||
-- ═════════════════════════════════════════════════════════════════════════════
|
||||
CREATE TABLE IF NOT EXISTS service_catalog (
|
||||
id SERIAL PRIMARY KEY,
|
||||
tenant_id INTEGER NOT NULL,
|
||||
name VARCHAR(200) NOT NULL,
|
||||
description TEXT,
|
||||
suggested_hours NUMERIC(6,2) DEFAULT 0,
|
||||
suggested_rate NUMERIC(12,2) DEFAULT 0,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_service_catalog_tenant ON service_catalog(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_service_catalog_active ON service_catalog(is_active);
|
||||
|
||||
COMMENT ON TABLE service_catalog IS 'Reusable labor concepts for workshop service orders';
|
||||
|
||||
-- Trigger to auto-update updated_at on service_catalog
|
||||
CREATE OR REPLACE FUNCTION update_service_catalog_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_service_catalog_updated_at ON service_catalog;
|
||||
CREATE TRIGGER trg_service_catalog_updated_at
|
||||
BEFORE UPDATE ON service_catalog
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_service_catalog_updated_at();
|
||||
|
||||
-- ═════════════════════════════════════════════════════════════════════════════
|
||||
-- 3. SERVICE_ORDER_ITEMS: track reserved quantity separately
|
||||
-- ═════════════════════════════════════════════════════════════════════════════
|
||||
ALTER TABLE service_order_items
|
||||
ADD COLUMN IF NOT EXISTS reserved_quantity NUMERIC(10,2) DEFAULT 0;
|
||||
|
||||
COMMENT ON COLUMN service_order_items.reserved_quantity IS 'Quantity currently reserved from inventory';
|
||||
|
||||
-- ═════════════════════════════════════════════════════════════════════════════
|
||||
-- 4. INVENTORY_OPERATIONS: new operation types for service orders
|
||||
-- ═════════════════════════════════════════════════════════════════════════════
|
||||
-- operation_type is VARCHAR(20) without a constraint, so no ALTER is needed.
|
||||
-- New types used by the workshop module:
|
||||
-- SO_RESERVE : negative quantity, reserves stock when item is added to SO
|
||||
-- SO_RELEASE : positive quantity, releases a previous reservation
|
||||
-- SO_CONSUME : negative quantity, final deduction when SO is converted to sale
|
||||
COMMENT ON COLUMN inventory_operations.operation_type IS
|
||||
'SALE, PURCHASE, RETURN, ADJUST, TRANSFER, INITIAL, QUOTE_RESERVE, QUOTE_RELEASE, SO_RESERVE, SO_RELEASE, SO_CONSUME';
|
||||
5
pos/migrations/v4.5_customer_max_discount.sql
Normal file
5
pos/migrations/v4.5_customer_max_discount.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
-- /home/Autopartes/pos/migrations/v4.5_customer_max_discount.sql
|
||||
-- Tenant DB schema v4.5 — add per-customer maximum discount percentage.
|
||||
|
||||
ALTER TABLE customers
|
||||
ADD COLUMN IF NOT EXISTS max_discount_pct NUMERIC(5,2) DEFAULT 0;
|
||||
@@ -7,3 +7,4 @@ gunicorn>=22.0
|
||||
redis>=5.0
|
||||
meilisearch>=0.40
|
||||
orjson
|
||||
facturapi>=1.0
|
||||
|
||||
@@ -86,11 +86,42 @@ def _post_chat_completion(url, api_key, model_id, messages, max_tokens=800, temp
|
||||
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":...}}
|
||||
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.
|
||||
Cuando pida cotizacion o multiples partes, search_query DEBE usar | para separar cada parte: "Brake Pad|Air Filter|Oil Filter|Spark Plug".
|
||||
|
||||
REGLAS DE VENTA AVANZADAS:
|
||||
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.
|
||||
@@ -195,11 +226,24 @@ def get_inventory_context(tenant_conn, branch_id=None):
|
||||
WHERE {where} AND i.brand IS NOT NULL AND i.brand != ''
|
||||
GROUP BY i.brand
|
||||
ORDER BY cnt DESC
|
||||
LIMIT 15
|
||||
LIMIT 10
|
||||
""", params)
|
||||
brands = cur.fetchall()
|
||||
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)
|
||||
cur.execute(f"""
|
||||
SELECT COUNT(*) FROM inventory i
|
||||
@@ -212,10 +256,12 @@ def get_inventory_context(tenant_conn, branch_id=None):
|
||||
"CONTEXTO DEL INVENTARIO:",
|
||||
f"Este negocio tiene {total} productos en inventario.",
|
||||
]
|
||||
if category_list:
|
||||
lines.append(f"Categorias principales: {category_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("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)
|
||||
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})
|
||||
|
||||
# Try Hermes first for vision (if enabled), fallback to OpenRouter
|
||||
# Vision backends: QWEN only, fallback to OpenRouter if key present
|
||||
backends = []
|
||||
if HERMES_ENABLED:
|
||||
backends.append((HERMES_CHAT_URL, HERMES_API_KEY, HERMES_VISION_MODEL))
|
||||
if QWEN_ENABLED:
|
||||
backends.append((QWEN_CHAT_URL, QWEN_API_KEY, QWEN_MODEL))
|
||||
if OPENROUTER_API_KEY:
|
||||
backends.append((OPENROUTER_URL, OPENROUTER_API_KEY, VISION_MODEL))
|
||||
|
||||
@@ -339,10 +385,10 @@ def classify_part(part_number):
|
||||
{"role": "user", "content": prompt}
|
||||
]
|
||||
|
||||
# Try Hermes first (if enabled), fallback to OpenRouter
|
||||
# Backends: QWEN only, fallback to OpenRouter if key present
|
||||
backends = []
|
||||
if HERMES_ENABLED:
|
||||
backends.append((HERMES_CHAT_URL, HERMES_API_KEY, HERMES_MODEL))
|
||||
if QWEN_ENABLED:
|
||||
backends.append((QWEN_CHAT_URL, QWEN_API_KEY, QWEN_MODEL))
|
||||
if OPENROUTER_API_KEY:
|
||||
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
|
||||
|
||||
# Build backend list: QWEN first (fast, ~1s), then Hermes (specialized, ~30s), then OpenRouter
|
||||
# Build backend list: QWEN first, then OpenRouter fallback
|
||||
backends = []
|
||||
if QWEN_ENABLED:
|
||||
backends.append((QWEN_CHAT_URL, QWEN_API_KEY, QWEN_MODEL, 35, SYSTEM_PROMPT_SHORT, 4000))
|
||||
if HERMES_ENABLED:
|
||||
backends.append((HERMES_CHAT_URL, HERMES_API_KEY, HERMES_MODEL, 45, SYSTEM_PROMPT, 800))
|
||||
backends.append((QWEN_CHAT_URL, QWEN_API_KEY, QWEN_MODEL, 18, SYSTEM_PROMPT_SHORT, 1200))
|
||||
if OPENROUTER_API_KEY:
|
||||
for m in FALLBACK_MODELS:
|
||||
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:
|
||||
msgs.extend(conversation_history)
|
||||
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)
|
||||
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 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"
|
||||
elif url == HERMES_CHAT_URL:
|
||||
print(f"[AI] Hermes failed, trying OpenRouter fallback...")
|
||||
last_error = "hermes_timeout"
|
||||
else:
|
||||
print(f"[AI] Rate limited on {model_id}, trying next model...")
|
||||
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
|
||||
if last_error == "rate_limit":
|
||||
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 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',
|
||||
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')
|
||||
|
||||
238
pos/services/cfdi_facturapi_builder.py
Normal file
238
pos/services/cfdi_facturapi_builder.py
Normal file
@@ -0,0 +1,238 @@
|
||||
# /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 datetime import datetime
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
|
||||
# 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)
|
||||
|
||||
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
|
||||
"""CFDI queue service: manages the timbrado pipeline.
|
||||
"""CFDI queue service: manages the Facturapi timbrado pipeline.
|
||||
|
||||
Flow:
|
||||
1. enqueue_cfdi() — inserts XML into cfdi_queue with status='pending'
|
||||
2. process_queue() — sends pending items to Horux API, updates status
|
||||
1. enqueue_cfdi() — inserts Facturapi JSON payload into cfdi_queue with status='pending'
|
||||
2. process_queue() — sends pending items to Facturapi, updates status
|
||||
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:
|
||||
POST /api/nexus/cfdi/stamp — send unsigned XML, receive signed+timbrado
|
||||
GET /api/nexus/cfdi/status/:uuid — check timbrado status
|
||||
POST /api/nexus/cfdi/cancel — cancel CFDI with SAT motive code
|
||||
Facturapi endpoints used:
|
||||
POST /v2/invoices — create and stamp an invoice
|
||||
GET /v2/invoices/:id — fetch invoice metadata
|
||||
DELETE /v2/invoices/:id — cancel with SAT motive
|
||||
|
||||
Retry backoff: 5s, 30s, 2m, 10m, 1h (max 5 retries)
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime
|
||||
|
||||
import requests
|
||||
from services import facturapi_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -29,25 +29,22 @@ MAX_RETRIES = len(BACKOFF_INTERVALS)
|
||||
|
||||
|
||||
def _generate_provisional_folio(conn):
|
||||
"""Generate a provisional folio like PRE-00001.
|
||||
|
||||
Uses the cfdi_queue table's max id to avoid collisions.
|
||||
"""
|
||||
"""Generate a provisional folio like PRE-00001."""
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT COALESCE(MAX(id), 0) + 1 FROM cfdi_queue")
|
||||
seq = cur.fetchone()[0]
|
||||
cur.close()
|
||||
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.
|
||||
|
||||
Args:
|
||||
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'
|
||||
xml: str (unsigned XML from cfdi_builder)
|
||||
payload: dict (Facturapi JSON payload) or str (JSON string)
|
||||
|
||||
Returns:
|
||||
dict: {id, sale_id, type, status, provisional_folio}
|
||||
@@ -55,169 +52,182 @@ def enqueue_cfdi(conn, sale_id, cfdi_type, xml):
|
||||
provisional_folio = _generate_provisional_folio(conn)
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute("""
|
||||
payload_json = payload if isinstance(payload, str) else json.dumps(payload)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
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)
|
||||
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()
|
||||
cur.close()
|
||||
|
||||
return {
|
||||
'id': cfdi_id,
|
||||
'sale_id': sale_id,
|
||||
'type': cfdi_type,
|
||||
'status': 'pending',
|
||||
'provisional_folio': provisional_folio,
|
||||
'created_at': str(created_at),
|
||||
"id": cfdi_id,
|
||||
"sale_id": sale_id,
|
||||
"type": cfdi_type,
|
||||
"status": "pending",
|
||||
"provisional_folio": provisional_folio,
|
||||
"created_at": str(created_at),
|
||||
}
|
||||
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
retry_count and records the error.
|
||||
|
||||
Args:
|
||||
conn: psycopg2 connection
|
||||
horux_api_url: str base URL for Horux API (e.g. 'https://horux.example.com')
|
||||
api_key: str Horux API key
|
||||
tenant_config: dict with facturapi_key (and optional facturapi_org_id)
|
||||
dry_run: if True, validates payload without stamping
|
||||
|
||||
Returns:
|
||||
dict: {processed: int, stamped: int, failed: int, details: [...]}
|
||||
"""
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute("""
|
||||
SELECT id, sale_id, type, xml_unsigned, retry_count
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, sale_id, type, payload_unsigned, retry_count
|
||||
FROM cfdi_queue
|
||||
WHERE status IN ('pending', 'failed')
|
||||
AND retry_count < %s
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 50
|
||||
""", (MAX_RETRIES,))
|
||||
""",
|
||||
(MAX_RETRIES,),
|
||||
)
|
||||
items = cur.fetchall()
|
||||
|
||||
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:
|
||||
results['processed'] += 1
|
||||
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
|
||||
|
||||
# Update status to 'sending'
|
||||
cur.execute("""
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE cfdi_queue SET status = 'sending' WHERE id = %s
|
||||
""", (cfdi_id,))
|
||||
""",
|
||||
(cfdi_id,),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f'{horux_api_url}/api/nexus/cfdi/stamp',
|
||||
headers={
|
||||
'Authorization': f'Bearer {api_key}',
|
||||
'Content-Type': 'application/xml',
|
||||
},
|
||||
data=xml_unsigned.encode('utf-8'),
|
||||
timeout=30,
|
||||
)
|
||||
payload = json.loads(payload_unsigned or "{}")
|
||||
if not payload:
|
||||
raise ValueError("Empty payload in queue item")
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
uuid_fiscal = data.get('uuid')
|
||||
xml_signed = data.get('xml', '')
|
||||
if dry_run:
|
||||
# TODO: Facturapi dry-run validation (not officially supported)
|
||||
# For now we just skip the API call and mark as stamped with a fake UUID
|
||||
raise ValueError("dry_run is not supported with Facturapi")
|
||||
|
||||
cur.execute("""
|
||||
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(
|
||||
"""
|
||||
UPDATE cfdi_queue
|
||||
SET status = 'stamped',
|
||||
xml_signed = %s,
|
||||
uuid_fiscal = %s,
|
||||
external_id = %s,
|
||||
stamped_at = NOW(),
|
||||
error_message = NULL
|
||||
WHERE id = %s
|
||||
""", (xml_signed, uuid_fiscal, cfdi_id))
|
||||
""",
|
||||
(xml_signed_str, uuid_fiscal, invoice_id, cfdi_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
results['stamped'] += 1
|
||||
results['details'].append({
|
||||
'id': cfdi_id, 'status': 'stamped', 'uuid': uuid_fiscal
|
||||
})
|
||||
else:
|
||||
error_msg = f'HTTP {response.status_code}: {response.text[:500]}'
|
||||
cur.execute("""
|
||||
results["stamped"] += 1
|
||||
results["details"].append(
|
||||
{
|
||||
"id": cfdi_id,
|
||||
"status": "stamped",
|
||||
"uuid": uuid_fiscal,
|
||||
"external_id": invoice_id,
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"{type(e).__name__}: {str(e)[:500]}"
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE cfdi_queue
|
||||
SET status = 'failed',
|
||||
retry_count = retry_count + 1,
|
||||
error_message = %s
|
||||
WHERE id = %s
|
||||
""", (error_msg, cfdi_id))
|
||||
""",
|
||||
(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:
|
||||
error_msg = f'Connection error: {str(e)[: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
|
||||
})
|
||||
results["failed"] += 1
|
||||
results["details"].append({"id": cfdi_id, "status": "failed", "error": error_msg})
|
||||
|
||||
cur.close()
|
||||
return results
|
||||
|
||||
|
||||
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
|
||||
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()
|
||||
|
||||
# For each failed item, check if enough time has passed for its retry level
|
||||
cur.execute("""
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, retry_count, created_at
|
||||
FROM cfdi_queue
|
||||
WHERE status = 'failed' AND retry_count < %s
|
||||
ORDER BY created_at ASC
|
||||
""", (MAX_RETRIES,))
|
||||
""",
|
||||
(MAX_RETRIES,),
|
||||
)
|
||||
items = cur.fetchall()
|
||||
|
||||
reset_count = 0
|
||||
now = datetime.utcnow()
|
||||
|
||||
for cfdi_id, retry_count, created_at in items:
|
||||
# Calculate required wait time based on retry count
|
||||
if retry_count < len(BACKOFF_INTERVALS):
|
||||
wait_seconds = BACKOFF_INTERVALS[retry_count]
|
||||
else:
|
||||
wait_seconds = BACKOFF_INTERVALS[-1] # max backoff
|
||||
wait_seconds = BACKOFF_INTERVALS[retry_count] if retry_count < len(BACKOFF_INTERVALS) else BACKOFF_INTERVALS[-1]
|
||||
|
||||
# Check if enough time has passed (use created_at as approximation)
|
||||
# In production, you'd track last_attempt_at separately
|
||||
if True: # Always eligible for manual retry trigger
|
||||
cur.execute("""
|
||||
# Use created_at as approximation for last attempt.
|
||||
# In production, track last_attempt_at separately.
|
||||
elapsed = (now - created_at).total_seconds()
|
||||
if elapsed >= wait_seconds:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE cfdi_queue SET status = 'pending' WHERE id = %s
|
||||
""", (cfdi_id,))
|
||||
""",
|
||||
(cfdi_id,),
|
||||
)
|
||||
reset_count += 1
|
||||
|
||||
conn.commit()
|
||||
@@ -225,9 +235,8 @@ def retry_failed(conn):
|
||||
return reset_count
|
||||
|
||||
|
||||
def cancel_cfdi(conn, cfdi_id, motive, replacement_uuid=None,
|
||||
horux_api_url=None, api_key=None):
|
||||
"""Cancel a stamped CFDI via Horux API.
|
||||
def cancel_cfdi(conn, cfdi_id, motive, replacement_uuid=None, tenant_config=None):
|
||||
"""Cancel a stamped CFDI via Facturapi.
|
||||
|
||||
SAT cancellation motives:
|
||||
01: Comprobante emitido con errores con relacion (requires replacement UUID)
|
||||
@@ -240,8 +249,7 @@ def cancel_cfdi(conn, cfdi_id, motive, replacement_uuid=None,
|
||||
cfdi_id: int (cfdi_queue.id)
|
||||
motive: str ('01', '02', '03', '04')
|
||||
replacement_uuid: str (required if motive == '01')
|
||||
horux_api_url: str (optional, skips API call if None — for offline)
|
||||
api_key: str (optional)
|
||||
tenant_config: dict with facturapi_key
|
||||
|
||||
Returns:
|
||||
dict: {id, status, message}
|
||||
@@ -249,173 +257,161 @@ def cancel_cfdi(conn, cfdi_id, motive, replacement_uuid=None,
|
||||
Raises:
|
||||
ValueError: on validation errors
|
||||
"""
|
||||
if motive not in ('01', '02', '03', '04'):
|
||||
if motive not in ("01", "02", "03", "04"):
|
||||
raise ValueError(f"Invalid SAT cancellation motive: {motive}")
|
||||
|
||||
if motive == '01' and not replacement_uuid:
|
||||
if motive == "01" and not replacement_uuid:
|
||||
raise ValueError("Motive 01 requires a replacement UUID")
|
||||
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute("""
|
||||
SELECT id, uuid_fiscal, status FROM cfdi_queue WHERE id = %s
|
||||
""", (cfdi_id,))
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, uuid_fiscal, external_id, status FROM cfdi_queue WHERE id = %s
|
||||
""",
|
||||
(cfdi_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
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")
|
||||
|
||||
if current_status != 'stamped':
|
||||
if current_status != "stamped":
|
||||
# If not stamped, we can just mark as cancelled locally
|
||||
cur.execute("""
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE cfdi_queue
|
||||
SET status = 'cancelled', cancel_motive = %s
|
||||
WHERE id = %s
|
||||
""", (motive, cfdi_id))
|
||||
""",
|
||||
(motive, cfdi_id),
|
||||
)
|
||||
conn.commit()
|
||||
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)"}
|
||||
|
||||
if not tenant_config or not tenant_config.get("facturapi_key"):
|
||||
cur.close()
|
||||
raise ValueError("Facturapi key not configured for tenant")
|
||||
|
||||
if not external_id:
|
||||
cur.close()
|
||||
raise ValueError("Cannot cancel: no Facturapi invoice id stored")
|
||||
|
||||
# Send cancel request to Horux
|
||||
if horux_api_url and api_key:
|
||||
try:
|
||||
payload = {
|
||||
'uuid': uuid_fiscal,
|
||||
'motive': motive,
|
||||
}
|
||||
if replacement_uuid:
|
||||
payload['replacement_uuid'] = replacement_uuid
|
||||
|
||||
response = requests.post(
|
||||
f'{horux_api_url}/api/nexus/cfdi/cancel',
|
||||
headers={
|
||||
'Authorization': f'Bearer {api_key}',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
json=payload,
|
||||
timeout=30,
|
||||
facturapi_service.cancel_invoice(
|
||||
tenant_config,
|
||||
external_id,
|
||||
motive,
|
||||
replacement_uuid=replacement_uuid,
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
cur.execute("""
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE cfdi_queue
|
||||
SET status = 'cancelled',
|
||||
cancel_motive = %s,
|
||||
cancel_replacement_uuid = %s,
|
||||
error_message = NULL
|
||||
WHERE id = %s
|
||||
""", (motive, replacement_uuid, cfdi_id))
|
||||
""",
|
||||
(motive, replacement_uuid, cfdi_id),
|
||||
)
|
||||
conn.commit()
|
||||
cur.close()
|
||||
return {
|
||||
'id': cfdi_id,
|
||||
'status': 'cancelled',
|
||||
'message': f'Cancelled with SAT (motive {motive})',
|
||||
"id": cfdi_id,
|
||||
"status": "cancelled",
|
||||
"message": f"Cancelled with SAT (motive {motive})",
|
||||
}
|
||||
else:
|
||||
error_msg = f'Cancel failed: HTTP {response.status_code}: {response.text[:500]}'
|
||||
cur.execute("""
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Cancel failed: {str(e)[:500]}"
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE cfdi_queue
|
||||
SET error_message = %s
|
||||
WHERE id = %s
|
||||
""", (error_msg, cfdi_id))
|
||||
""",
|
||||
(error_msg, cfdi_id),
|
||||
)
|
||||
conn.commit()
|
||||
cur.close()
|
||||
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',
|
||||
}
|
||||
raise ValueError(error_msg) from e
|
||||
|
||||
|
||||
def get_queue_status(conn, filters=None):
|
||||
"""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: {...}}
|
||||
"""
|
||||
"""Get CFDI queue items with optional filters."""
|
||||
filters = filters or {}
|
||||
cur = conn.cursor()
|
||||
|
||||
page = int(filters.get('page', 1))
|
||||
per_page = min(int(filters.get('per_page', 50)), 200)
|
||||
page = int(filters.get("page", 1))
|
||||
per_page = min(int(filters.get("per_page", 50)), 200)
|
||||
|
||||
where_clauses = ["1=1"]
|
||||
params = []
|
||||
|
||||
if filters.get('status'):
|
||||
if filters.get("status"):
|
||||
where_clauses.append("q.status = %s")
|
||||
params.append(filters['status'])
|
||||
params.append(filters["status"])
|
||||
|
||||
if filters.get('sale_id'):
|
||||
if filters.get("sale_id"):
|
||||
where_clauses.append("q.sale_id = %s")
|
||||
params.append(int(filters['sale_id']))
|
||||
params.append(int(filters["sale_id"]))
|
||||
|
||||
if filters.get('type'):
|
||||
if filters.get("type"):
|
||||
where_clauses.append("q.type = %s")
|
||||
params.append(filters['type'])
|
||||
params.append(filters["type"])
|
||||
|
||||
where = " AND ".join(where_clauses)
|
||||
|
||||
cur.execute(f"SELECT count(*) FROM cfdi_queue q WHERE {where}", params)
|
||||
total = cur.fetchone()[0]
|
||||
|
||||
cur.execute(f"""
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT q.id, q.sale_id, q.type, q.uuid_fiscal, q.status,
|
||||
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
|
||||
WHERE {where}
|
||||
ORDER BY q.created_at DESC
|
||||
LIMIT %s OFFSET %s
|
||||
""", params + [per_page, (page - 1) * per_page])
|
||||
""",
|
||||
params + [per_page, (page - 1) * per_page],
|
||||
)
|
||||
|
||||
items = []
|
||||
for r in cur.fetchall():
|
||||
items.append({
|
||||
'id': r[0], 'sale_id': r[1], 'type': r[2],
|
||||
'uuid_fiscal': r[3], 'status': r[4],
|
||||
'retry_count': r[5], 'provisional_folio': r[6],
|
||||
'error_message': r[7], 'cancel_motive': r[8],
|
||||
'created_at': str(r[9]) if r[9] else None,
|
||||
'stamped_at': str(r[10]) if r[10] else None,
|
||||
})
|
||||
items.append(
|
||||
{
|
||||
"id": r[0],
|
||||
"sale_id": r[1],
|
||||
"type": r[2],
|
||||
"uuid_fiscal": r[3],
|
||||
"status": r[4],
|
||||
"retry_count": r[5],
|
||||
"provisional_folio": r[6],
|
||||
"error_message": r[7],
|
||||
"cancel_motive": r[8],
|
||||
"created_at": str(r[9]) if r[9] else None,
|
||||
"stamped_at": str(r[10]) if r[10] else None,
|
||||
"external_id": r[11],
|
||||
}
|
||||
)
|
||||
|
||||
cur.close()
|
||||
total_pages = (total + per_page - 1) // per_page
|
||||
return {
|
||||
'data': items,
|
||||
'pagination': {
|
||||
'page': page, 'per_page': per_page,
|
||||
'total': total, 'total_pages': total_pages,
|
||||
}
|
||||
"data": items,
|
||||
"pagination": {
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"total": total,
|
||||
"total_pages": total_pages,
|
||||
},
|
||||
}
|
||||
|
||||
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
|
||||
404
pos/services/facturapi_service.py
Normal file
404
pos/services/facturapi_service.py
Normal file
@@ -0,0 +1,404 @@
|
||||
# /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 base64
|
||||
import logging
|
||||
import os
|
||||
from decimal import Decimal
|
||||
|
||||
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) from e
|
||||
|
||||
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) -> str | None:
|
||||
for key in ("facturapi_secret_key", "facturapi_key", "cfdi_facturapi_key"):
|
||||
val = (tenant_config.get(key) or "").strip()
|
||||
if val:
|
||||
return val
|
||||
return None
|
||||
|
||||
|
||||
def _get_org_id(tenant_config: dict) -> str | None:
|
||||
for key in ("facturapi_org_id", "cfdi_facturapi_org_id"):
|
||||
val = (tenant_config.get(key) or "").strip()
|
||||
if val:
|
||||
return val
|
||||
return None
|
||||
|
||||
|
||||
def _get_user_key() -> str | None:
|
||||
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 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 = _get_org_id(tenant_config)
|
||||
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
|
||||
for key in ("facturapi_key", "cfdi_facturapi_key"):
|
||||
tenant_key = (tenant_config.get(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) -> dict | None:
|
||||
"""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 = _get_org_id(tenant_config)
|
||||
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: str | None = 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):
|
||||
"""Get current stock for an inventory item. Optionally filter by branch.
|
||||
|
||||
Uses Redis cache first, then inventory_stock_summary, falls back to
|
||||
PostgreSQL SUM query.
|
||||
Uses Redis cache first, then inventory_stock (per-branch) or
|
||||
inventory_stock_summary (total), falls back to PostgreSQL SUM query.
|
||||
"""
|
||||
# Try Redis first
|
||||
cached = get_cached_stock(inventory_id, branch_id)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
# Use inventory_stock_summary (O(1) lookup)
|
||||
cur = conn.cursor()
|
||||
if branch_id:
|
||||
# Per-branch stock from inventory_stock
|
||||
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)
|
||||
)
|
||||
else:
|
||||
# Total stock from inventory_stock_summary
|
||||
cur.execute(
|
||||
"SELECT stock FROM inventory_stock_summary WHERE inventory_id = %s",
|
||||
(inventory_id,)
|
||||
@@ -73,13 +74,14 @@ def get_stock(conn, inventory_id, branch_id=None):
|
||||
def get_stock_bulk(conn, branch_id=None):
|
||||
"""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()
|
||||
if branch_id:
|
||||
cur.execute("""
|
||||
SELECT inventory_id, stock
|
||||
FROM inventory_stock_summary WHERE branch_id = %s
|
||||
FROM inventory_stock WHERE branch_id = %s
|
||||
""", (branch_id,))
|
||||
else:
|
||||
cur.execute("""
|
||||
@@ -119,6 +121,18 @@ def record_operation(conn, inventory_id, branch_id, operation_type, quantity,
|
||||
notes
|
||||
))
|
||||
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()
|
||||
return op_id
|
||||
|
||||
@@ -272,38 +286,72 @@ def record_initial(conn, inventory_id, branch_id, quantity, cost=None):
|
||||
return result
|
||||
|
||||
|
||||
def get_alerts(conn, branch_id=None):
|
||||
"""Get stock alerts: zero stock, below minimum, above maximum."""
|
||||
stock_map = get_stock_bulk(conn, branch_id)
|
||||
def get_alerts(conn, branch_id=None, limit_per_type=500):
|
||||
"""Get stock alerts: zero stock, below minimum, above maximum.
|
||||
Returns at most limit_per_type alerts per severity to avoid browser freeze.
|
||||
"""
|
||||
cur = conn.cursor()
|
||||
|
||||
where = "WHERE i.is_active = true"
|
||||
branch_filter = ""
|
||||
params = []
|
||||
if branch_id:
|
||||
where += " AND i.branch_id = %s"
|
||||
branch_filter = " AND i.branch_id = %s"
|
||||
params.append(branch_id)
|
||||
|
||||
# Use a single SQL query with window functions to rank and limit per type
|
||||
cur.execute(f"""
|
||||
SELECT i.id, i.part_number, i.name, i.min_stock, i.max_stock, i.branch_id
|
||||
FROM inventory i {where}
|
||||
""", params)
|
||||
WITH stock AS (
|
||||
SELECT inventory_id, COALESCE(SUM(quantity), 0) AS qty
|
||||
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 = []
|
||||
for row in cur.fetchall():
|
||||
inv_id, part_num, name, min_s, max_s, br_id = row
|
||||
stock = stock_map.get(inv_id, 0)
|
||||
|
||||
if stock <= 0:
|
||||
alerts.append({'type': 'zero', 'severity': 'critical', 'inventory_id': inv_id,
|
||||
'part_number': part_num, 'name': name, 'stock': stock, 'branch_id': br_id})
|
||||
elif min_s and stock < min_s:
|
||||
alerts.append({'type': 'low', 'severity': 'warning', 'inventory_id': inv_id,
|
||||
'part_number': part_num, 'name': name, 'stock': stock,
|
||||
'min_stock': min_s, 'branch_id': br_id})
|
||||
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})
|
||||
alerts.append({
|
||||
'inventory_id': row[0],
|
||||
'part_number': row[1],
|
||||
'name': row[2],
|
||||
'stock': row[3],
|
||||
'min_stock': row[4],
|
||||
'max_stock': row[5],
|
||||
'branch_id': row[6],
|
||||
'type': row[7],
|
||||
'severity': row[8],
|
||||
})
|
||||
|
||||
cur.close()
|
||||
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
|
||||
WHERE vp.part_id = ANY(%s)
|
||||
AND b.name_brand = %s
|
||||
LIMIT 200
|
||||
LIMIT 500
|
||||
""", (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:
|
||||
# No brand hint — return all MYEs for these parts
|
||||
cur.execute("""
|
||||
SELECT DISTINCT model_year_engine_id
|
||||
FROM vehicle_parts
|
||||
WHERE part_id = ANY(%s)
|
||||
LIMIT 200
|
||||
LIMIT 500
|
||||
""", (oem_ids,))
|
||||
|
||||
mye_ids = [r[0] for r in cur.fetchall()]
|
||||
|
||||
cur.close()
|
||||
|
||||
# ── Insert into tenant table ─────────────────────────────────────────
|
||||
@@ -243,6 +256,20 @@ def remove_compatibility(tenant_conn, inventory_id, model_year_engine_id):
|
||||
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):
|
||||
cur = tenant_conn.cursor()
|
||||
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
|
||||
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.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
|
||||
WHERE inventory_id = %s
|
||||
ORDER BY model_year_engine_id
|
||||
ORDER BY COALESCE(make, ''), COALESCE(model, ''), COALESCE(year, 0)
|
||||
""", (inventory_id,))
|
||||
rows = cur_t.fetchall()
|
||||
cur_t.close()
|
||||
@@ -274,9 +305,10 @@ def get_compatibility(tenant_conn, master_conn, inventory_id):
|
||||
if not rows:
|
||||
return []
|
||||
|
||||
mye_ids = [r[0] for r in rows]
|
||||
|
||||
# 2. Resolve vehicle details from master DB
|
||||
# 2. Resolve MYE-linked vehicles from master DB
|
||||
mye_ids = [r[0] for r in rows if r[0] is not None]
|
||||
details = {}
|
||||
if mye_ids:
|
||||
cur_m = master_conn.cursor()
|
||||
cur_m.execute("""
|
||||
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
|
||||
result = []
|
||||
for mye_id, source, confidence, created_at in rows:
|
||||
d = details.get(mye_id)
|
||||
if d:
|
||||
for (compat_id, mye_id, make, model, year, engine, engine_code,
|
||||
source, confidence, created_at) in rows:
|
||||
if mye_id is not None and mye_id in details:
|
||||
d = details[mye_id]
|
||||
result.append({
|
||||
'id': compat_id,
|
||||
'model_year_engine_id': mye_id,
|
||||
'brand': d[1],
|
||||
'model': d[2],
|
||||
'year': d[3],
|
||||
'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,
|
||||
'confidence': float(confidence),
|
||||
'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):
|
||||
"""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:
|
||||
tenant_conn: Connection to tenant DB.
|
||||
inventory_id: The inventory item ID.
|
||||
@@ -390,14 +442,30 @@ def save_qwen_fitment(tenant_conn, inventory_id, fitment_result):
|
||||
cur = tenant_conn.cursor()
|
||||
for v in vehicles:
|
||||
mye_id = v.get('mye_id')
|
||||
if not mye_id:
|
||||
continue
|
||||
if mye_id is not None and mye_id:
|
||||
# TecDoc-linked vehicle
|
||||
cur.execute("""
|
||||
INSERT INTO inventory_vehicle_compat
|
||||
(inventory_id, model_year_engine_id, source, confidence, created_at)
|
||||
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)))
|
||||
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:
|
||||
inserted += 1
|
||||
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):
|
||||
part_number, stock, price
|
||||
Optional:
|
||||
min_order, warehouse_location, currency
|
||||
name, min_order, warehouse_location, currency
|
||||
|
||||
Resolution rules:
|
||||
- part_number matches `parts.oem_part_number` exactly (case-sensitive).
|
||||
- Parts not found in the master catalog are skipped and reported.
|
||||
- Existing rows for (bodega_id, part_id, warehouse_location) are updated
|
||||
via UPSERT; new rows are inserted.
|
||||
- part_number matches `parts.oem_part_number` or `part_cross_references.cross_reference_number`.
|
||||
- If matched → linked to catalog (part_id set, seller fields NULL).
|
||||
- If NOT matched → created as seller listing (part_id NULL, seller_part_number set).
|
||||
- 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))
|
||||
# Normalize header names
|
||||
@@ -166,9 +166,15 @@ def upload_inventory_csv(master_conn, bodega_id: int, csv_text: str) -> dict:
|
||||
cur.close()
|
||||
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
|
||||
updated = 0
|
||||
skipped = 0
|
||||
oem_count = 0
|
||||
seller_count = 0
|
||||
errors = []
|
||||
|
||||
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', '')
|
||||
stock_str = norm.get('stock', '0')
|
||||
price_str = norm.get('price', '0')
|
||||
part_name = norm.get('name', '')
|
||||
|
||||
if not part_number:
|
||||
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
|
||||
continue
|
||||
|
||||
# Resolve part_number → part_id
|
||||
# Resolve part_number → part_id (OEM catalog or cross-reference)
|
||||
part_id = None
|
||||
cur.execute(
|
||||
"SELECT id_part FROM parts WHERE oem_part_number = %s LIMIT 1",
|
||||
(part_number,)
|
||||
)
|
||||
row_part = cur.fetchone()
|
||||
if not row_part:
|
||||
errors.append(f'Fila {i}: part_number "{part_number}" no encontrado en catalogo')
|
||||
skipped += 1
|
||||
continue
|
||||
if row_part:
|
||||
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)
|
||||
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()
|
||||
min_order = int(norm.get('min_order') or 1)
|
||||
|
||||
# UPSERT on (user_id, part_id, warehouse_location) — the existing
|
||||
# unique constraint. Don't block if user_id FK fails.
|
||||
# UPSERT on composite unique (bodega_id, part_id, seller_part_number, warehouse_location)
|
||||
try:
|
||||
if part_id:
|
||||
# OEM-matched listing
|
||||
cur.execute("""
|
||||
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)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, NOW())
|
||||
ON CONFLICT (user_id, part_id, warehouse_location)
|
||||
VALUES (%s, %s, NULL, NULL, %s, %s, %s, %s, %s, %s, NOW())
|
||||
ON CONFLICT (bodega_id, part_id, warehouse_location) WHERE part_id IS NOT NULL
|
||||
DO UPDATE SET
|
||||
price = EXCLUDED.price,
|
||||
stock_quantity = EXCLUDED.stock_quantity,
|
||||
min_order_quantity = EXCLUDED.min_order_quantity,
|
||||
bodega_id = EXCLUDED.bodega_id,
|
||||
user_id = EXCLUDED.user_id,
|
||||
currency = EXCLUDED.currency,
|
||||
updated_at = NOW()
|
||||
RETURNING (xmax = 0) AS inserted
|
||||
""", (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]
|
||||
if was_insert:
|
||||
inserted += 1
|
||||
@@ -250,6 +284,8 @@ def upload_inventory_csv(master_conn, bodega_id: int, csv_text: str) -> dict:
|
||||
'inserted': inserted,
|
||||
'updated': updated,
|
||||
'skipped': skipped,
|
||||
'oem_count': oem_count,
|
||||
'seller_count': seller_count,
|
||||
'errors': errors[:20], # cap to avoid huge responses
|
||||
'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.
|
||||
Aggregates identical parts across bodegas so the buyer sees each part once
|
||||
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()
|
||||
|
||||
clauses = ["wi.stock_quantity > 0", "b.verified = TRUE"]
|
||||
params = []
|
||||
like = f'%{query}%' if query else None
|
||||
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:
|
||||
clauses.append("(p.oem_part_number ILIKE %s OR p.name_part ILIKE %s OR COALESCE(p.name_es, '') ILIKE %s)")
|
||||
like = f'%{query}%'
|
||||
params.extend([like, like, like])
|
||||
clauses_oem.append("(p.oem_part_number ILIKE %s OR p.name_part ILIKE %s OR COALESCE(p.name_es, '') ILIKE %s)")
|
||||
params_oem.extend([like, like, like])
|
||||
|
||||
if brand:
|
||||
# Search by vehicle brand via vehicle_parts → model_year_engine → models → brands.
|
||||
# Too slow for this MVP. Instead, match on aftermarket manufacturer name.
|
||||
clauses.append("""
|
||||
clauses_oem.append("""
|
||||
EXISTS (
|
||||
SELECT 1 FROM aftermarket_parts ap
|
||||
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)
|
||||
)
|
||||
""")
|
||||
params.append(brand)
|
||||
params_oem.append(brand)
|
||||
|
||||
if city:
|
||||
clauses.append("LOWER(b.city) = LOWER(%s)")
|
||||
params.append(city)
|
||||
where_oem = " AND ".join(clauses_oem)
|
||||
|
||||
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
|
||||
p.id_part,
|
||||
p.oem_part_number,
|
||||
p.id_part AS id,
|
||||
p.oem_part_number AS part_number,
|
||||
COALESCE(p.name_es, p.name_part) AS name,
|
||||
p.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,
|
||||
-- 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
|
||||
JOIN bodegas b ON b.id_bodega = wi.bodega_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
|
||||
|
||||
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
|
||||
LIMIT %s
|
||||
""", params + [limit])
|
||||
"""
|
||||
|
||||
all_params = params_oem + params_common + params_seller + params_common + [limit]
|
||||
cur.execute(sql, all_params)
|
||||
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
|
||||
return [
|
||||
{
|
||||
'id_part': r[0],
|
||||
'oem_part_number': r[1],
|
||||
'id': r[0],
|
||||
'part_number': r[1],
|
||||
'name': r[2],
|
||||
'image_url': r[3],
|
||||
'bodega_count': r[4],
|
||||
'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,
|
||||
'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
|
||||
]
|
||||
@@ -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
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -397,12 +504,15 @@ def create_po_draft(master_conn, *, buyer_tenant_id: int, buyer_user_id: int,
|
||||
# Insert items
|
||||
total = 0.0
|
||||
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'])
|
||||
if quantity < 1:
|
||||
continue
|
||||
|
||||
# Lookup part info + price
|
||||
if part_id:
|
||||
# OEM-matched part
|
||||
part_id = int(part_id)
|
||||
cur.execute("""
|
||||
SELECT p.oem_part_number, COALESCE(p.name_es, p.name_part), wi.price
|
||||
FROM parts p
|
||||
@@ -420,10 +530,35 @@ def create_po_draft(master_conn, *, buyer_tenant_id: int, buyer_user_id: int,
|
||||
|
||||
cur.execute("""
|
||||
INSERT INTO purchase_order_items
|
||||
(po_id, part_id, oem_part_number, part_name, quantity, unit_price, subtotal, notes)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||
(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, FALSE)
|
||||
""", (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
|
||||
cur.execute("UPDATE purchase_orders SET total_amount = %s WHERE id_po = %s",
|
||||
(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))
|
||||
payment_details = sale_data.get('payment_details', [])
|
||||
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')
|
||||
|
||||
# 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 ───────────────────────────────────────────
|
||||
currency = sale_data.get('currency', 'MXN')
|
||||
if currency not in ('MXN', 'USD'):
|
||||
@@ -440,6 +454,42 @@ def process_sale(conn, sale_data):
|
||||
except Exception:
|
||||
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 {
|
||||
'id': sale_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}
|
||||
],
|
||||
'temperature': 0.2,
|
||||
'max_tokens': 4096,
|
||||
'max_tokens': 8192,
|
||||
},
|
||||
timeout=45,
|
||||
timeout=120,
|
||||
)
|
||||
response.raise_for_status()
|
||||
raw = response.json()
|
||||
finish_reason = None
|
||||
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:
|
||||
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:
|
||||
break
|
||||
except requests.RequestException as exc:
|
||||
@@ -62,6 +68,8 @@ def get_vehicle_fitment(part_number, name, brand):
|
||||
|
||||
if not content:
|
||||
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}
|
||||
|
||||
# Parse JSON from QWEN response (sometimes wrapped in markdown)
|
||||
@@ -91,32 +99,21 @@ def _build_prompt(part_number, name, brand):
|
||||
- Nombre/descripcion: {name}
|
||||
- Marca del fabricante: {brand_str}
|
||||
|
||||
Devuelve UNICAMENTE un JSON valido (sin markdown, sin backticks, sin texto adicional) con esta estructura exacta:
|
||||
{{
|
||||
"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."
|
||||
}}
|
||||
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":""}}
|
||||
|
||||
Reglas obligatorias:
|
||||
1. "make" = marca del vehiculo (ej: Toyota, Nissan, Ford, Volkswagen, Chevrolet, Honda, Hyundai, Kia, Mazda, Subaru).
|
||||
2. "model" = modelo exacto. Si hay variantes (ej: Civic Sedan vs Civic Coupe), incluye la variante.
|
||||
3. "year" = ano numerico (int). Si hay rango de anos (ej: 2003-2008), genera una entrada POR CADA ANO del rango. NO uses rangos.
|
||||
4. "engine" = descripcion del motor (ej: "1.8L", "2.0L TDI", "V6 3.5L", "1.6L Turbo"). Si no conoces el motor, usa "desconocido".
|
||||
5. "engine_code" = codigo exacto del motor SI LO CONOCES (ej: "2ZR-FE", "K24Z7", "EA888"). Si no lo conoces, usa "" (string vacio).
|
||||
6. Devuelve TODOS los vehiculos compatibles que conozcas. Minimo 1, maximo 100. Para piezas genericas (bujias, filtros, balatas, amortiguadores) incluye TODOS los modelos aplicables.
|
||||
7. "confidence" entre 0.0 y 1.0. Usa valores altos (>0.85) solo si estas muy seguro.
|
||||
8. Incluye marcas y modelos populares en Mexico (Nissan Tsuru, VW Sedan/Vocho, Chevy Monza, Ford Ka, etc.) cuando apliquen.
|
||||
9. Si la pieza es universal o de alta compatibilidad, indicalo en "notes".
|
||||
REGLAS OBLIGATORIAS:
|
||||
1. "make" = marca del vehiculo.
|
||||
2. "model" = modelo exacto (incluye variante si aplica).
|
||||
3. USA "year_range" = string "YYYY-YYYY" cuando el MISMO modelo/motor abarca multiples anos consecutivos. Esto ahorra tokens y permite mas resultados.
|
||||
4. USA "year" = int SOLO cuando sea un ano aislado sin rangos adyacentes.
|
||||
5. "engine" = descripcion corta del motor (ej: "1.8L", "V6 3.5L"). Si no lo conoces, usa "".
|
||||
6. "engine_code" = codigo exacto SI LO CONOCES. Si no, usa "".
|
||||
7. "notes" = string vacio "" para ahorrar tokens, salvo que haya una advertencia critica.
|
||||
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. "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):
|
||||
"""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 ''
|
||||
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_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 = []
|
||||
|
||||
# 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):
|
||||
years = [year_raw]
|
||||
elif isinstance(year_raw, str):
|
||||
# Try range "2003-2008"
|
||||
m = re.match(r'(\d{4})\s*[-–]\s*(\d{4})', year_raw)
|
||||
if m:
|
||||
start, end = int(m.group(1)), int(m.group(2))
|
||||
years = list(range(start, end + 1))
|
||||
else:
|
||||
# Try single year
|
||||
m2 = re.match(r'(\d{4})', year_raw)
|
||||
if m2:
|
||||
years = [int(m2.group(1))]
|
||||
@@ -200,16 +211,30 @@ def _validate_vehicles(vehicles):
|
||||
1. Exact engine_code match (most precise)
|
||||
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)
|
||||
|
||||
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
|
||||
try:
|
||||
master = get_master_conn()
|
||||
cur = master.cursor()
|
||||
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 = []
|
||||
seen_mye = set()
|
||||
seen_text = set() # (make, model, year) for text-only dedup
|
||||
|
||||
for v in vehicles:
|
||||
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()]
|
||||
|
||||
# Deduplicate and add to results
|
||||
if matched_myes:
|
||||
for mye_id in matched_myes:
|
||||
if mye_id not in seen_mye:
|
||||
seen_mye.add(mye_id)
|
||||
@@ -296,6 +322,19 @@ def _validate_vehicles(vehicles):
|
||||
'engine_code': engine_code,
|
||||
'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()
|
||||
master.close()
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
States: received -> diagnosis -> waiting_parts -> repair -> quality_check -> ready -> delivered
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
from datetime import datetime
|
||||
|
||||
from services import inventory_engine
|
||||
|
||||
VALID_TRANSITIONS = {
|
||||
'received': ['diagnosis', 'cancelled'],
|
||||
'diagnosis': ['waiting_parts', 'repair', 'cancelled'],
|
||||
@@ -30,10 +33,8 @@ def _generate_order_number(conn):
|
||||
row = cur.fetchone()
|
||||
last_num = 0
|
||||
if row and row[0]:
|
||||
try:
|
||||
with contextlib.suppress(ValueError):
|
||||
last_num = int(row[0].split('-')[-1])
|
||||
except ValueError:
|
||||
pass
|
||||
new_num = last_num + 1
|
||||
cur.close()
|
||||
return f"{prefix}{new_num:04d}"
|
||||
@@ -422,7 +423,7 @@ def get_kanban_summary(conn, branch_id=None):
|
||||
GROUP BY status
|
||||
""", params)
|
||||
|
||||
summary = {status: 0 for status in VALID_TRANSITIONS.keys() if status != 'cancelled'}
|
||||
summary = {status: 0 for status in VALID_TRANSITIONS if status != 'cancelled'}
|
||||
for r in cur.fetchall():
|
||||
summary[r[0]] = r[1]
|
||||
|
||||
@@ -438,3 +439,413 @@ def get_kanban_summary(conn, branch_id=None):
|
||||
cur.close()
|
||||
summary['overdue'] = overdue
|
||||
return summary
|
||||
|
||||
|
||||
# ─── Workshop inventory integration ─────────────────────────────────────────
|
||||
|
||||
|
||||
def reserve_item(conn, so_item_id, branch_id, employee_id=None):
|
||||
"""Reserve inventory for a service order item.
|
||||
|
||||
Records a negative SO_RESERVE operation and updates reserved_quantity.
|
||||
Raises ValueError if stock is insufficient.
|
||||
"""
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT soi.service_order_id, soi.inventory_id, soi.quantity, soi.status,
|
||||
so.order_number
|
||||
FROM service_order_items soi
|
||||
JOIN service_orders so ON so.id = soi.service_order_id
|
||||
WHERE soi.id = %s
|
||||
""",
|
||||
(so_item_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
cur.close()
|
||||
raise ValueError("Service order item not found")
|
||||
|
||||
so_id, inventory_id, quantity, status, order_number = row
|
||||
if status == "cancelled":
|
||||
cur.close()
|
||||
raise ValueError("Cannot reserve a cancelled item")
|
||||
if not inventory_id:
|
||||
cur.close()
|
||||
raise ValueError("Item has no inventory linked")
|
||||
|
||||
qty = int(quantity)
|
||||
available = inventory_engine.get_stock(conn, inventory_id, branch_id)
|
||||
if available < qty:
|
||||
cur.close()
|
||||
raise ValueError(f"Insufficient stock. Available: {available}, requested: {qty}")
|
||||
|
||||
inventory_engine.record_operation(
|
||||
conn,
|
||||
inventory_id,
|
||||
branch_id,
|
||||
"SO_RESERVE",
|
||||
-qty,
|
||||
reference_id=so_id,
|
||||
reference_type="service_order_item",
|
||||
notes=f"Reserva orden {order_number}",
|
||||
employee_id=employee_id,
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"UPDATE service_order_items SET reserved_quantity = %s WHERE id = %s",
|
||||
(qty, so_item_id),
|
||||
)
|
||||
conn.commit()
|
||||
cur.close()
|
||||
return {"reserved": qty}
|
||||
|
||||
|
||||
def release_item(conn, so_item_id, employee_id=None):
|
||||
"""Release a previous reservation for a service order item.
|
||||
|
||||
Records a positive SO_RELEASE operation and resets reserved_quantity.
|
||||
"""
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT soi.service_order_id, soi.inventory_id, soi.reserved_quantity,
|
||||
so.branch_id, so.order_number
|
||||
FROM service_order_items soi
|
||||
JOIN service_orders so ON so.id = soi.service_order_id
|
||||
WHERE soi.id = %s
|
||||
""",
|
||||
(so_item_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
cur.close()
|
||||
raise ValueError("Service order item not found")
|
||||
|
||||
so_id, inventory_id, reserved_qty, branch_id, order_number = row
|
||||
if not inventory_id or not reserved_qty:
|
||||
cur.close()
|
||||
return {"released": 0}
|
||||
|
||||
qty = int(reserved_qty)
|
||||
inventory_engine.record_operation(
|
||||
conn,
|
||||
inventory_id,
|
||||
branch_id,
|
||||
"SO_RELEASE",
|
||||
qty,
|
||||
reference_id=so_id,
|
||||
reference_type="service_order_item",
|
||||
notes=f"Liberacion reserva orden {order_number}",
|
||||
employee_id=employee_id,
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"UPDATE service_order_items SET reserved_quantity = 0 WHERE id = %s",
|
||||
(so_item_id,),
|
||||
)
|
||||
conn.commit()
|
||||
cur.close()
|
||||
return {"released": qty}
|
||||
|
||||
|
||||
def _consume_item_inventory(conn, so_item, sale_id, order_number, branch_id, employee_id=None):
|
||||
"""Release reservation and record final SALE for a service order item."""
|
||||
inventory_id = so_item.get("inventory_id")
|
||||
reserved_qty = so_item.get("reserved_quantity", 0)
|
||||
qty = int(so_item.get("quantity", 0))
|
||||
if not inventory_id or qty <= 0:
|
||||
return
|
||||
|
||||
if reserved_qty:
|
||||
inventory_engine.record_operation(
|
||||
conn,
|
||||
inventory_id,
|
||||
branch_id,
|
||||
"SO_RELEASE",
|
||||
int(reserved_qty),
|
||||
reference_id=so_item.get("service_order_id"),
|
||||
reference_type="service_order",
|
||||
notes=f"Liberacion para venta orden {order_number}",
|
||||
employee_id=employee_id,
|
||||
)
|
||||
|
||||
inventory_engine.record_operation(
|
||||
conn,
|
||||
inventory_id,
|
||||
branch_id,
|
||||
"SALE",
|
||||
-qty,
|
||||
reference_id=sale_id,
|
||||
reference_type="sale",
|
||||
notes=f"Venta desde orden {order_number}",
|
||||
employee_id=employee_id,
|
||||
)
|
||||
|
||||
|
||||
def convert_to_sale(conn, so_id, sale_data, employee_id=None):
|
||||
"""Convert a service order into a POS sale.
|
||||
|
||||
sale_data keys:
|
||||
payment_method: 'efectivo' | 'transferencia' | 'tarjeta' | 'mixto'
|
||||
sale_type: 'cash' | 'credit' | 'mixed'
|
||||
register_id: int (optional)
|
||||
amount_paid: float (optional)
|
||||
payment_details: list (optional)
|
||||
notes: str (optional)
|
||||
|
||||
Returns dict with sale_id, total, items_count.
|
||||
"""
|
||||
cur = conn.cursor()
|
||||
so = get_service_order(conn, so_id)
|
||||
if not so:
|
||||
cur.close()
|
||||
raise ValueError("Service order not found")
|
||||
if so["status"] == "cancelled":
|
||||
cur.close()
|
||||
raise ValueError("Cannot convert a cancelled service order")
|
||||
if so.get("sale_id"):
|
||||
cur.close()
|
||||
raise ValueError("Service order already converted to sale")
|
||||
|
||||
branch_id = so["branch_id"]
|
||||
customer_id = so["customer_id"]
|
||||
|
||||
# Build sale items from SO parts and labor
|
||||
sale_items = []
|
||||
for item in so.get("items", []):
|
||||
if item.get("status") == "cancelled":
|
||||
continue
|
||||
qty = int(item.get("quantity", 1))
|
||||
unit_price = float(item.get("unit_price") or 0)
|
||||
unit_cost = float(item.get("unit_cost") or 0)
|
||||
sale_items.append(
|
||||
{
|
||||
"inventory_id": item.get("inventory_id"),
|
||||
"part_number": item.get("part_number") or "PART",
|
||||
"name": item.get("name") or "Refaccion",
|
||||
"quantity": qty,
|
||||
"unit_price": unit_price,
|
||||
"unit_cost": unit_cost,
|
||||
"tax_rate": 0.16,
|
||||
}
|
||||
)
|
||||
|
||||
for labor in so.get("labor", []):
|
||||
if labor.get("status") == "cancelled":
|
||||
continue
|
||||
sale_items.append(
|
||||
{
|
||||
"inventory_id": None,
|
||||
"part_number": "SERV",
|
||||
"name": labor.get("description") or "Mano de obra",
|
||||
"quantity": 1,
|
||||
"unit_price": float(labor.get("total_cost") or 0),
|
||||
"unit_cost": 0,
|
||||
"tax_rate": 0.16,
|
||||
}
|
||||
)
|
||||
|
||||
if not sale_items:
|
||||
cur.close()
|
||||
raise ValueError("No items or labor to invoice")
|
||||
|
||||
# Calculate totals
|
||||
subtotal = 0.0
|
||||
tax_total = 0.0
|
||||
for item in sale_items:
|
||||
item_subtotal = item["quantity"] * item["unit_price"]
|
||||
item_tax = item_subtotal * item["tax_rate"]
|
||||
item["subtotal"] = item_subtotal
|
||||
item["tax_amount"] = item_tax
|
||||
subtotal += item_subtotal
|
||||
tax_total += item_tax
|
||||
|
||||
total = subtotal + tax_total
|
||||
|
||||
payment_method = sale_data.get("payment_method", "efectivo")
|
||||
sale_type = sale_data.get("sale_type", "cash")
|
||||
register_id = sale_data.get("register_id")
|
||||
amount_paid = float(sale_data.get("amount_paid", total if sale_type == "cash" else 0))
|
||||
change_given = max(amount_paid - total, 0) if sale_type == "cash" and payment_method == "efectivo" else 0
|
||||
notes = sale_data.get("notes") or f"Orden de servicio {so['order_number']}"
|
||||
|
||||
metodo_pago_sat = "PPD" if sale_type == "credit" else "PUE"
|
||||
forma_pago_map = {"efectivo": "01", "transferencia": "03", "tarjeta": "04", "mixto": "99"}
|
||||
forma_pago_sat = forma_pago_map.get(payment_method, "99")
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO sales
|
||||
(branch_id, customer_id, employee_id, register_id, sale_type,
|
||||
payment_method, subtotal, discount_total, tax_total, total,
|
||||
amount_paid, change_given, metodo_pago_sat, forma_pago_sat,
|
||||
status, notes)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'completed', %s)
|
||||
RETURNING id, created_at
|
||||
""",
|
||||
(
|
||||
branch_id,
|
||||
customer_id,
|
||||
employee_id,
|
||||
register_id,
|
||||
sale_type,
|
||||
payment_method,
|
||||
subtotal,
|
||||
0,
|
||||
tax_total,
|
||||
total,
|
||||
amount_paid,
|
||||
change_given,
|
||||
metodo_pago_sat,
|
||||
forma_pago_sat,
|
||||
notes,
|
||||
),
|
||||
)
|
||||
sale_id, _created_at = cur.fetchone()
|
||||
|
||||
# Insert sale_items
|
||||
for item in sale_items:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO sale_items
|
||||
(sale_id, inventory_id, part_number, name, quantity,
|
||||
unit_price, unit_cost, tax_rate, tax_amount, subtotal)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
""",
|
||||
(
|
||||
sale_id,
|
||||
item["inventory_id"],
|
||||
item["part_number"],
|
||||
item["name"],
|
||||
item["quantity"],
|
||||
item["unit_price"],
|
||||
item["unit_cost"],
|
||||
item["tax_rate"],
|
||||
item["tax_amount"],
|
||||
item["subtotal"],
|
||||
),
|
||||
)
|
||||
|
||||
# Consume inventory for parts
|
||||
for item in so.get("items", []):
|
||||
if item.get("status") == "cancelled":
|
||||
continue
|
||||
_consume_item_inventory(
|
||||
conn, item, sale_id, so["order_number"], branch_id, employee_id
|
||||
)
|
||||
|
||||
# Link order to sale
|
||||
cur.execute("UPDATE service_orders SET sale_id = %s WHERE id = %s", (sale_id, so_id))
|
||||
|
||||
conn.commit()
|
||||
cur.close()
|
||||
return {"sale_id": sale_id, "total": total, "items_count": len(sale_items)}
|
||||
|
||||
|
||||
def assign_mechanic(conn, so_id, employee_id):
|
||||
"""Assign a mechanic/technician to a service order."""
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT id FROM service_orders WHERE id = %s", (so_id,))
|
||||
if not cur.fetchone():
|
||||
cur.close()
|
||||
raise ValueError("Service order not found")
|
||||
|
||||
cur.execute(
|
||||
"UPDATE service_orders SET employee_id = %s WHERE id = %s",
|
||||
(employee_id, so_id),
|
||||
)
|
||||
conn.commit()
|
||||
cur.close()
|
||||
return {"employee_id": employee_id}
|
||||
|
||||
|
||||
# ─── Service catalog (reusable labor concepts) ───────────────────────────────
|
||||
|
||||
|
||||
def list_service_catalog(conn, active_only=True):
|
||||
"""List reusable labor/service concepts."""
|
||||
cur = conn.cursor()
|
||||
where = "WHERE is_active = true" if active_only else ""
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT id, tenant_id, name, description, suggested_hours, suggested_rate,
|
||||
is_active, created_at, updated_at
|
||||
FROM service_catalog
|
||||
{where}
|
||||
ORDER BY name
|
||||
"""
|
||||
)
|
||||
items = []
|
||||
for r in cur.fetchall():
|
||||
items.append(
|
||||
{
|
||||
"id": r[0],
|
||||
"tenant_id": r[1],
|
||||
"name": r[2],
|
||||
"description": r[3],
|
||||
"suggested_hours": float(r[4]) if r[4] else 0,
|
||||
"suggested_rate": float(r[5]) if r[5] else 0,
|
||||
"is_active": r[6],
|
||||
"created_at": str(r[7]) if r[7] else None,
|
||||
"updated_at": str(r[8]) if r[8] else None,
|
||||
}
|
||||
)
|
||||
cur.close()
|
||||
return items
|
||||
|
||||
|
||||
def create_service_catalog_item(conn, tenant_id, data):
|
||||
"""Create a reusable labor concept."""
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO service_catalog
|
||||
(tenant_id, name, description, suggested_hours, suggested_rate, is_active)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
tenant_id,
|
||||
data.get("name"),
|
||||
data.get("description"),
|
||||
data.get("suggested_hours", 0),
|
||||
data.get("suggested_rate", 0),
|
||||
data.get("is_active", True),
|
||||
),
|
||||
)
|
||||
item_id = cur.fetchone()[0]
|
||||
conn.commit()
|
||||
cur.close()
|
||||
return {"id": item_id}
|
||||
|
||||
|
||||
def update_service_catalog_item(conn, item_id, data):
|
||||
"""Update a reusable labor concept."""
|
||||
cur = conn.cursor()
|
||||
allowed = ["name", "description", "suggested_hours", "suggested_rate", "is_active"]
|
||||
sets = []
|
||||
vals = []
|
||||
for field in allowed:
|
||||
if field in data:
|
||||
sets.append(f"{field} = %s")
|
||||
vals.append(data[field])
|
||||
if not sets:
|
||||
cur.close()
|
||||
return False
|
||||
vals.append(item_id)
|
||||
cur.execute(f"UPDATE service_catalog SET {', '.join(sets)} WHERE id = %s", vals)
|
||||
conn.commit()
|
||||
cur.close()
|
||||
return True
|
||||
|
||||
|
||||
def delete_service_catalog_item(conn, item_id):
|
||||
"""Soft-delete a reusable labor concept by setting is_active = false."""
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"UPDATE service_catalog SET is_active = false WHERE id = %s", (item_id,)
|
||||
)
|
||||
conn.commit()
|
||||
cur.close()
|
||||
return True
|
||||
|
||||
@@ -204,3 +204,140 @@ def _total_line(label, amount, width):
|
||||
"""Format a totals line like 'Subtotal: $1,234.56'."""
|
||||
val = f'${abs(amount):,.2f}' if amount >= 0 else f'-${abs(amount):,.2f}'
|
||||
return _format_line(label, val, width) + '\n'
|
||||
|
||||
|
||||
def generate_service_order_ticket(so_data, business_info, width=80):
|
||||
"""Generate ESC/POS bytes for a workshop service order ticket.
|
||||
|
||||
Args:
|
||||
so_data: dict with service order info:
|
||||
order_number, status, customer_name, vehicle_plate, vehicle_make,
|
||||
vehicle_model, mileage_in, fuel_level, reception_notes,
|
||||
employee_name, created_at, items[{name, part_number, quantity,
|
||||
unit_price}], labor[{description, hours, hourly_rate, total_cost}],
|
||||
estimated_cost, total
|
||||
business_info: dict with name, rfc, address
|
||||
width: 58 or 80 (mm)
|
||||
|
||||
Returns: bytes ready to send to printer
|
||||
"""
|
||||
chars = 32 if width == 58 else 48
|
||||
buf = bytearray()
|
||||
buf += INIT
|
||||
|
||||
# Header
|
||||
buf += ALIGN_CENTER
|
||||
buf += LARGE_SIZE
|
||||
buf += (business_info.get("name", "NEXUS POS") + "\n").encode("cp437", errors="replace")
|
||||
buf += NORMAL_SIZE
|
||||
if business_info.get("rfc"):
|
||||
buf += (business_info["rfc"] + "\n").encode("cp437", errors="replace")
|
||||
if business_info.get("address"):
|
||||
buf += (business_info["address"] + "\n").encode("cp437", errors="replace")
|
||||
buf += b"\n"
|
||||
|
||||
# Title
|
||||
buf += BOLD_ON + DOUBLE_HEIGHT
|
||||
buf += "ORDEN DE SERVICIO\n".encode("cp437", errors="replace")
|
||||
buf += NORMAL_SIZE + BOLD_OFF
|
||||
buf += b"\n"
|
||||
|
||||
# Order info
|
||||
buf += ALIGN_LEFT
|
||||
buf += BOLD_ON
|
||||
buf += f"Folio: {so_data.get('order_number', 'N/A')}\n".encode("cp437", errors="replace")
|
||||
buf += BOLD_OFF
|
||||
buf += f"Estado: {so_data.get('status', '')}\n".encode("cp437", errors="replace")
|
||||
buf += f"Fecha: {str(so_data.get('created_at', ''))[:19]}\n".encode("cp437", errors="replace")
|
||||
if so_data.get("employee_name"):
|
||||
buf += f"Mecanico: {so_data['employee_name']}\n".encode("cp437", errors="replace")
|
||||
buf += ("-" * chars + "\n").encode()
|
||||
|
||||
# Customer / vehicle
|
||||
if so_data.get("customer_name"):
|
||||
buf += BOLD_ON
|
||||
buf += f"Cliente: {so_data['customer_name']}\n".encode("cp437", errors="replace")
|
||||
buf += BOLD_OFF
|
||||
vehicle = " ".join(
|
||||
str(v) for v in [
|
||||
so_data.get("vehicle_plate", ""),
|
||||
so_data.get("vehicle_make", ""),
|
||||
so_data.get("vehicle_model", ""),
|
||||
] if v
|
||||
).strip()
|
||||
if vehicle:
|
||||
buf += f"Vehiculo: {vehicle}\n".encode("cp437", errors="replace")
|
||||
if so_data.get("mileage_in"):
|
||||
buf += f"Kilometraje: {so_data['mileage_in']}\n".encode("cp437", errors="replace")
|
||||
if so_data.get("fuel_level"):
|
||||
buf += f"Gasolina: {so_data['fuel_level']}\n".encode("cp437", errors="replace")
|
||||
buf += ("-" * chars + "\n").encode()
|
||||
|
||||
# Reception notes
|
||||
if so_data.get("reception_notes"):
|
||||
buf += BOLD_ON
|
||||
buf += "Falla / Observaciones:\n".encode("cp437", errors="replace")
|
||||
buf += BOLD_OFF
|
||||
for line in str(so_data["reception_notes"]).splitlines():
|
||||
buf += (line[:chars] + "\n").encode("cp437", errors="replace")
|
||||
buf += ("-" * chars + "\n").encode()
|
||||
|
||||
# Parts
|
||||
items = so_data.get("items", [])
|
||||
if items:
|
||||
buf += BOLD_ON
|
||||
buf += "REFACCIONES\n".encode("cp437", errors="replace")
|
||||
buf += BOLD_OFF
|
||||
for item in items:
|
||||
name = item.get("name", "")[:chars - 10]
|
||||
part_no = item.get("part_number", "")
|
||||
qty = item.get("quantity", 1)
|
||||
unit_price = item.get("unit_price", 0)
|
||||
line_total = qty * unit_price
|
||||
buf += f"{qty}x {name}\n".encode("cp437", errors="replace")
|
||||
if part_no:
|
||||
buf += f" #{part_no}\n".encode("cp437", errors="replace")
|
||||
buf += ALIGN_RIGHT
|
||||
buf += f"${line_total:,.2f}\n".encode("cp437", errors="replace")
|
||||
buf += ALIGN_LEFT
|
||||
buf += ("-" * chars + "\n").encode()
|
||||
|
||||
# Labor
|
||||
labor_items = so_data.get("labor", [])
|
||||
if labor_items:
|
||||
buf += BOLD_ON
|
||||
buf += "MANO DE OBRA\n".encode("cp437", errors="replace")
|
||||
buf += BOLD_OFF
|
||||
for labor in labor_items:
|
||||
desc = labor.get("description", "")[:chars - 10]
|
||||
hours = labor.get("hours", 0)
|
||||
rate = labor.get("hourly_rate", 0)
|
||||
total = labor.get("total_cost", hours * rate)
|
||||
buf += f"{desc}\n".encode("cp437", errors="replace")
|
||||
buf += f" {hours} hrs x ${rate:,.2f}\n".encode("cp437", errors="replace")
|
||||
buf += ALIGN_RIGHT
|
||||
buf += f"${total:,.2f}\n".encode("cp437", errors="replace")
|
||||
buf += ALIGN_LEFT
|
||||
buf += ("-" * chars + "\n").encode()
|
||||
|
||||
# Totals
|
||||
buf += ALIGN_RIGHT
|
||||
if items or labor_items:
|
||||
total = so_data.get("total") or sum(
|
||||
i.get("quantity", 1) * i.get("unit_price", 0) for i in items
|
||||
) + sum(labor.get("total_cost", 0) for labor in labor_items)
|
||||
buf += BOLD_ON + DOUBLE_HEIGHT
|
||||
buf += _total_line("TOTAL ESTIMADO:", total, chars).encode("cp437", errors="replace")
|
||||
buf += NORMAL_SIZE + BOLD_OFF
|
||||
if so_data.get("estimated_cost"):
|
||||
buf += _total_line("Costo estimado:", so_data["estimated_cost"], chars).encode("cp437", errors="replace")
|
||||
|
||||
# Footer
|
||||
buf += b"\n"
|
||||
buf += ALIGN_CENTER
|
||||
buf += "No es comprobante fiscal\n".encode("cp437", errors="replace")
|
||||
buf += "Nexus Autoparts POS\n".encode("cp437", errors="replace")
|
||||
buf += b"\n\n\n"
|
||||
buf += PARTIAL_CUT
|
||||
|
||||
return bytes(buf)
|
||||
|
||||
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,))
|
||||
tenant_conn.commit()
|
||||
cur.close()
|
||||
clear_last_shown(phone)
|
||||
clear_last_shown(tenant_conn, phone)
|
||||
return qid
|
||||
|
||||
|
||||
@@ -135,41 +135,34 @@ def _ensure_sessions_table(tenant_conn):
|
||||
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.
|
||||
|
||||
part_info: dict with keys inventory_id, part_number, name, brand,
|
||||
price, stock, unit
|
||||
"""
|
||||
# In-memory fallback for when tenant_conn is not available
|
||||
from tenant_db import get_tenant_conn
|
||||
try:
|
||||
conn = get_tenant_conn(11)
|
||||
_ensure_sessions_table(conn)
|
||||
cur = conn.cursor()
|
||||
_ensure_sessions_table(tenant_conn)
|
||||
cur = tenant_conn.cursor()
|
||||
import json
|
||||
cur.execute("""
|
||||
INSERT INTO whatsapp_sessions (phone, last_shown, updated_at)
|
||||
VALUES (%s, %s, NOW())
|
||||
ON CONFLICT (phone) DO UPDATE SET last_shown = EXCLUDED.last_shown, updated_at = NOW()
|
||||
""", (phone, json.dumps(part_info)))
|
||||
conn.commit()
|
||||
tenant_conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
print(f"[WA-SESSION] Failed to persist last_shown for {phone}: {e}")
|
||||
|
||||
|
||||
def get_last_shown_part(phone):
|
||||
from tenant_db import get_tenant_conn
|
||||
def get_last_shown_part(tenant_conn, phone):
|
||||
try:
|
||||
conn = get_tenant_conn(11)
|
||||
_ensure_sessions_table(conn)
|
||||
cur = conn.cursor()
|
||||
_ensure_sessions_table(tenant_conn)
|
||||
cur = tenant_conn.cursor()
|
||||
cur.execute("SELECT last_shown FROM whatsapp_sessions WHERE phone = %s", (phone,))
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
conn.close()
|
||||
if row and row[0]:
|
||||
return row[0]
|
||||
except Exception as e:
|
||||
@@ -177,54 +170,45 @@ def get_last_shown_part(phone):
|
||||
return None
|
||||
|
||||
|
||||
def clear_last_shown(phone):
|
||||
from tenant_db import get_tenant_conn
|
||||
def clear_last_shown(tenant_conn, phone):
|
||||
try:
|
||||
conn = get_tenant_conn(11)
|
||||
_ensure_sessions_table(conn)
|
||||
cur = conn.cursor()
|
||||
_ensure_sessions_table(tenant_conn)
|
||||
cur = tenant_conn.cursor()
|
||||
cur.execute("DELETE FROM whatsapp_sessions WHERE phone = %s", (phone,))
|
||||
conn.commit()
|
||||
tenant_conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
except Exception as 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.
|
||||
|
||||
vehicle: dict with keys brand, model, year
|
||||
"""
|
||||
from tenant_db import get_tenant_conn
|
||||
try:
|
||||
conn = get_tenant_conn(11)
|
||||
_ensure_sessions_table(conn)
|
||||
cur = conn.cursor()
|
||||
_ensure_sessions_table(tenant_conn)
|
||||
cur = tenant_conn.cursor()
|
||||
import json
|
||||
cur.execute("""
|
||||
INSERT INTO whatsapp_sessions (phone, vehicle, updated_at)
|
||||
VALUES (%s, %s, NOW())
|
||||
ON CONFLICT (phone) DO UPDATE SET vehicle = EXCLUDED.vehicle, updated_at = NOW()
|
||||
""", (phone, json.dumps(vehicle)))
|
||||
conn.commit()
|
||||
tenant_conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
except Exception as 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."""
|
||||
from tenant_db import get_tenant_conn
|
||||
try:
|
||||
conn = get_tenant_conn(11)
|
||||
_ensure_sessions_table(conn)
|
||||
cur = conn.cursor()
|
||||
_ensure_sessions_table(tenant_conn)
|
||||
cur = tenant_conn.cursor()
|
||||
cur.execute("SELECT vehicle FROM whatsapp_sessions WHERE phone = %s", (phone,))
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
conn.close()
|
||||
if row and row[0]:
|
||||
return row[0]
|
||||
except Exception as e:
|
||||
@@ -232,17 +216,14 @@ def get_vehicle(phone):
|
||||
return None
|
||||
|
||||
|
||||
def clear_session(phone):
|
||||
def clear_session(tenant_conn, phone):
|
||||
"""Clear all session data (last_shown + vehicle) for this phone."""
|
||||
from tenant_db import get_tenant_conn
|
||||
try:
|
||||
conn = get_tenant_conn(11)
|
||||
_ensure_sessions_table(conn)
|
||||
cur = conn.cursor()
|
||||
_ensure_sessions_table(tenant_conn)
|
||||
cur = tenant_conn.cursor()
|
||||
cur.execute("DELETE FROM whatsapp_sessions WHERE phone = %s", (phone,))
|
||||
conn.commit()
|
||||
tenant_conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
except Exception as 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,))
|
||||
tenant_conn.commit()
|
||||
cur.close()
|
||||
clear_last_shown(phone)
|
||||
clear_last_shown(tenant_conn, phone)
|
||||
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
|
||||
@@ -88,9 +88,19 @@ def process_incoming(webhook_data):
|
||||
key = data.get('key', {})
|
||||
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', '')
|
||||
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 old parsing if they're missing (older bridge version).
|
||||
@@ -105,6 +115,7 @@ def process_incoming(webhook_data):
|
||||
# - For 'text' messages → conversation or extendedTextMessage
|
||||
# - For 'image'/'video' → the caption (may be empty)
|
||||
# - For 'audio' → empty (filled in later by Whisper transcription)
|
||||
# - For 'location' → synthetic text with coordinates
|
||||
if media_kind == 'text':
|
||||
text = (
|
||||
message.get('conversation', '')
|
||||
@@ -114,9 +125,14 @@ def process_incoming(webhook_data):
|
||||
else:
|
||||
text = media_caption
|
||||
|
||||
# Location fields (from bridge classification)
|
||||
latitude = data.get('latitude')
|
||||
longitude = data.get('longitude')
|
||||
|
||||
return {
|
||||
'phone': phone,
|
||||
'jid': remote_jid,
|
||||
'sender_pn': sender_pn,
|
||||
'text': text,
|
||||
'from_me': key.get('fromMe', False),
|
||||
'message_id': key.get('id', ''),
|
||||
@@ -125,4 +141,20 @@ def process_incoming(webhook_data):
|
||||
'media_mimetype': media_mimetype,
|
||||
'is_voice_note': is_voice_note,
|
||||
'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);
|
||||
color: var(--color-text-primary);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -326,6 +324,7 @@
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
margin-left: 260px;
|
||||
}
|
||||
|
||||
.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);
|
||||
color: var(--color-text-primary);
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,8 +12,6 @@
|
||||
font-size: var(--text-body);
|
||||
color: var(--color-text-primary);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -76,7 +74,7 @@
|
||||
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 */
|
||||
.content-header {
|
||||
@@ -90,7 +88,8 @@
|
||||
.breadcrumb__link:hover { color: var(--color-primary); }
|
||||
.breadcrumb__sep { color: var(--color-text-disabled); }
|
||||
.breadcrumb__current { color: var(--color-text-primary); font-weight: var(--font-weight-semibold); }
|
||||
|
||||
.breadcrumb__back { display: inline-flex; align-items: center; gap: 4px; padding: 2px 10px; background: transparent; border: 1px solid var(--color-border); border-radius: var(--radius-sm); color: var(--color-text-muted); font-size: var(--text-body-sm); cursor: pointer; transition: var(--transition-fast); }
|
||||
.breadcrumb__back:hover { background: var(--color-primary-muted); color: var(--color-primary); }
|
||||
.header-actions { display: flex; align-items: center; gap: var(--space-3); }
|
||||
|
||||
/* ── Catalog mode toggle (OEM / Local) ── */
|
||||
@@ -371,6 +370,22 @@
|
||||
.alt-item__mfr { font-size: var(--text-caption); color: var(--color-text-muted); }
|
||||
.alt-item__stock { font-size: var(--text-caption); }
|
||||
|
||||
/* Compatible vehicles pagination */
|
||||
.compat-pager {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: var(--space-2); margin-top: var(--space-4); padding-top: var(--space-3);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
.compat-pager__btn {
|
||||
font-family: inherit; font-size: var(--text-caption); font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary); background: var(--color-surface-2);
|
||||
border: 1px solid var(--color-border); border-radius: var(--radius-sm);
|
||||
padding: var(--space-2) var(--space-3); cursor: pointer; white-space: nowrap;
|
||||
}
|
||||
.compat-pager__btn:hover:not(:disabled) { background: var(--color-surface-3, var(--color-surface-2)); }
|
||||
.compat-pager__btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.compat-pager__info { font-size: var(--text-caption); color: var(--color-text-muted); text-align: center; flex: 1; }
|
||||
|
||||
/* Add to cart section */
|
||||
.detail-footer {
|
||||
padding: var(--space-4) var(--space-5); border-top: 1px solid var(--color-border);
|
||||
|
||||
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);
|
||||
color: var(--color-text-primary);
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,8 +19,6 @@
|
||||
font-size: var(--text-body-sm);
|
||||
color: var(--color-text-primary);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -326,6 +324,7 @@
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
margin-left: 260px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
@@ -1028,6 +1027,7 @@
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: var(--space-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.device-card {
|
||||
@@ -1040,6 +1040,11 @@
|
||||
gap: var(--space-4);
|
||||
box-shadow: var(--shadow-sm);
|
||||
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 {
|
||||
@@ -1072,7 +1077,7 @@
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.device-card__body { flex: 1; }
|
||||
.device-card__body { flex: 1; min-width: 0; overflow-wrap: break-word; }
|
||||
|
||||
.device-card__name {
|
||||
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);
|
||||
color: var(--color-text-primary);
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -20,8 +20,6 @@
|
||||
font-weight: var(--font-weight-regular);
|
||||
background-color: var(--color-bg-base);
|
||||
color: var(--color-text-primary);
|
||||
transition: background-color var(--duration-normal) var(--ease-in-out),
|
||||
color var(--duration-normal) var(--ease-in-out);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
@@ -340,6 +338,7 @@
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
margin-left: 260px;
|
||||
}
|
||||
|
||||
/* Page header */
|
||||
|
||||
2
pos/static/css/customers.min.css
vendored
2
pos/static/css/customers.min.css
vendored
@@ -20,8 +20,6 @@
|
||||
font-weight: var(--font-weight-regular);
|
||||
background-color: var(--color-bg-base);
|
||||
color: var(--color-text-primary);
|
||||
transition: background-color var(--duration-normal) var(--ease-in-out),
|
||||
color var(--duration-normal) var(--ease-in-out);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -23,8 +23,6 @@
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: background-color var(--duration-normal) var(--ease-in-out),
|
||||
color var(--duration-normal) var(--ease-in-out);
|
||||
}
|
||||
|
||||
/* Modern theme dot-grid on body */
|
||||
@@ -296,7 +294,7 @@
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
margin-left: 220px;
|
||||
margin-left: 260px;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -814,6 +812,18 @@
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.chart-canvas-wrap {
|
||||
position: relative;
|
||||
height: 220px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chart-canvas-wrap canvas {
|
||||
display: block;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.rank-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
4
pos/static/css/dashboard.min.css
vendored
4
pos/static/css/dashboard.min.css
vendored
@@ -23,8 +23,6 @@
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: background-color var(--duration-normal) var(--ease-in-out),
|
||||
color var(--duration-normal) var(--ease-in-out);
|
||||
}
|
||||
|
||||
/* Modern theme dot-grid on body */
|
||||
@@ -296,7 +294,7 @@
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
margin-left: 220px;
|
||||
margin-left: 260px;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -12,8 +12,6 @@
|
||||
font-size: var(--text-body);
|
||||
color: var(--color-text-primary);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -76,6 +74,7 @@
|
||||
.main-content {
|
||||
flex: 1; display: flex; flex-direction: column; overflow: hidden;
|
||||
min-width: 0;
|
||||
margin-left: 260px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
|
||||
2
pos/static/css/diagrams.min.css
vendored
2
pos/static/css/diagrams.min.css
vendored
@@ -12,8 +12,6 @@
|
||||
font-size: var(--text-body);
|
||||
color: var(--color-text-primary);
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,8 +19,6 @@
|
||||
font-size: var(--text-body-sm);
|
||||
color: var(--color-text-primary);
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
2
pos/static/css/fleet.min.css
vendored
2
pos/static/css/fleet.min.css
vendored
@@ -19,8 +19,6 @@
|
||||
font-size: var(--text-body-sm);
|
||||
color: var(--color-text-primary);
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,8 +19,6 @@
|
||||
font-size: var(--text-body-sm);
|
||||
color: var(--color-text-primary);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -332,6 +330,7 @@
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
margin-left: 260px;
|
||||
}
|
||||
|
||||
/* ---- Page Header ---- */
|
||||
@@ -731,6 +730,20 @@
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.btn--meli {
|
||||
background: #FFE600;
|
||||
color: #2D3277;
|
||||
border-color: transparent;
|
||||
font-weight: 700;
|
||||
}
|
||||
.btn--meli:hover {
|
||||
background: #e6cf00;
|
||||
color: #1a1f5c;
|
||||
}
|
||||
.btn--meli svg {
|
||||
stroke: currentColor;
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
DATA TABLE
|
||||
========================================================================= */
|
||||
@@ -1260,7 +1273,7 @@
|
||||
.inv-field label {
|
||||
font-size: var(--text-caption);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-muted);
|
||||
color: var(--color-text-secondary);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
@@ -1281,6 +1294,23 @@
|
||||
box-shadow: 0 0 0 2px var(--color-primary-muted);
|
||||
}
|
||||
|
||||
.inv-field select {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--color-surface-1);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text-primary);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-body-sm);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.inv-field select:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 2px var(--color-primary-muted);
|
||||
}
|
||||
|
||||
.count-row {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
@@ -1300,3 +1330,129 @@
|
||||
|
||||
/* History table inside modal */
|
||||
.inv-modal .data-table { width: 100%; }
|
||||
|
||||
/* ─── Virtual Scroll fixes ───────────────────────────────────────────── */
|
||||
.vs-container {
|
||||
will-change: transform;
|
||||
contain: layout paint;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.vs-container table {
|
||||
will-change: transform;
|
||||
}
|
||||
.vs-container tbody tr {
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: auto 48px;
|
||||
}
|
||||
|
||||
/* ─── MercadoLibre Publish Modal Enhancements ────────────────────────── */
|
||||
.meli-preview-card {
|
||||
display: grid;
|
||||
grid-template-columns: 56px 1fr auto auto auto;
|
||||
gap: var(--space-3);
|
||||
align-items: center;
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: var(--space-2);
|
||||
background: var(--color-surface-1);
|
||||
}
|
||||
.meli-preview-card img {
|
||||
width: 56px; height: 56px; object-fit: cover; border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-2);
|
||||
}
|
||||
.meli-preview-card .meli-title-input {
|
||||
width: 100%;
|
||||
background: var(--color-surface-2);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-primary);
|
||||
padding: 4px 8px;
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
.meli-preview-card .meli-num-input {
|
||||
width: 80px;
|
||||
background: var(--color-surface-2);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-primary);
|
||||
padding: 4px 8px;
|
||||
font-size: var(--text-caption);
|
||||
text-align: right;
|
||||
}
|
||||
.meli-check { font-size: var(--text-caption); display: flex; align-items: center; gap: 4px; }
|
||||
.meli-check.ok { color: var(--color-success); }
|
||||
.meli-check.fail { color: var(--color-error); }
|
||||
.meli-checks-row {
|
||||
display: flex; gap: var(--space-3); flex-wrap: wrap; margin-top: var(--space-1);
|
||||
}
|
||||
.meli-attrs-section {
|
||||
margin-top: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-1);
|
||||
}
|
||||
.meli-attrs-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: var(--space-3);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
.meli-img-upload {
|
||||
border: 2px dashed var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-3);
|
||||
text-align: center;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-caption);
|
||||
cursor: pointer;
|
||||
transition: border-color var(--transition-fast);
|
||||
}
|
||||
.meli-img-upload:hover { border-color: var(--color-primary); }
|
||||
.meli-img-upload input { display: none; }
|
||||
|
||||
/* ─── MercadoLibre Category Autocomplete ─────────────────────────────── */
|
||||
.meli-cat-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 200;
|
||||
background: var(--color-surface-1);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.meli-cat-item {
|
||||
padding: 10px 14px;
|
||||
cursor: pointer;
|
||||
font-size: var(--text-body-sm);
|
||||
color: var(--color-text-primary);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
transition: background var(--transition-fast);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.meli-cat-item:last-child { border-bottom: none; }
|
||||
.meli-cat-item:hover,
|
||||
.meli-cat-item.is-active {
|
||||
background: var(--color-surface-2);
|
||||
}
|
||||
.meli-cat-item .cat-id {
|
||||
font-size: var(--text-caption);
|
||||
color: var(--color-text-muted);
|
||||
margin-left: 8px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.meli-cat-loading,
|
||||
.meli-cat-empty {
|
||||
padding: 12px 14px;
|
||||
font-size: var(--text-caption);
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
2
pos/static/css/inventory.min.css
vendored
2
pos/static/css/inventory.min.css
vendored
@@ -19,8 +19,6 @@
|
||||
font-size: var(--text-body-sm);
|
||||
color: var(--color-text-primary);
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,8 +19,6 @@
|
||||
font-size: var(--text-body-sm);
|
||||
color: var(--color-text-primary);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -332,6 +330,7 @@
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
margin-left: 260px;
|
||||
}
|
||||
|
||||
/* ---- Page Header ---- */
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user