Compare commits

..

1 Commits

Author SHA1 Message Date
7f753376e3 cambios para corregir bug visual de los temas oscuro y blanco 2026-06-24 10:54:33 -06:00
238 changed files with 3524 additions and 43067 deletions

4
.gitignore vendored
View File

@@ -91,7 +91,3 @@ backups/
# Local tools (AWS CLI) # Local tools (AWS CLI)
tools/ tools/
# Rached migration session artifacts (tokens / captures / samples)
rached_*.json
rached_*.txt

View File

@@ -1,23 +0,0 @@
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch();
const page = await browser.newPage();
const pages = [
['dashboard', 'http://localhost:5000/'],
['catalog', 'http://localhost:5000/catalog'],
['tienda (capi)', 'http://localhost:5001/tienda'],
];
for (const [name, url] of pages) {
const errs = [];
const onErr = (e) => errs.push(String(e).slice(0, 140));
const onCon = (m) => { if (m.type() === 'error' && !/favicon|net::ERR|404|sourcemap/i.test(m.text())) errs.push(m.text().slice(0, 140)); };
page.on('pageerror', onErr); page.on('console', onCon);
try {
await page.goto(url, { waitUntil: 'load', timeout: 20000 });
await page.waitForTimeout(2000);
console.log((errs.length ? '✗ ' : '✓ ') + name, errs.length ? errs.slice(0, 3) : '');
} catch (e) { console.log('✗✗ ' + name + ' → ' + e.message.slice(0, 100)); }
page.removeAllListeners('pageerror'); page.removeAllListeners('console');
}
await browser.close();
})();

View File

@@ -1,10 +0,0 @@
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch();
const page = await browser.newPage();
const bad = [];
page.on('response', (r) => { if (r.status() >= 500) bad.push(r.status() + ' ' + r.url()); });
await page.goto('http://localhost:5000/', { waitUntil: 'networkidle', timeout: 25000 });
console.log('recursos con 500:', bad.length ? bad : 'ninguno');
await browser.close();
})();

View File

@@ -1,32 +0,0 @@
const { chromium } = require('playwright');
const { execSync } = require('child_process');
const JWT_SECRET = execSync("grep -oP 'POS_JWT_SECRET=\\K.*' /etc/systemd/system/nexus-pos.service | head -1").toString().trim();
function tok() {
const h = { alg: 'HS256', typ: 'JWT' };
const now = Math.floor(Date.now()/1000);
const p = { tenant_id: 32, employee_id: 1, name: 'Audit', role: 'owner', branch_id: 1,
max_discount_pct: 100, permissions: ['*'], device_id: 'a', type: 'pos_access', exp: now+900, iat: now };
const b64 = o => Buffer.from(JSON.stringify(o)).toString('base64url');
const crypto = require('crypto');
return b64(h)+'.'+b64(p)+'.'+crypto.createHmac('sha256', JWT_SECRET).update(b64(h)+'.'+b64(p)).digest('base64url');
}
(async () => {
const browser = await chromium.launch();
const page = await browser.newPage();
const errs = [];
page.on('pageerror', e => errs.push(String(e).slice(0,120)));
page.on('console', m => { if (m.type()==='error' && !/favicon|404|net::ERR|sourcemap/i.test(m.text())) errs.push(m.text().slice(0,120)); });
await page.addInitScript(t => {
localStorage.setItem('pos_token', t);
localStorage.setItem('pos_employee', JSON.stringify({id:1,name:'Audit',role:'owner'}));
localStorage.setItem('pos_tenant_id','32');
document.cookie='pos_role=owner; path=/pos; SameSite=Lax';
}, tok());
for (const p of ['/pos/fleet','/pos/supplier-catalog','/pos/historical-sales','/pos/diagrams']) {
errs.length = 0;
const resp = await page.goto('http://localhost:5001'+p, {waitUntil:'load', timeout:20000}).catch(e=>null);
await page.waitForTimeout(2000);
console.log((errs.length?'✗ ':'✓ ') + p + ' [' + (resp?resp.status():'?') + ']' + (errs.length? ' → '+errs[0]:''));
}
await browser.close();
})();

View File

@@ -1,85 +0,0 @@
#!/usr/bin/env node
/* Auditoría JS: carga cada página del POS y reporta errores de consola y handlers rotos. */
const { chromium } = require('playwright');
const { execSync } = require('child_process');
const JWT_SECRET = execSync(
"grep -oP 'POS_JWT_SECRET=\\K.*' /etc/systemd/system/nexus-pos.service | head -1"
).toString().trim();
function makeToken() {
const header = { alg: 'HS256', typ: 'JWT' };
const now = Math.floor(Date.now() / 1000);
const payload = {
tenant_id: 32, employee_id: 1, name: 'Audit Admin', role: 'owner',
branch_id: 1, max_discount_pct: 100,
permissions: ['pos.sell', 'pos.view', 'pos.remission', 'pos.discount', 'pos.cancel',
'catalog.view', 'inventory.view', 'inventory.create', 'inventory.edit',
'inventory.adjust', 'inventory.transfer', 'customers.view', 'customers.create',
'invoicing.view', 'invoicing.create', 'reports.view', 'config.view', 'config.edit',
'config.edit_prices', 'workshop.view', 'workshop.edit', 'dashboard.view'],
device_id: 'audit', type: 'pos_access', exp: now + 900, iat: now,
};
const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
const crypto = require('crypto');
const sig = crypto.createHmac('sha256', JWT_SECRET)
.update(b64(header) + '.' + b64(payload)).digest('base64url');
return b64(header) + '.' + b64(payload) + '.' + sig;
}
const PAGES = [
'/pos/sale', '/pos/inventory', '/pos/customers', '/pos/config',
'/pos/dashboard', '/pos/invoicing', '/pos/remission-notes', '/pos/catalog',
'/pos/quotations', '/pos/reports', '/pos/accounting', '/pos/workshop',
'/pos/whatsapp', '/pos/marketplace-external',
];
(async () => {
const browser = await chromium.launch();
const ctx = await browser.newContext();
const page = await ctx.newPage();
const brokenHandlers = [];
await page.addInitScript((token) => {
localStorage.setItem('pos_token', token);
localStorage.setItem('pos_employee', JSON.stringify({ id: 1, name: 'Audit Admin', role: 'owner' }));
localStorage.setItem('pos_tenant_id', '32');
document.cookie = 'pos_role=owner; path=/pos; SameSite=Lax';
// Capturar errores de handlers inline (onclick con función inexistente)
window.addEventListener('error', function (e) {
if (e.message && /is not a function|is not defined/.test(e.message)) {
(window.__broken = window.__broken || []).push(e.message + ' @ ' + location.pathname);
}
});
}, makeToken());
for (const p of PAGES) {
const errs = [];
const onConsole = (m) => {
if (m.type() === 'error') errs.push(m.text().slice(0, 160));
};
page.on('pageerror', (e) => errs.push('PAGEERROR: ' + String(e).slice(0, 160)));
page.on('console', onConsole);
try {
await page.goto('http://localhost:5001' + p, { waitUntil: 'load', timeout: 20000 });
await page.waitForTimeout(2200);
// Los errores capturados en window.__broken (handlers inline)
const inline = await page.evaluate(() => (window.__broken || []).splice(0));
const realErrs = errs.filter((t) =>
!/favicon|net::ERR|Failed to load resource|404|sourcemap|DevTools/i.test(t));
if (realErrs.length || inline.length) {
console.log('✗ ' + p);
realErrs.slice(0, 4).forEach((t) => console.log(' consola: ' + t));
inline.slice(0, 6).forEach((t) => console.log(' handler: ' + t));
} else {
console.log('✓ ' + p);
}
} catch (e) {
console.log('✗✗ ' + p + ' → ' + e.message.slice(0, 120));
}
page.removeAllListeners('console');
page.removeAllListeners('pageerror');
}
await browser.close();
})();

View File

@@ -23,7 +23,6 @@
<button data-mode="oem" onclick="setCatalogMode('oem')">OEM</button> <button data-mode="oem" onclick="setCatalogMode('oem')">OEM</button>
<button data-mode="local" onclick="setCatalogMode('local')">Local</button> <button data-mode="local" onclick="setCatalogMode('local')">Local</button>
</div> </div>
<a href="/usados" class="header-back" id="usedPartsLink" title="Partes usadas y reconstruidas">Usados</a>
<a href="/" class="header-back">&larr; Inicio</a> <a href="/" class="header-back">&larr; Inicio</a>
<button class="theme-toggle" onclick="toggleTheme()" id="themeToggle"> <button class="theme-toggle" onclick="toggleTheme()" id="themeToggle">
<span id="themeIcon">&#9790;</span> <span id="themeIcon">&#9790;</span>
@@ -87,7 +86,7 @@
})(); })();
</script> </script>
<script src="/catalog-public.js?v=2"></script> <script src="/catalog-public.js"></script>
<!-- AI Chat Widget --> <!-- AI Chat Widget -->
<link rel="stylesheet" href="/chat-public.css"> <link rel="stylesheet" href="/chat-public.css">

View File

@@ -27,7 +27,7 @@
nxPartType: null, // {slug, name} ← Nexpart part type nxPartType: null, // {slug, name} ← Nexpart part type
region: 'north-america', region: 'north-america',
mode: (localStorage.getItem('catalog_mode') === 'oem' ? 'oem' : 'local'), mode: (localStorage.getItem('catalog_mode') === 'local' ? 'local' : 'oem'),
page: 1, page: 1,
totalPages: 1, totalPages: 1,
}; };
@@ -64,7 +64,6 @@
state.brand = state.model = state.year = state.engine = null; state.brand = state.model = state.year = state.engine = null;
state.level = 'brands'; state.level = 'brands';
loadBrands(); loadBrands();
waConfig(function () {}); // preload WhatsApp contact config
}; };
// ── Region selector (global) ── // ── Region selector (global) ──
@@ -110,31 +109,6 @@
loadBrands(); loadBrands();
} }
// ── Link a la vitrina de partes usadas del tenant (si existe) ──
(function () {
var link = document.getElementById('usedPartsLink');
if (!link) return;
var parts = window.location.hostname.split('.');
var sub = (parts.length >= 3) ? parts[0]
: (parts.length === 2 && parts[1] === 'localhost') ? parts[0] : '';
var reserved = ['www', 'api', 'admin', 'mail', 'staging', 'dev', 'nexus', 'pos', 'app', 'dashboard'];
if (reserved.indexOf(sub) !== -1) sub = '';
var urlParams2 = new URLSearchParams(window.location.search);
var tenant = urlParams2.get('tenant') || sub;
if (!tenant) return;
fetch('/api/public/used-parts?per_page=1&tenant=' + encodeURIComponent(tenant))
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
if (d && d.total > 0) {
link.href = '/usado/' + encodeURIComponent(d.tenant.subdomain || tenant);
link.textContent = 'Usados (' + d.total + ')';
} else {
link.style.display = 'none';
}
})
.catch(function () { link.style.display = 'none'; });
})();
// Enter on search // Enter on search
// ── Smart search detector ── // ── Smart search detector ──
function detectQueryType(raw) { function detectQueryType(raw) {
@@ -755,7 +729,6 @@
} }
html += '<button class="part-detail-btn" onclick="openDetail(' + p.id_part + ')">Ver detalle y alternativas</button>'; html += '<button class="part-detail-btn" onclick="openDetail(' + p.id_part + ')">Ver detalle y alternativas</button>';
html += waQuoteButtonHtml(p);
html += '</div>'; html += '</div>';
if (p.image_url) { if (p.image_url) {
html += '<img class="part-img" src="' + esc(p.image_url) + '" alt="" loading="lazy" onerror="this.style.display=\'none\'">'; html += '<img class="part-img" src="' + esc(p.image_url) + '" alt="" loading="lazy" onerror="this.style.display=\'none\'">';
@@ -809,26 +782,6 @@
content.innerHTML = html; content.innerHTML = html;
} }
// ── WhatsApp quote button ──
var _waConfig = null;
function waConfig(cb) {
if (_waConfig) return cb(_waConfig);
fetch('/api/public/contact-config').then(function (r) { return r.json(); })
.then(function (d) { _waConfig = d || { enabled: false }; cb(_waConfig); })
.catch(function () { _waConfig = { enabled: false }; cb(_waConfig); });
}
function waQuoteButtonHtml(p) {
var cfg = _waConfig;
if (!cfg || !cfg.enabled || !cfg.wa_number) return '';
var text = 'Hola, me interesa cotizar esta pieza:%0A%0A*OEM:* ' + encodeURIComponent(p.oem_part_number || '') +
'%0A*Descripción:* ' + encodeURIComponent(p.name || '');
if (p.vehicle_info) text += '%0A*Vehículo:* ' + encodeURIComponent(p.vehicle_info);
return '<a class="btn-wa-quote" target="_blank" rel="noopener" href="https://wa.me/' + cfg.wa_number + '?text=' + text + '"' +
' style="display:inline-flex;align-items:center;gap:8px;margin-top:var(--space-4);padding:12px 20px;border-radius:var(--radius-md);background:#25D366;color:#fff;text-decoration:none;font-weight:600;">' +
'Cotizar por WhatsApp</a>';
}
// ── Part detail ── // ── Part detail ──
function renderDetail(d, body) { function renderDetail(d, body) {
if (!d || !d.part) { if (!d || !d.part) {
@@ -878,7 +831,7 @@
html += '</tbody></table></div>'; html += '</tbody></table></div>';
} }
waConfig(function () { html += waQuoteButtonHtml(p); body.innerHTML = html; }); body.innerHTML = html;
} }
// ── Helpers ── // ── Helpers ──

View File

@@ -31,14 +31,14 @@
} }
var navLinks = [ var navLinks = [
{ label: 'Demo', href: '/demo.html' }, { label: 'Demo', href: '/demo' },
{ label: 'Tienda', href: '/tienda.html' }, { label: 'Tienda', href: '/tienda' },
{ label: 'Cat\u00e1logo', href: '/index.html' }, { label: 'Cat\u00e1logo', href: '/index.html' },
{ label: 'Captura', href: '/captura.html' }, { label: 'Captura', href: '/captura' },
{ label: 'POS', href: '/pos/login' }, { label: 'POS', href: '/pos' },
{ label: 'Cuentas', href: '/cuentas.html' }, { label: 'Cuentas', href: '/cuentas' },
{ label: 'Bodega', href: '/bodega.html' }, { label: 'Bodega', href: '/bodega' },
{ label: 'Admin', href: '/admin.html' } { label: 'Admin', href: '/admin' }
]; ];
var linksHTML = navLinks.map(function (link) { var linksHTML = navLinks.map(function (link) {

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,7 @@
global: global:
resolve_timeout: 5m smtp_smarthost: 'localhost:587'
smtp_from: 'alerts@nexus.local'
smtp_require_tls: false
route: route:
group_by: ['alertname', 'severity'] group_by: ['alertname', 'severity']
@@ -10,14 +12,19 @@ route:
receivers: receivers:
- name: 'default' - name: 'default'
email_configs:
- to: 'admin@nexus.local'
subject: 'Nexus Alert: {{ .GroupLabels.alertname }}'
body: |
{{ range .Alerts }}
Alert: {{ .Annotations.summary }}
Description: {{ .Annotations.description }}
Severity: {{ .Labels.severity }}
Time: {{ .StartsAt }}
{{ end }}
webhook_configs: webhook_configs:
# El contenedor llega al host por el gateway del bridge docker - url: 'http://localhost:5001/pos/api/notifications/webhook'
- url: 'http://172.17.0.1:5001/pos/api/notifications/webhook'
send_resolved: true send_resolved: true
http_config:
authorization:
type: Bearer
credentials: 'c58db62766712e618a881dbe8de580960812e57a069ef92c9dd00e7e69158cb2'
inhibit_rules: inhibit_rules:
- source_match: - source_match:

View File

@@ -3,8 +3,6 @@ version: "3.8"
services: services:
prometheus: prometheus:
image: prom/prometheus:v2.51.0 image: prom/prometheus:v2.51.0
extra_hosts:
- "host.docker.internal:host-gateway"
container_name: nexus-prometheus container_name: nexus-prometheus
restart: unless-stopped restart: unless-stopped
ports: ports:

View File

@@ -1,7 +1,7 @@
{ {
"uid": "nexus-app", "uid": "nexus-app",
"title": "Nexus — Application", "title": "Nexus — Application",
"tags": ["app", "flask", "nexus"], "tags": ["gunicorn", "flask"],
"timezone": "browser", "timezone": "browser",
"schemaVersion": 36, "schemaVersion": 36,
"refresh": "30s", "refresh": "30s",
@@ -26,15 +26,15 @@
"panels": [ "panels": [
{ {
"id": 1, "id": 1,
"title": "Request Rate (by app / status)", "title": "Request Rate (nginx)",
"type": "timeseries", "type": "timeseries",
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 0}, "gridPos": {"h": 8, "w": 12, "x": 0, "y": 0},
"datasource": {"type": "prometheus", "uid": "${datasource}"}, "datasource": {"type": "prometheus", "uid": "${datasource}"},
"targets": [ "targets": [
{ {
"datasource": {"type": "prometheus", "uid": "${datasource}"}, "datasource": {"type": "prometheus", "uid": "${datasource}"},
"expr": "sum by (app, status) (rate(nexus_http_requests_total[5m]))", "expr": "rate(nginx_http_requests_total[5m])",
"legendFormat": "{{app}} {{status}}", "legendFormat": "Requests/sec",
"refId": "A" "refId": "A"
} }
], ],
@@ -49,20 +49,20 @@
}, },
{ {
"id": 2, "id": 2,
"title": "Response Time p95 / p99", "title": "Response Time (nginx)",
"type": "timeseries", "type": "timeseries",
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 0}, "gridPos": {"h": 8, "w": 12, "x": 12, "y": 0},
"datasource": {"type": "prometheus", "uid": "${datasource}"}, "datasource": {"type": "prometheus", "uid": "${datasource}"},
"targets": [ "targets": [
{ {
"datasource": {"type": "prometheus", "uid": "${datasource}"}, "datasource": {"type": "prometheus", "uid": "${datasource}"},
"expr": "histogram_quantile(0.95, sum by (le) (rate(nexus_http_request_duration_seconds_bucket[5m])))", "expr": "histogram_quantile(0.95, sum(rate(nginx_http_request_duration_seconds_bucket[5m])) by (le))",
"legendFormat": "p95", "legendFormat": "p95",
"refId": "A" "refId": "A"
}, },
{ {
"datasource": {"type": "prometheus", "uid": "${datasource}"}, "datasource": {"type": "prometheus", "uid": "${datasource}"},
"expr": "histogram_quantile(0.99, sum by (le) (rate(nexus_http_request_duration_seconds_bucket[5m])))", "expr": "histogram_quantile(0.99, sum(rate(nginx_http_request_duration_seconds_bucket[5m])) by (le))",
"legendFormat": "p99", "legendFormat": "p99",
"refId": "B" "refId": "B"
} }
@@ -78,15 +78,15 @@
}, },
{ {
"id": 3, "id": 3,
"title": "In-flight Requests", "title": "Active Workers",
"type": "timeseries", "type": "timeseries",
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 8}, "gridPos": {"h": 8, "w": 12, "x": 0, "y": 8},
"datasource": {"type": "prometheus", "uid": "${datasource}"}, "datasource": {"type": "prometheus", "uid": "${datasource}"},
"targets": [ "targets": [
{ {
"datasource": {"type": "prometheus", "uid": "${datasource}"}, "datasource": {"type": "prometheus", "uid": "${datasource}"},
"expr": "sum by (app) (nexus_http_requests_in_flight)", "expr": "count by (instance) (node_processes_state{state=\"S\", cmdline=~\".*gunicorn.*\"})",
"legendFormat": "{{app}}", "legendFormat": "Workers {{instance}}",
"refId": "A" "refId": "A"
} }
], ],
@@ -108,8 +108,8 @@
"targets": [ "targets": [
{ {
"datasource": {"type": "prometheus", "uid": "${datasource}"}, "datasource": {"type": "prometheus", "uid": "${datasource}"},
"expr": "sum by (app) (rate(nexus_http_requests_total{status=\"5xx\"}[5m]))", "expr": "rate(nginx_http_requests_total{status=~\"5..\"}[5m])",
"legendFormat": "{{app}} 5xx/s", "legendFormat": "5xx/sec",
"refId": "A" "refId": "A"
} }
], ],
@@ -124,22 +124,22 @@
}, },
{ {
"id": 5, "id": 5,
"title": "Master DB Connections", "title": "Memory per Worker",
"type": "timeseries", "type": "timeseries",
"gridPos": {"h": 8, "w": 24, "x": 0, "y": 16}, "gridPos": {"h": 8, "w": 24, "x": 0, "y": 16},
"datasource": {"type": "prometheus", "uid": "${datasource}"}, "datasource": {"type": "prometheus", "uid": "${datasource}"},
"targets": [ "targets": [
{ {
"datasource": {"type": "prometheus", "uid": "${datasource}"}, "datasource": {"type": "prometheus", "uid": "${datasource}"},
"expr": "nexus_db_connections", "expr": "process_resident_memory_bytes{cmdline=~\".*gunicorn.*\"} / 1024 / 1024",
"legendFormat": "{{app}} ({{database}})", "legendFormat": "{{cmdline}}",
"refId": "A" "refId": "A"
} }
], ],
"fieldConfig": { "fieldConfig": {
"defaults": { "defaults": {
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10}, "custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
"unit": "short", "unit": "mbytes",
"min": 0 "min": 0
}, },
"overrides": [] "overrides": []

View File

@@ -19,7 +19,7 @@ scrape_configs:
- job_name: 'node' - job_name: 'node'
static_configs: static_configs:
- targets: ['host.docker.internal:9100'] - targets: ['node-exporter:9100']
- job_name: 'postgres' - job_name: 'postgres'
static_configs: static_configs:
@@ -29,16 +29,12 @@ scrape_configs:
static_configs: static_configs:
- targets: ['redis-exporter:9121'] - targets: ['redis-exporter:9121']
# Métricas de aplicación expuestas por POS (gunicorn :5001) y dashboard (:5000) - job_name: 'nexus-pos'
# en el host; se alcanzan desde el contenedor por el gateway del bridge docker.
- job_name: 'nexus_pos'
metrics_path: /metrics
static_configs: static_configs:
- targets: ['172.17.0.1:5001'] - targets: ['host.docker.internal:5001']
- job_name: 'nexus_dashboard'
metrics_path: /metrics metrics_path: /metrics
- job_name: 'nexus-quart'
static_configs: static_configs:
- targets: ['172.17.0.1:5000'] - targets: ['host.docker.internal:5002']
metrics_path: /metrics

View File

@@ -1,140 +0,0 @@
# Auditoría y Mejoras del Sistema Nexus POS — 15 de junio de 2026
## Resumen ejecutivo
Se realizó una revisión completa del sistema Nexus POS enfocada en corregir errores críticos y mayores, eliminar código muerto, cerrar brechas de seguridad menores y estandarizar la experiencia de usuario. También se aplicaron optimizaciones de rendimiento en consultas frecuentes y se mejoró la accesibilidad de los formularios.
- **Rama/base:** `main`
- **Tenant de pruebas:** `tenant_refaccionaria_la_casita` (`tenant_id = 33`)
- **Servicio:** `nexus-pos.service` (puerto 5001)
- **Estado final:** operativo, tests pasando
---
## 1. Problemas críticos corregidos
| Problema | Solución | Archivos principales |
|----------|----------|----------------------|
| `@require_auth` sin paréntesis en `tasks_bp` | Se cambió al decorador correcto | `pos/blueprints/tasks_bp.py` |
| Permiso inexistente `accounting.read` | Se reemplazó por `accounting.view` | `pos/blueprints/accounting_bp.py` |
| `historical_sales.html` no cargaba `api.js` | Se incluyó el helper compartido | `pos/templates/historical_sales.html`, `pos/static/js/api.js` |
| `featureProximamente` no estaba disponible en `customers.js` | Se expuso la función en el módulo | `pos/static/js/customers.js` |
| `pos_engine` usaba `threading` sin importar y la caja sin `FOR UPDATE` | Se agregó `import threading` y lock pesimista de caja | `pos/services/pos_engine.py` |
| Race condition en stock de ventas | Se agregó `SELECT ... FOR UPDATE` sobre `inventory_stock` | `pos/services/pos_engine.py` |
---
## 2. Problemas mayores corregidos
| Problema | Solución | Archivos principales |
|----------|----------|----------------------|
| N+1 en `complete_layaway` | Se cargan todos los items en una sola consulta | `pos/services/pos_engine.py` |
| Race conditions en folios CFDI y números de póliza | Se usó `pg_advisory_xact_lock(hashtext(prefix))` | `pos/services/cfdi_queue.py`, `pos/services/accounting_engine.py` |
| N+1 en flotilla (schedules/history) | Se crearon endpoints bulk `/vehicles/schedules` y `/vehicles/history` | `pos/blueprints/fleet_bp.py`, `pos/static/js/fleet.js` |
| N+1 en dashboard (ventas recientes) y `global_invoice` | Se consolidaron consultas | `pos/blueprints/dashboard_stats_bp.py`, `pos/services/global_invoice.py` |
| `client_secret` de MercadoLibre expuesto en frontend | Se movió el flujo OAuth a backend con endpoint `/connect/init` | `pos/blueprints/marketplace_external_bp.py`, `pos/static/js/marketplace_external.js` |
| Botones/filtros sin handler en config, customers y accounting | Se conectaron eventos y handlers faltantes | `pos/static/js/config.js`, `pos/static/js/customers.js`, `pos/static/js/accounting.v9.js`, `pos/templates/*.html` |
| Botones/filtros sin handler en invoicing | Se implementaron filtros, export CSV y acciones | `pos/static/js/invoicing.js`, `pos/templates/invoicing.html` |
---
## 3. Mejoras de rendimiento
- **Locks atómicos:** los folios de órdenes de servicio, CFDI y pólizas contables ahora se generan bajo `pg_advisory_xact_lock` para evitar duplicados bajo concurrencia.
- **Consultas bulk:** flotilla y dashboard redujeron drásticamente el número de queries al backend.
- **Virtual scroll:** se mantiene en tablas grandes (clientes, programas de mantenimiento, historial) para renderizado eficiente.
- **Cache busting:** se actualizaron los query strings estáticos a `?v=32` y el Service Worker a `v32` para forzar la actualización de assets en navegadores y PWA.
---
## 4. Mejoras de UX y accesibilidad
- **Formularios accesibles:** se agregaron `aria-label`, `id` y `<label>` explícitos en:
- POS: pago mixto, toggle de costo/margen, campos de vehículo del modal de cliente.
- Clientes: búsqueda y filtros.
- Catálogo: búsqueda y filtro de niveles.
- Facturación: búsquedas de facturas, notas de crédito y complementos de pago.
- **Estados de carga consistentes:** se añadió `renderLoadingState()` en `pos-utils.js` con estilo en `pos-ui.css`, y se aplicó en:
- Clientes (`customers.js`)
- Facturación (`invoicing.js`)
- Flotilla (`fleet.js`): vehículos, programas, historial y alertas.
- **Empty states normalizados:** los errores y listas vacías ahora usan `renderEmptyState()` en lugar de mensajes inline inconsistentes.
---
## 5. Limpieza de código
- Se eliminaron las versiones obsoletas `pos/static/js/accounting.js` y `pos/static/js/accounting.min.js`.
- Se consolidó la lógica contable en `pos/static/js/accounting.v9.js`.
- Se removieron `console.log`/`console.error` de depuración de `accounting.v9.js`.
- Se limpió encabezado comentado de `accounting.v9.js`.
---
## 6. Archivos añadidos
```text
pos/migrations/v4.10_fleet_permissions.sql
pos/migrations/v4.7_workshop_business.sql
pos/migrations/v4.8_workshop_permissions.sql
pos/migrations/v4.9_workshop_customers_view.sql
pos/static/js/accounting.v9.js
pos/static/js/api.js
scripts/allocate_existing_customer_payments.py
```
---
## 7. Estadísticas del cambio
```text
54 archivos modificados
+2485 / -1300 líneas aproximadas
```
---
## 8. Validación
```bash
# Sintaxis de JavaScript
node --check pos/static/js/pos-utils.js
node --check pos/static/js/customers.js
node --check pos/static/js/invoicing.js
node --check pos/static/js/fleet.js
node --check pos/static/js/accounting.v9.js
node --check pos/static/js/pos.js
node --check pos/static/js/catalog.js
# Tests de backend
python3 -m pytest pos/tests/test_service_order_integration.py \
pos/tests/test_bulk_import.py \
pos/tests/test_facturapi_service.py -q
# Resultado: 35 passed
```
Servicio `nexus-pos.service` verificado como **activo**.
---
## 9. Instrucciones de despliegue
1. Aplicar migraciones SQL nuevas según el orden numérico:
- `v4.7_workshop_business.sql`
- `v4.8_workshop_permissions.sql`
- `v4.9_workshop_customers_view.sql`
- `v4.10_fleet_permissions.sql`
2. Reiniciar el servicio para cargar cambios de backend:
```bash
sudo systemctl restart nexus-pos.service
```
3. Refrescar la PWA en los navegadores/clientes para que `sw.js` actualice el cache a `v32`.
---
## 10. Notas para el equipo
- Los permisos de flotilla (`fleet.view/create/edit/delete`) ya están sembrados para `owner`/`admin` y `view` para `accountant`/`workshop`.
- El Service Worker se sirve correctamente en `/pos/sw.js` desde `static/pwa/sw.js`.
- El helper global `api()` fue creado para páginas standalone como `historical_sales.html`.
- No se realizaron mutaciones de git no solicitadas; el push a Gitea se hace como parte de esta entrega.

View File

@@ -320,30 +320,6 @@ python3 scripts/check_facturapi_tenants.py
--- ---
## Mejoras A5 / A6 / A10 (2026-10-11)
Capacidades que no dependen de gran volumen de ventas históricas.
| # | Feature | Archivos | Qué hace |
|---|---------|----------|----------|
| **A10** | Venta cruzada "esto + esto" | `services/cross_sell.py`, `blueprints/pos_bp.py` (`POST /pos/api/recommendations`), `static/js/pos.js`, `templates/pos.html` | Sugiere complementos del carrito usando `part_kits.KIT_SUGGESTIONS` + co-ocurrencia real en `sale_items` (mismo `sale_id`). Prioriza piezas con stock; panel en el POS al agregar un artículo. Permiso `pos.sell`. |
| **A5** | Anomalías de negocio | `services/anomaly_detector.py`, `blueprints/pos_bp.py` (`GET /pos/api/reports/anomalies`), `scripts/run_anomaly_scan.py` | z-score robusto (MAD) por empleado/SKU sobre `sale_items` + `sales` + `audit_log`: descuentos atípicos, ventas bajo costo, cancelaciones repetidas y ventas fuera de horario (TZ `America/Mexico_City`). Maneja *cold start* (`min_samples`, default 5) y nunca marca `unit_cost = 0` como bajo costo. Permiso `reports.view`. |
| **A6** | Sinónimos + cross-refs en búsqueda | `services/meili_search.py` (`build_synonyms`, `apply_synonyms_to_all`), `scripts/index_meilisearch.py` (`synonyms` mode, `aliases`), `services/catalog_service.py` (`_search_inventory_by_alias`) | Sinónimos ES↔EN (curados + `services.translations.PART_TRANSLATIONS`, 653 términos) aplicados a `nexus_inventory_*` y `nexus_supplier_catalog` sin reindexar. Los SKU alias (`inventory_sku_aliases`) e interchange se indexan en `aliases` y además se consultan por SQL, así la búsqueda por número cruzado funciona de inmediato. |
Comandos de operación (A6):
```bash
# Aplicar solo settings/sinónimos (no reindexa documentos)
python3 scripts/index_meilisearch.py synonyms
# Reindexar un tenant (incluye aliases) o todos
python3 scripts/index_meilisearch.py tenant tenant_capi_autopartes
python3 scripts/index_meilisearch.py all
# Escaneo de anomalías (exit 1 si hay hallazgos de severidad alta)
python3 scripts/run_anomaly_scan.py all 90
```
---
## Mejoras Pendientes (Roadmap Actualizado) ## Mejoras Pendientes (Roadmap Actualizado)
### 🔴 Crítico — Deuda Técnica ### 🔴 Crítico — Deuda Técnica

View File

@@ -1,47 +0,0 @@
# Nexus Yonke — Documento Comercial (segmento partes usadas)
> Complemento del `docs/YONKE_ONBOARDING.md` (técnico). Aquí va el **posicionamiento, precios, segmento y copy** para vender el módulo a yonkes/deshuesaderos.
## 1. A quién le vendemos
Yonkes, deshuesaderos y refaccionarias que venden **piezas usadas** (1‑de‑1, con vehículo donador y fotos). Hoy operan con un cuaderno, WhatsApp y MercadoLibre; sin sistema que entienda que **cada pieza es única**.
## 2. Propuesta de valor (diferenciadores)
1. **Pieza única 1‑de‑1** con SKU automático (`USADO‑<VIN>‑<n>`): dos espejos iguales de donadores distintos **no se fusionan**.
2. **Alta en <30 s** desde mostrador: **foto múltiple + VIN del donador** (decodificado solo) → pieza lista.
3. **Búsqueda por donador** ("¿qué tengo de este Civic 2015?") además de "¿qué le queda a este auto?".
4. **Visión por IA**: sugiere el **grado A/B/C/D** desde la foto y **identifica la pieza**.
5. **Publicación en MercadoLibre como USADO** (grado, donador, hasta 10 fotos) desde el mismo sistema.
6. **Consignación**: piezas de terceros con comisión y **estado de cuenta del consignante**.
7. **Vitrina pública** de usados con SEO (Google encuentra tus piezas).
8. **Marketplace B2B entre yonkes** (roadmap): intercambio de usadas entre deshuesaderos de la red.
## 3. Precio
- Plan **`yonke`** — **MXN 299/mes**: sin límite de piezas, hasta 10 empleados, 1 sucursal, **sin catálogo TecDoc** (no lo necesita).
- Upsell: multi‑sucursal, WhatsApp automatizado, marketplace B2B.
## 4. Onboarding guiado (checklist)
1. Crear el tenant del negocio (plan `yonke`), activar el módulo **Yonke**.
2. Otorgar `yonke.view`/`yonke.manage` a los roles (el owner ya los tiene).
3. **Configurar la política de garantía de usados** (`yonke_default_warranty_days`, p.ej. 30).
4. Cargar las **primeras 20 piezas** en `/pos/yonke` (VIN → Decodificar → fotos → guardar).
5. Registrar el primer **donador completo** (auto + sus piezas de una pasada).
6. Opcional: publicar 3 piezas en **MercadoLibre** y activar la **vitrina pública**.
## 5. Copy para landing (borrador)
**Título:** *Tu yonke, con sistema. Piezas usadas, ordenadas como nunca.*
**Bajada:** *Cada pieza es única. Nexus Yonke lo entiende: registra la pieza en 30 segundos desde una foto y el VIN del donador, la encuentra por vehículo, le pone su grado con IA y la publica en MercadoLibre como usada. Consignación, etiquetas y vitrina pública, incluidos.*
**Bullets:**
- "Alta por foto + VIN en menos de 30 segundos."
- "Tu inventario en el patio, buscable por donador."
- "Publica en MercadoLibre como USADO con grado y fotos."
- "Consigna piezas de terceros y liquida con estado de cuenta."
- "Vitrina pública indexada en Google."
**CTA:** *Prueba Nexus Yonke — MXN 299/mes, sin límite de piezas.*
## 6. Roadmap comercial del segmento
- **Fase 1 (listo):** alta, búsqueda por donador, MeLi usado, consignación, plan.
- **Fase 2:** vitrina pública + garantías/devolución + etiquetas + reportes (en curso).
- **Fase 3:** **marketplace B2B entre yonkes** (`is_seller`), analítica del segmento, importación masiva.

View File

@@ -1,51 +0,0 @@
# Nexus Yonke — Onboarding (negocios de partes usadas)
> Módulo para refaccionarias/yonkes/deshuesaderos que venden **piezas usadas** (piezas únicas 1-de-1,
> con vehículo donador y varias fotos). No requiere catálogo TecDoc.
## Qué se habilitó
**Modelo de datos (migración `v4.27_yonke_m1`, aplicada a todos los tenants):**
- `inventory`: `condition` (nuevo/usado/reconstruido), `condition_grade` (A/B/C/D), `is_unique_piece`,
`serial_number`, `donor_vin`, `donor_make`, `donor_model`, `donor_year`, `donor_mileage_km`,
`ownership` (propio/consigna), `acquisition_cost`.
- `part_number` acepta `NULL` → dos piezas idénticas de donadores distintos **no se fusionan**.
- `inventory_images` (fotos múltiples por pieza).
- `warranties.warranty_days` (garantía por días).
- `consignments` / `consignment_items` (piezas de terceros a consignación).
**Módulo `/pos/yonke`** (pestañas):
1. **Piezas usadas** — listado con filtros (texto, condición, grado, propiedad) y paginación.
2. **Nueva pieza** — alta rápida: nombre, precio, **VIN del donador + botón "Decodificar"** (NHTSA)
que autocompleta marca/modelo/año, km, propiedad, cobertura en días y **varias fotos**. Genera SKU
automático `USADO-<últimos 6 del VIN>-<n>` si no se captura número de parte.
3. **Por donador** — "¿Qué tengo de este auto?" (agrupado por VIN) y piezas de un donador concreto.
4. **Consignaciones** — alta de consignante, piezas, montos y **liquidación** (reporte por periodo).
**Publicación en MercadoLibre (M4):** las piezas usadas se publican con `condition="used"`, el grado
en el título, datos del donador como atributos y **hasta 10 fotos**.
**Permisos:** `yonke.view` (ver) y `yonke.manage` (crear/editar). El rol `owner`/`admin` los tiene por
defecto; otros roles se otorgan desde Configuración → Permisos (grupo "Yonke").
## Cómo dar de alta un cliente de este segmento
1. Alta normal del tenant (el flujo de tenants ya funciona; ver S1).
2. En el panel admin, activar el módulo `yonke_enabled` del tenant (opcional).
3. Otorgar `yonke.view`/`yonke.manage` a los roles que correspondan (por defecto el owner ya los tiene).
4. En `/pos/yonke` → **Nueva pieza**: capturar la primera pieza (VIN → Decodificar → fotos → guardar).
5. Repetir por pieza; usar **Por donador** para ubicar todo lo que se tiene de un auto.
## Plan comercial
El plan **`yonke`** (MXN 299/mes) está disponible en `services/billing.py`: sin límite de productos,
hasta 10 empleados, 1 sucursal y sin catálogo TecDoc.
## Pendientes conocidos
- La garantía por días se registra vía `POST /pos/api/warranties` con `warranty_days`; **aún no se
auto-registra al vender** (el POS no registra garantías automáticamente hoy). Implementar el hook al
vender una pieza con cobertura.
- Las categorías de ML para usados pueden exigir reglas propias; el mapeo de categorías por segmento
queda como mejora.
- `donor_vin` no se publica en ML (no hay atributo universal); se puede añadir a la descripción.

View File

@@ -1,106 +0,0 @@
# Auditoría #2 — Mejoras y IA no conversacional (post F0–F4)
> **Fecha:** 11-oct-2026 · **Base:** 5 investigaciones nuevas con evidencia en `archivo:línea`, SQL real y estado del host.
> **Contexto:** segunda pasada **sobre el estado ya mejorado** (F0–F4 hechos). Busca la capa que falta, incluidas **regresiones** y **puntos ciegos** de la primera pasada.
---
## 0. Resumen ejecutivo
La primera pasada dejó una base sólida, pero esta segunda encontró **regresiones y puntos ciegos**:
**Corregido YA en esta pasada:**
1. **Redis caído otra vez** (18:31 UTC): `redis.conf` hacía `bind 127.0.0.1 172.17.0.1`; si `docker0` no está arriba, **Redis muere en bucle** ("Cannot assign requested address") y con él **todo Celery** (fitment, publicación MeLi en lote, webhook). → Arreglado (`bind 127.0.0.1 ::1`) + `vm.overcommit_memory=1` + `reset-failed`; Celery vivo.
2. **30 de 35 endpoints `/api/admin/*` SIN auth** (solo `tenants*`/`users*` la tenían): `DELETE /api/admin/parts`, `POST /api/admin/import/<type>`, `upload-image`, `export/<type>`… expuestos. → Guard global `before_request` que exige token **ADMIN** en **toda** la superficie; verificado 401.
**Lo demás que falta** (priorizado):
- **MeLi sigue "inerte"**: 52 publicaciones de Rached con `inventory_id=NULL` (0/52), 31 órdenes pagadas sin convertir, `module_marketplace=false`, sync congelado desde el 11-jun, 0 imágenes. No es la API: es el **mapeo SKU→inventario + imágenes**.
- **Template de tenant congelado en 32 tablas** (un tenant real tiene ~91): funciona solo porque el alta aplica v1.1→v4.27 sobre un template *desactualizado* → frágil.
- **Flags de módulo inconsistentes** (`*_enabled` en dashboard vs `module_*` en POS): activar MercadoLibre/WhatsApp en el panel **no** se refleja en el POS.
- **Yonke construido y vacío**: sin flag de módulo, sin link de navegación, sin plan asignable, 0 piezas.
- **Lealtad sin cerrar el ciclo** (acumula pero no se puede canjear con descuento real).
- **Backups**: sin offsite, sin retención (7.2 GB), **sin alerta de backup**; y `expire_demos.py` no está cableado a ningún timer.
- **Reset de demo no limpia PII** (clientes/conversaciones quedan).
- **La IA de embeddings está construida pero SIN usar**: `semantic_search.embed()/find_similar()` **no tienen un solo caller** → es la palanca más barata para media docena de mejoras.
---
## 1. Regresiones / puntos ciegos operativos (con evidencia)
| # | Hallazgo | Evidencia | Estado |
|---|---|---|---|
| 1 | **Redis muere por `bind` a docker0** | `redis-server.log:847` `Cannot assign requested address 172.17.0.1`; `redis.conf:88` | ✅ arreglado |
| 2 | **32 endpoints `/api/admin/*` sin auth** | `dashboard/server.py` (35 rutas, 3 con ADMIN) | ✅ arreglado (guard global) |
| 3 | **Admin de catálogo roto (500)** | `/api/admin/stats`,`/groups` → 500 (tablas `parts`/`part_categories` retiradas, `server.py:2716-2743`) | pendiente |
| 4 | **Backups Oct 2–10 inexistentes** + sin offsite + sin retención + **sin alerta de backup** | `backups/`; `BACKUP_S3_BUCKET` sin set | parcial (timer ya corre) |
| 5 | **`expire_demos.py` sin timer** | grep `systemd/`=0 | pendiente |
| 6 | **Template congelado (32 tablas) + drift** (`sale_payments` ausente) | `tenant_template` 32 vs tenant real 91 | pendiente |
| 7 | **Reset de demo deja PII** (no trunca `customers`,`whatsapp_messages`,`wa_*`,`audit_log`) | `manager/services/tenant_service.py:188-240` | pendiente |
| 8 | **14 DB `tenant_*` huérfanas** (6 con PII de prueba) vs 8 registradas | `pg_database` | pendiente |
| 9 | **Flags de módulo inconsistentes** | dashboard escribe `meli_enabled`, POS lee `module_meli` (`config_bp.py:1269`), manager usa `module_*` | pendiente |
| 10 | **Manager sin nginx/TLS/2FA; auditoría vacía; bug de comparación de versiones** (`"v4.9">="v4.10"`) | `manager/services/migration_service.py:82` | pendiente |
| 11 | **Sin TLS en el host, sin firewall; `5432`/`5003` en 0.0.0.0** | `ss -tlnp`; `iptables -S` ACCEPT | pendiente (decisión) |
| 12 | **`nexus` comparte rol en TODAS las DB; sin roles por tenant** | `has_database_privilege`=t | pendiente |
| 13 | **nginx del repo difiere del vivo** (falta bloque wildcard de tenants; sobra `nexus_quart`/`login2`) | `nginx/nexus-pos.conf` vs `/etc/nginx/...` | pendiente |
| 14 | **Sin analytics/tracking** en la cara pública (cero GA/GTM/pixel) | grep = 0 | pendiente |
| 15 | **`i18n.js` muerto** (fuerza `es`, cero `t()`), `*.min.js` desactualizados | `i18n.js:322` | pendiente |
| 16 | **Aislamiento multi-tenant por proceso**: 8 workers × N tenants × pool; sin PgBouncer; scans cross-tenant O(n) | `tenant_db.py`, `marketplace_external_bp.py:113` | pendiente |
| 17 | **Token del webhook de Alertmanager en claro en el repo** | `docker/alertmanager/alertmanager.yml` | pendiente |
---
## 2. MeLi / Yonke / WhatsApp — estado real y brechas
**MercadoLibre (Rached):** 52 listings, **`inventory_id` 0/52**, 31 órdenes `paid` sin convertir (`nexus_sale_id` NULL), `sync_errors` 52/52 (jun-11), `last_sync_at` 2026-06-11, 0 imágenes, `module_marketplace=false`. Bloqueo operativo = **mapear SKU→inventario + subir imágenes + encender el módulo**.
Ausente comercialmente (0 refs en código): **descripción de anuncio**, **facturación automática al convertir** (usa `_create_sale_manual` que salta el hook de CFDI/contabilidad), **persistir comisión/rentabilidad**, variaciones, `catalog_listing`/`price_to_win`, promociones, reputación, **SLA de preguntas** (4 sin responder, una de febrero), multi-cuenta, backoff de rate-limit (429).
**Yonke:** código completo (M1–M6), **0 datos**. Falta: flag `module_yonke` (no existe), link en navegación, plan asignable desde admin, tenant piloto, exposición pública de usados, categorías ML de usados, botón "publicar usado" en la UI.
**WhatsApp:** 8 bridges vivos + WA CRM (`:3010`) pero `module_whatsapp=false` en todos, WA CRM sin cablear (secret placeholder), **0 mensajes**; y el flujo WA **no escribe actividades en el CRM** (aunque sí factura pedidos).
---
## 3. IA no conversacional — la siguiente ola (con prontitud de datos)
### 3.1 La palanca infrautilizada
`pos/services/semantic_search.py` tiene `embed()` (4096 dims), `find_similar()`, `cosine_similarity()`, `rerank()` — **el rerank está integrado y el resto NO tiene un solo caller**. Es la base más barata para: matching, dedup, alternativas, limpieza de fitment, búsqueda semántica de bodega y respuestas de compatibilidad.
### 3.2 Priorizado por impacto/esfuerzo
| Prio | Oportunidad | Punto de integración | Datos (real) | Enfoque | Esfuerzo |
|---|---|---|---|---|---|
| **1** | **Ligar inventario→catálogo a escala** (habilita casi todo) | `sku_matcher.py:82,138`; endpoint `/inventory/match-catalog` | 355k items sin ligar; 17,246 catálogo; 406,393 xrefs | exact+interchange (ya) **+ embeddings** en el fallback de nombres (hoy `difflib`) | M |
| **2** | **Enriquecer catálogo: descripciones e imágenes** | `catalog_import_service.py:14`; `image_scraper.py:55` | 95% de `inventory.description` vacío; 0/17,246 `supplier_catalog` con imagen/desc; hay imágenes en los Excel de `data/` | LLM para descripción ES; cargar imágenes de proveedor + visión para validar | S–M |
| **3** | **"Alternativas / piezas similares" (feature muerta)** | `catalog_service._get_alternatives:1859`; `dashboard/server.py:1915` | `supplier_catalog_interchange` (406k) + catálogo 17k | reconstruir sobre interchange + `find_similar()` (hoy consulta tablas TecDoc retiradas → vacío) | M |
| **4** | **Anuncios MeLi: título + descripción + categoría** | `marketplace_external_service.build_item_payload:237`; `meli_service.search_categories:280` | nombre/marca/compat/donor | LLM (categoría vía `domain_discovery`) + descripción | S–M |
| **5** | **Claves SAT de productos** (`clave_prod_serv`/`clave_unidad` 100% vacías) | `cfdi_builder.py:178`; `cfdi_facturapi_builder.py:83` | nombre/categoría/marca de 170k | reglas + LLM → CFDI correcto | S |
| **6** | **Persistir comisión/rentabilidad por listing** | `marketplace_listings` (nuevas columnas); `_calculate_meli_net_price:387` (ya calcula, no guarda) | listings + costo | guardar fee/net/costo → margen por publicación | S |
| **7** | **Facturación automática al convertir orden MeLi** | `convert_order_to_sale`/`_create_sale_manual:1759` (salta `pos_engine` CFDI) | órdenes MeLi | enrutar por `process_sale` o encolar CFDI | S |
| **8** | **Extracción de pedidos desde WhatsApp (texto/voz/foto)** | `whatsapp_bp.py:376` (hoy menú rígido) | `whisper_local` (voz), `part_vision`/`invoice_ocr` (foto), catálogo | LLM/NER → cotización borrador | M–L |
| **9** | **Limpieza de fitment ruidoso** | `qwen_fitment.py:113,298`; `inventory_vehicle_compat.py:322` | 41.5M filas (estrada ~131/pieza; `qwen` 99.5%) | apagar `qwen` por defecto en bulk-import + scorear ruido con embeddings/rerank | M |
| **10** | **Búsqueda semántica de bodega** (150,778 items, multi-vendedor) | `peer_service`, `catalog_service.get_bodega_availability:2562` | 150k con nombre/SKU | embeddings + rerank (offline) | M |
| **11** | **Métricas de negocio por tenant + anomalías multi-tenant + salud de cartera** | master rollup diario; `anomaly_detector.py` (intra-tenant) | por definir | rollup + z-score entre tenants | M |
| **12** | **Dedup/normalización con embeddings** | `sku_matcher.match_name:82` | estrada: 6,121 grupos de `name` duplicado | `find_similar()` clustering | M |
### 3.3 No vale la pena todavía (cold-start honesto)
- **Forecast de demanda por SKU, elasticidad/precios, predicción de quiebre, fraude/anomalías por SKU, churn/scoring por cliente**: hay **~70 líneas de venta en toda la plataforma** y **0 compras**. `cross_sell` estadístico y `anomaly_detector` se auto-marcan en `cold_start`. Dejar **diseñado, no invertir** hasta tener miles de transacciones por tenant.
- **IA de ops**: triage de logs con LLM y agrupación de alertas con ML = **overkill** (volumen KB/día, 1 receiver). Lo útil es **sin IA**: alerta de backup/celery/tenant caído y `predict_linear` para disco.
---
## 4. Roadmap propuesto
| Fase | Foco | Entregable verificable |
|---|---|---|
| **F5 — Cerrar lo crítico (2-3 días)** | Redis estable (hecho) · auth admin (hecho) · admin catálogo sin 500 · `expire_demos` a timer · unificar flags de módulo · reset de demo con PII · limpiar DB huérfanas · alerta de backup | 0 endpoint admin sin auth; módulos coherentes dashboard↔POS; alerta de backup llega |
| **F6 — MeLi de verdad (1 sem)** | Mapear los 52 listings → inventario · pipeline de imágenes · encender `module_marketplace` y `module_yonke` · publicar con descripción/categoría · persistir comisión · facturación automática al convertir | Publicar SKU con imagen desde el POS → vender en ML → orden convertida + CFDI encolado |
| **F7 — IA de catálogo (2 sem)** | Ligado masivo con embeddings · descripciones + imágenes · "alternativas/similares" funcional · SAT keys · limpieza de fitment | ≥70% de items ligados a catálogo; alternativas devuelven resultados; fitment por pieza < 20 filas |
| **F8 — Escala y cartera (2-3 sem)** | PgBouncer + pool · métricas de negocio por tenant + anomalías multi-tenant + salud de cartera · analytics en la cara pública · Yonke piloto · WA CRM por negocio · TLS/firewall/roles | panel de cartera; 30+ tenants sin agotar conexiones; 1 tenant Yonke en producción |
---
## 5. Métricas de éxito
- **Confiabilidad:** 0 servicios en bucle; backups diarios verificados + offsite; alertas que llegan.
- **Cierre:** 0 endpoints `/api/admin/*` sin auth; flags de módulo coherentes.
- **MeLi:** 100% de listings con `inventory_id`; >0 órdenes convertidas + facturadas; publicaciones con imagen y descripción.
- **IA de catálogo:** % items ligados a catálogo (0→≥70%); tiempo de alta de anuncio; búsquedas sin resultado.

View File

@@ -1,125 +0,0 @@
# Auditoría completa de la plataforma + IA no conversacional — Nexus Autoparts
> **Fecha:** 11-oct-2026 · **Base:** 6 investigaciones con evidencia en `archivo:línea`, SQL real y estado del host.
> **Énfasis solicitado:** áreas de mejora + **IA no conversacional** (predicción, clasificación, visión, OCR, anomalías, matching).
---
## 0. Resumen ejecutivo
La plataforma es hoy, en los datos, **un catálogo de autopartes con fitment masivo (≈21 M relaciones pieza-vehículo y ≈406 K equivalencias) más un POS funcional pero con poco volumen transaccional** (máximo 24 ventas en un tenant; la única serie temporal real son 4,582 ventas históricas de Atlas en 19 meses). El código de IA existente es **LLM + visión (QWEN/Hermes/Gemma) y Meilisearch**; no hay ML clásico instalado.
Hay tres frentes con problemas:
1. **Operación/seguridad (grave y barato de arreglar):** firewall abierto, Postgres con `trust` en toda la LAN, secretos en texto claro, **Redis caído 6 días → todo Celery muerto**, **alertas silenciosas** (sin `ALERT_PHONES`), backups que fallaron 5 días sin avisar.
2. **Bugs de producto en caja** (visibles al usuario hoy) y funciones construidas pero **sin cablear** (garantías, notificaciones de taller, lealtad, venta cruzada, flotillas).
3. **Calidad de datos** que bloquea el catálogo, la publicación en MercadoLibre y el SEO: **0 imágenes, 0 descripciones, `reorder_point` 100% NULL, 0 precios de proveedor, 52 listings sin `inventory_id`**.
**IA no conversacional: qué sí tiene sentido hoy** (no depende de volumen de ventas): auto-categorización de productos (ML y taxonomía), deduplicación/matching de SKUs, **OCR/VLM de facturas de proveedor**, **visión para graduar piezas usadas (Yonke)**, semantic search por embeddings, y **detección de anomalías de negocio** (descuentos/stock). **Qué NO vale la pena todavía:** forecast de demanda (cold start), triage de logs con LLM, agrupación de alertas con ML (overkill).
---
## 1. Operación y seguridad — P0 (hacer ya)
| # | Hallazgo | Evidencia | Acción |
|---|---|---|---|
| 1 | **Postgres con `trust` para toda la LAN** + puerto `5432` abierto en `0.0.0.0` | `pg_hba.conf`: `host all all 192.168.10.0/24 trust`; `iptables -S` → `INPUT ACCEPT` sin reglas; `ufw` no instalado | Quitar `trust` (usar `scram-sha-256`), `listen_addresses` a localhost/LAN acotada, firewall |
| 2 | **Secretos en claro en unit files world-readable** | `nexus-pos.service` (QWEN/FACTURAPI/JWT/INTERNAL/MEILI keys), `nexus-manager.service`, `nexus.service`, `meilisearch.service` | `EnvironmentFile` modo `600` o `systemd-creds`; rotar llaves |
| 3 | **Redis caído 6 días → Celery en bucle de muerte** | `systemctl status redis-server` = failed; `celery.log`: `Cannot connect to redis://localhost:6379/1 (55/100)` desde hace 6 días | Levantar Redis (o quitarlo del diseño); hoy **toda tarea Celery está caída** |
| 4 | **Alertas silenciosas** | `/etc/nexus/alert.env` sin `ALERT_PHONES` → `alerts.log: ALERT no ALERT_PHONES configured`; `RedisDown` activo 6 días sin consecuencia | Poblar `alert.env`, segundo canal (email/Slack), alertas de backup/Celery/app-down |
| 5 | **Backups: fallaron 5 días en silencio** (7–11 oct); solo locales; 3.5 GB zombi | `journalctl -u nexus-backup`; `BACKUP_S3_BUCKET` sin definir; cleanup no borra `.dump`/`.sql` viejos | Ya corregí el `((deleted++))`; **falta validar el timer 02:00**, offsite S3 y limpieza |
| 6 | **Sin TLS en este host** | solo `listen 80`; no hay 443 | Confirmar proxy/Cloudflare delante, o terminar TLS |
| 7 | **Endpoints expuestos** | Grafana `admin/nexus2026`; Prometheus/Alertmanager sin auth; webhook `/pos/api/notifications/webhook` POST **sin auth** | Auth + bind a localhost |
| 8 | **Servicios como `root`** y **timers muertos** (`nexus-quart`, `cache-warm`, `mv-refresh`, `cron.d` inexistente) | `systemctl status` | Erradicar jobs muertos / matrices obsoletas |
**Observabilidad:** Prometheus/Grafana/Alertmanager corren y scrapean infra, pero **la app no expone `/metrics`** (`:5001/metrics` → 404; `grep prometheus_client` = 0) → el dashboard "Nexus — Application" está **vacío**. Falta `pg_stat_statements` cargado (`shared_preload_libraries` vacío) y `log_min_duration_statement` activo.
**Escalabilidad:** el pool es **por proceso** (`tenant_db.py:24-32`, `minconn=2, maxconn=20` × 8 workers gunicorn) → ~160 conexiones/tenant en el peor caso; **sin PgBouncer**. 8 tenants van sobrados; **a ~15-20 tenants calientes revienta `max_connections=200`**. A 100 es insostenible.
---
## 2. Bugs de producto en caja — P0/P1
| Sev | Bug | Evidencia |
|---|---|---|
| P0 | **Venta offline rota**: `totals['total']` no existe → `ReferenceError`, el ticket offline nunca se muestra | `pos/static/js/pos.js:1587` |
| P0 | **Devolución muestra reembolso $0**: lee `res.refund_total`, el API devuelve `total_refund` | `pos.js:978` vs `pos_bp.py:2628` |
| P0 | **Apartado liquidado no muestra la venta**: lee `res.sale_id`, el API devuelve `{id,...}` | `pos.js:1054` vs `pos_bp.py:2377` |
| P0 | **"Facturar CFDI" no hace nada**: se manda `generate_cfdi` pero **ningún backend lo lee** | `pos.js:1519`; `grep generate_cfdi` en `pos/**/*.py` = 0 |
| P0 | **Reembolsos no entran al corte de caja** → efectivo esperado sobreestimado | `pos_bp.py:2454` no inserta `cash_movements`; `cashregister_bp.py:195-314` no resta devoluciones |
| P1 | **Apartado cancelado no revierte contabilidad** (pasivo fantasma 240) | `pos_bp.py:2389-2449` |
| P1 | **Apartado se liquida siempre como efectivo** (aunque sea tarjeta) | `pos_bp.py:2291,2332` |
| P1 | **Descuento/tope de precio solo se valida en venta directa** (cotización/apartado la evaden) | `pos_engine.py:290-295`; `pos_bp.py:822,1212,1704,1864` |
| P1 | **Devoluciones sin control de rol** (solo `pos.sell`) | `pos_bp.py:2455` |
| P1 | **500 por query param sin validar** (`?per_page=abc`) | `error.log`; `/pos/api/marketplace-ext/*` |
**Construido pero sin cablear:** garantías/RMA nunca se registran al vender (`warranty_bp.py:41`); `notify_order_ready`/`notify_maintenance_due` existen y no se llaman; **lealtad acumula pero no se puede canjear en el POS**; **venta cruzada muerta** (`part_kits.py` no se importa); flotillas fuera del menú (`sidebar.js` no incluye `fleet`); analytics de cliente (LTV/churn) calculado y no mostrado (`crm_bp.py:227`, `crm_engine.py:289`).
---
## 3. Calidad de datos y catálogo
- **Imágenes: 0 en todos los tenants** (`image_url` vacío; `inventory_images` 0 filas). Bloquea publicar en ML, el catálogo público y el SEO.
- **`supplier_catalog`**: 17,246 items, **0 con imagen, 0 con descripción**.
- **`reorder_point`/`reorder_qty` 100% NULL**; `min_stock=0` en ~95% → **las alertas de stock bajo nunca disparan**; `reorder_alerts` = 0 filas.
- **Filtro de sucursal roto en reorden**: filtra por `inventory.branch_id`, que es NULL al 100%.
- **Motor de Proveedores/Órdenes de Compra completo y sin uso** (`purchase_orders` = 0 filas en todos los tenants); `supplier_catalog_prices` = 0; `catalog_part_id` 0/14,568 → sin precios/margen de proveedor.
- **Fitment**: `inventory_vehicle_compat` de Estrada = **20.7 M filas / 129,101 items ≈ 160 por pieza** (99.5% salida QWEN, conf 0.843) — **ruidoso**; Capa/Rached usan `supplier_catalog` (0.95, limpio).
- **Búsqueda**: Meili cubre inventario y proveedor, pero **el índice `nexus_parts` (1.55 M docs TecDoc) sigue re-indexándose y está muerto**; los aliases/cross-refs **no entran** a `smart_search`; sin sinónimos.
- **Estrada y Originales Autopartes son clones exactos** (mismos 157,243 SKUs y 20,687,557 fitments) → cuéntalos una vez.
---
## 4. IA no conversacional — qué construir (y en qué orden)
### 4.1 Lo que ya existe (reutilizable)
- `ai_chat.classify_part()` (`ai_chat.py:379`) → número OEM a `{name, brand, vehicle, category}`.
- `ai_chat.chat_with_image()` (`ai_chat.py:309`) → **visión**: identifica una autoparte por foto (Gemma/QWEN).
- `qwen_fitment.py` → compatibilidad pieza-vehículo validada contra la maestra (con `confidence`).
- `whisper_local.py` → **ASR local** (faster-whisper, CPU) para notas de voz.
- Meilisearch (keyword + typos; soporta vectores) con índices poblados.
- `nexpart_taxonomy.py` → 558 etiquetas (dataset listo para clasificador); `wa_learning.get_learning_pairs_for_training()` (`:73`) → pares texto→producto etiquetados.
- **Infra lista para OCR**: `tesseract` + `pytesseract` + `ffmpeg` + `pdftotext` ya instalados.
- **No hay** `sklearn`/`torch`/`transformers`/`faiss`; **sin GPU** (12 vCPU / 47 GB). Inferencia en CPU o vía QWEN/Hermes.
### 4.2 Propuestas priorizadas por **viabilidad de datos** (no por moda)
| Prio | Implementación | Punto de integración | Datos (reales hoy) | Enfoque | Beneficio |
|---|---|---|---|---|---|
| **A1** | **Auto-categorización de productos** (taxonomía Nexpart + categoría ML) | `inventory_bp.py:129` (hoy no persiste) y `marketplace_external_service.py` (publish); ML ya tiene `domain_discovery` en `meli_service.py:280` | 171 K SKUs, 18 K sin categoría; taxonomía de 558 etiquetas | Reglas → embeddings (Meili) → LLM (`classify_part`) como fallback | Habilita navegación/filtros y **publicación en lote sin elegir categoría a mano** |
| **A2** | **Deduplicación / matching de SKUs** (poblar `catalog_part_id` + aliases) | `catalog_import_service.py:57-76` | 406 K interchange + 9 K + 4 K aliases | Normalización + blocking por prefijo + similitud de embeddings | Desbloquea precios de proveedor, margen y enriquecimiento (hoy link 0%) |
| **A3** | **OCR/VLM de facturas de proveedor** | `supplier_engine.receive_po:214` / compra directa `inventory_bp.py:1198` | Facturas PDF/foto (infra ya instalada) | `pdftotext`+Tesseract y VLM (`chat_with_image`) + validación contra catálogo | Elimina captura manual y **alimenta `supplier_catalog_prices`** (hoy vacío) |
| **A4** | **Visión para graduar piezas usadas (Yonke)** | `yonke_bp.py:145` (subida de fotos) | Fotos de piezas (hoy 0) | VLM que sugiera `condition_grade` (A/B/C/D) y defectos; validar encuadre antes de publicar | Alta consistente <30 s, menos devoluciones, confianza del comprador |
| **A5** | **Anomalías de negocio** (descuentos, merma de stock, precios mal capturados) | post-commit de venta `pos_engine.py:462` + job nocturno sobre `sale_items`/`inventory_operations`/`audit_log` | Ventas/ops por tenant | EWMA/z-score robusto o Isolation Forest por empleado/SKU/sucursal | Detecta fuga de margen que los topes no capturan (apartados/cotizaciones los evaden) |
| **A6** | **Semantic search** (embeddings + sinónimos) | `meili_search.py:23`, `index_meilisearch.py:47` | Nombres/SKUs/cross-refs | Vectores en Meili (híbrido) + sinónimos + indexar interchange | "balatas"→"Brake Pad"; búsqueda por número cruzado |
| **A7** | **Clasificación/priorización de preguntas de MeLi** (+ auto-respuesta por plantilla) | `service.py:1151` (`sync_questions` → `_upsert_question`) | 18 preguntas (4 sin responder desde mayo) | Clasificador de intención (compat/precio/envío) + plantillas + `vehicle_compatibility` | SLA (ML penaliza respuestas lentas) y más conversión |
| **A8** | **Enriquecimiento de imágenes** (scraping + tagging por nombre de parte) | `image_scraper.bulk_import:71`, `image_service.save_image:74` | `supplier_catalog` 0 imágenes | Scraping existente + VLM para validar/clasificar | Desbloquea catálogo público, SEO y publicación ML |
| **A9** | **Score de riesgo de crédito (fiado)** | validación en `pos_engine.py:314`, `get_customer` `customers_bp.py:133` | Clientes + aging (pocas ventas aún) | Regresión logística/heurística ponderada (mora>30d) | Menos incobrables; subir límites a buenos clientes |
| **A10** | **Venta cruzada "esto + esto"** | hook muerto `part_kits.build_kit_text`; `pos.js:403` al agregar al carrito | `sale_items` + `KIT_SUGGESTIONS` | Reglas de asociación/co-ocurrencia, fallback a kits | +5-15% ticket típico |
| **A11** | **Forecast de demanda / punto de reorden** | `reorder_engine.py:26,78` | **Cold start**: máx 24 ventas; única serie = Atlas `historical_sales` (4,582 / 19 meses) | Media móvil/Exponencial (ROP = μ·LT + z·σ); ML real cuando haya volumen | ⚠️ Hoy **bloqueado por datos**; arrancar con reglas y migrar |
| **A12** | **Churn / próxima compra** | `crm_engine.py:289` (hoy umbrales fijos) | 52% de compradores de Atlas recompran | BG/NBD o regresión de intervalo intercompra | Campañas de reactivación dirigidas |
### 4.3 Lo que NO vale la pena (honestidad)
- **Triage de logs con LLM**: el volumen de errores es de KB/día; deduplicar por firma con reglas gana y cuesta cero.
- **Agrupación de alertas con ML**: hoy hay **un** receiver y **cero** alertas útiles; arreglar el routing da el 100% del valor.
- **Predicción de capacidad con IA**: `predict_linear(disk[6h], 7d)` en PromQL ya responde; es una alerta, no un modelo.
- **Forecast de demanda hoy**: sin historial transaccional, cualquier modelo sería ruido.
---
## 5. Roadmap propuesto
| Fase | Foco | Entregable verificable |
|---|---|---|
| **F0 — Blindar (2-3 días)** | Seguridad (pg_hba/firewall/secretos), Redis arriba, alerting, validar backup timer + offsite, 500 de query params, borrar jobs muertos | Celery vivo; alerta de backup llega a un humano; `5432` cerrado |
| **F1 — Caja correcta (1 sem)** | Bugs P0 de `pos.js`, reembolsos→caja, reversa de apartado, forzar topes de descuento también en cotización/apartado, cablear/eliminar `generate_cfdi` | Corte de caja cuadra con devoluciones; ticket offline funciona |
| **F2 — IA de datos (2-3 sem)** | A1 auto-categorización, A2 dedup SKUs, A3 OCR facturas, A8 imágenes | ≥90% SKUs categorizados; factura de proveedor → líneas en <1 min; catálogo con imágenes |
| **F3 — IA en el flujo (2-3 sem)** | A4 visión Yonke, A6 semantic search, A5 anomalías, A7 preguntas, A10 venta cruzada | Grado sugerido por foto; búsqueda por sinónimo; alerta de descuento anómalo; sugeridos en carrito |
| **F4 — Escala y producto (3-4 sem)** | PgBouncer + pool, `/metrics` de app + `pg_stat_statements`, A9 crédito, A12 churn, A11 forecast cuando haya datos, WA CRM piloto | 30+ tenants sin agotar conexiones; dashboard con métricas reales; score de crédito en fiado |
---
## 6. Métricas de éxito
- **Seguridad/ops:** 0 servicios DB con `trust`; 0 secretos en unit files legibles; alertas de backup/Celery entregadas; 30 días de backups verificados (local + offsite).
- **Producto:** corte de caja cuadra con devoluciones; 0 `ReferenceError` en POS; 100% de SKUs categorizados; catálogo público con >80% de items con imagen.
- **IA:** ≥90% de facturas de proveedor parseadas sin captura manual; grado de pieza usada sugerido y aceptado en ≥70% de altas; búsquedas sin resultado reducidas >30% (semantic); ≥1 alerta real de anomalía accionada por mes.

View File

@@ -1,199 +0,0 @@
# Plan Maestro de Mejoras — Nexus Autoparts
> **Fecha:** 11 Oct 2026 · **Base:** auditoría completa del sistema (6 investigaciones con evidencia en archivo:línea, SQL y curl)
> **Énfasis:** MercadoLibre (eje comercial) + apertura del mercado de **partes usadas** (yonkes/deshuesaderos)
---
## 0. Resumen ejecutivo
El sistema es un **esqueleto potente con el ciclo comercial roto en los últimos metros**. Lo que funciona bien es la base (motor de ventas con locks y partida doble, aislamiento multi-tenant, compatibilidad vehicular, OAuth de MeLi con refresh persistido, WA CRM recién instalado). Lo que está roto es justo lo que genera ingresos:
| # | Bloqueante | Impacto | Evidencia |
|---|---|---|---|
| 1 | **Backups caídos desde el 1-oct** | Sin respaldo fresco de ningún tenant (riesgo existencial) | `scripts/backup_selective.sh:299-303` — `((deleted++))` devuelve 1 y mata el script bajo `set -euo pipefail` |
| 2 | **Alta de tenants falla** | No se puede onboardear ningún cliente nuevo | `tenant_manager.py:201-211` aplica `v1.2_subdomain.sql` (migración de *master*) al tenant → rollback |
| 3 | **CFDI 100% roto** | Ninguna factura se timbra | `invoicing_bp.py:65` — `SELECT … codigo_postal FROM branches` (la columna es `cp`) |
| 4 | **Órdenes MeLi nunca se convierten a venta** | 31/31 órdenes de Rached sin vender en el POS | `marketplace_external_service.py:1619` — `inventory_record_sale(..., reference=)` no existe en la firma |
| 5 | **Publicar en MeLi desde el POS está muerto** | Validar/Publicar no hacen nada (ReferenceError) | `inventory.js:2069,2135` llaman `_collectMeliCustomData()`, que no existe |
| 6 | **Sync de stock MeLi falla 52/52** | Envía `price:0` a ML cada 15 min (0 éxitos desde junio) | `tasks.py:135-145` — listings con `inventory_id NULL` |
| 7 | **Webhook MeLi nunca procesa** | Sin notificaciones en tiempo real, público y sin firma | `marketplace_external_bp.py:775-790` — consulta `tenant_config` en la BD maestra (no existe ahí) |
| 8 | **Catálogo público con 500s** | El visitante nuevo ve error en buscador, categorías y detalle | `dashboard/server.py:977,1281,1994` contra tablas TecDoc retiradas |
| 9 | **Plan `demo` → fallback `free`** | Estrada (157,243 productos) bloqueada en 100 productos | `billing.py:25-28` + `tenants.plan='demo'` |
| 10 | **`/api/admin/tenants*` sin autenticación** | Cualquiera en internet lista y modifica módulos de tenants | `dashboard/server.py:4660,4674,4691` |
**Y el hallazgo estratégico:** el negocio de **partes usadas es hoy imposible de operar** en el sistema (no existe condición, ni pieza única, ni vehículo donador, ni multi-foto) — y MeLi publica todo como `"condition":"new"` hardcodeado. Abrir ese mercado requiere un cambio de modelo de datos, no cosmético.
---
## 1. MercadoLibre — cerrar el ciclo antes de soñar en grande
### 1.1 Fase A — Desbloquear lo que ya está construido (1 semana)
| Tarea | Detalle | Esfuerzo |
|---|---|---|
| A1 | Implementar `_collectMeliCustomData()`, `_pollMeliAsync()`, `_renderPublishResult()` en `inventory.js` (los inputs de título/precio/stock y atributos **ya se generan pero nunca se envían**) y mandar `custom_data` al backend (`marketplace_external_service.py:669-673`) | M |
| A2 | Corregir `convert_order_to_sale`: usar `record_sale(conn, inventory_id, branch_id, quantity, sale_id=…)` — hoy `TypeError` → rollback → 31 órdenes sin convertir (`service.py:1619`, `inventory_engine.py:199`) | S |
| A3 | `import_existing_listings`: poblar `inventory_id` por `seller_custom_field`/`part_number`/alias y **saltar el push cuando `price<=0`**; el `ON CONFLICT` no aplica con NULL (`service.py:1785`) | S |
| A4 | Webhook: resolver tenant por `meli_user_id` consultando cada `tenant_config` de tenant (no la maestra), validar `X-Signature` con `validate_webhook_signature` (ya existe, nunca se llama) y procesar el `resource` correcto (no un sync completo) | M |
| A5 | Normalizar `status` de preguntas (`UPPER`) — hoy 4 preguntas reales no se pueden responder por comparar `'unanswered'` vs `UNANSWERED` (`marketplace_external.js:516`) | S |
| A6 | Pasar `tenant_conn` a `_get_meli_service` en `sync_meli_stock_price_task` (`tasks.py:112`) para no perder el refresh token rotado | S |
| A7 | Pausar/reactivar automáticamente por stock (0 → pausar, >0 → reactivar) en la cola `meli_sync_queue` + drenarla por timer | M |
| A8 | Sincronizar órdenes en todos los estados (`cancelled`, `payment_required`…) y corregir `buyer_nickname` (ML ya no manda `from.nickname`) | S |
| A9 | Corregir el **estimador de comisiones por artículo** (busca `#meliFee-<id>` en un DOM que nunca lo crea, `inventory.js:2295`) | S |
| A10 | Definir/otorgar el permiso `marketplace.manage` (no existe en ningún tenant) y proteger los GET del módulo | S |
**Resultado:** el ciclo MeLi→POS (publicar → vender → convertir → facturar) cierra por primera vez.
### 1.2 Fase B — Publicar en volumen (2-3 semanas) ← el verdadero cuello de botella
| Tarea | Detalle | Esfuerzo |
|---|---|---|
| B1 | **Pipeline masivo de imágenes** — el bloqueo real: 0 imágenes en 6,851 SKUs de Rached, 0 en 14,568 de Capi, 1 en 157,243 de Estrada. ML exige imagen. Usar los servicios de scraping existentes (`services/image_scraper.py`, `image_service.py`) + una cola por lotes con revisión | L |
| B2 | **Selección y publicación por lotes real**: hoy la UI solo selecciona una página de 50 y un solo `category_id` para todo el lote. Agregar: seleccionar todo el resultado filtrado, categorización asistida por IA (nombre+marca→categoría ML con `domain_discovery`), y confirmación por grupos | M |
| B3 | **Rate-limit/backoff** en el worker de publicación (Celery) con reintentos y progreso visible (barra + reporte de fallidos por motivo) | M |
| B4 | **Ciclo export→edición→reimportación** para fitment e imágenes (hoy el export no incluye compat ni image_url) | M |
### 1.3 Fase C — Vender más (3-4 semanas)
| Oportunidad | Hoy | Qué agregar |
|---|---|---|
| **Variaciones** | Nada (0 referencias a `variations`) | Agrupar SKUs en una publicación con `variations`/`attribute_combinations` (mismo producto, varias medidas) |
| **Catálogo de MeLi** | Solo `category_id` | Detectar categorías que **exigen catálogo** (`catalog_listing`, `catalog_product_id`), avisar en la UI y usar `price_to_win` para competir por la oferta ganadora |
| **Promociones** | Nada | Oferta del día, descuentos por volumen, `sale_price` por publicación |
| **Envíos** | Solo me2/custom, sin etiquetas | Flex, generación de etiqueta, `mark_ready_to_ship` (existe y nunca se usa), tracking |
| **Preguntas** | Manual y roto | Auto-respuesta con `ai_chat.py` + plantillas por categoría + SLA visible |
| **Reputación** | Nada | Panel con `/users/{id}/reputation`: nivel, reclamos, cancelaciones, tiempo de despacho |
| **Rentabilidad por publicación** | Solo el estimado al publicar (no se guarda) | Persistir comisión + envío + costo en `marketplace_listings` y panel de margen real por SKU |
| **Facturación de ventas MeLi** | Ningún enganche a CFDI | Encolar CFDI automático al convertir orden (con RFC del comprador si lo pide) |
| **Cancelaciones/reintegros/claims** | Solo estado local | Llamar a la API de ML (cancelar, devoluciones, mediaciones) con SLA |
| **Multi-cuenta** | 1 cuenta MeLi por tenant | Varias cuentas MeLi por tenant (varias marcas) con selección por publicación |
| **Panel comercial** | Nada | Ventas MeLi vs POS, conversión, SKUs sin stock con publicación activa, top ventas, alertas de publicaciones caídas |
### 1.4 Fase D — Robustez operativa
- Onboarding asistido de credenciales MeLi (hoy es manual y el fallo típico —ACCA— es un `client_secret` regenerado que nadie detecta hasta que el cliente llama; ya mejoramos el mensaje, falta un **chequeo proactivo diario** que avise antes).
- Versionar el timer de sync en el repo (hoy vive solo en `/etc/systemd/system`).
- Pruebas automatizadas del módulo (hoy: **cero** tests de MeLi).
---
## 2. Abrir el mercado: negocios de SOLO partes usadas (yonkes/deshuesaderos)
### 2.1 Por qué hoy no se puede (evidencia)
| Requisito del yonke | Estado | Dónde se rompe |
|---|---|---|
| Pieza única (1-de-1, sin código de parte) | ❌ | `part_number` es **NOT NULL + UNIQUE global** (`v4.0_multi_branch.sql:214`); dos espejos iguales de donadores distintos **se fusionan** en el import |
| Saber de qué auto salió (donador) | ❌ | No hay `donor_vin`/`donor_mileage`; la compat existente es "sirve para", no "salió de" |
| Condición nuevo/usado/A/B/C | ❌ | **0 columnas** de condición en todo el esquema; MeLi publica `"condition":"new"` hardcodeado |
| Fotos múltiples (ángulos + daños) | ❌ | Un solo `image_url` varchar; cada subida **reemplaza** la anterior (`inventory_bp.py:995`) |
| Garantía por días (15/30 días) | ⚠️ | Solo `warranty_months` (mínimo 1 mes) |
| Buscar "todo lo que tengo de un Civic 2015" | ❌ | La navegación por vehículo exige `part_number` para poblar compat |
| Consignación / piezas de terceros | ❌ | No existe modelo de propiedad; todo el stock se asume propio |
| Valuación con costo 0 | ⚠️ | Vender funciona, pero los reportes de valoración/ABC dan $0 (`inventory_bp.py:1879`) |
### 2.2 Plan de producto: "Nexus Yonke" (5-6 semanas)
**M1 — Modelo de datos (1 semana)**
- `inventory`: `condition` (`nuevo|usado|reconstruido`), `condition_grade` (A/B/C/D), `is_unique_piece`, `serial_number`, `donor_vin`, `donor_make/model/year`, `donor_mileage_km`, `ownership` (`propio|consigna`), `acquisition_cost`.
- **Relajar unicidad**: índice único **parcial** `WHERE part_number IS NOT NULL` + auto-SKU para piezas únicas (`USADO-<VIN-corto>-<secuencia>`), manteniendo el flujo de partes nuevas intacto.
- Nueva tabla `inventory_images (inventory_id, url, order, is_primary)` con migración del `image_url` actual.
- `warranties`: `warranty_days` + política por defecto de usados en `tenant_config`.
- Nuevas tablas `consignments` / `consignment_items` (tercero, pieza, precio pactado, comisión, estado: en_consigna/vendida/devuelta/liquidada).
**M2 — Alta rápida en mostrador (1-2 semanas)**
- Flujo "Registrar pieza usada": **foto múltiple** (cámara del tablet) + **VIN del donador** (el decodificador NHTSA ya existe: `/pos/api/catalog/vin`) + pieza + precio + condición. Objetivo: **< 30 segundos por pieza**.
- **Ingreso de donador completo**: registrar el auto (VIN, km, color) y generar N piezas colgadas de ese donador con un solo flujo (hoy: pieza por pieza).
- Etiqueta propia por pieza única (código de barras ya implementado) para control físico en el patio.
**M3 — Búsqueda por donador (1 semana)**
- Filtro/búsqueda por vehículo **donador** (marca/modelo/año/color/km) sin requerir `part_number`; indexar esos campos en Meilisearch.
- Buscador "¿qué tengo de este auto?" + "¿qué tengo compatible con este auto?" (compat comprador, ya existe) como dos pestañas distintas.
**M4 — MeLi para usados (1 semana)**
- `condition` dinámico en el payload (usado/remanufacturado) + grado en el título/atributos.
- Mapeo de categorías ML para usados (ML tiene categorías de autopartes usadas con reglas propias).
- Fotos múltiples al anuncio (ML acepta hasta 10; hoy se manda 1).
**M5 — Consignación y garantía (1-2 semanas)**
- Liquidación a consignantes (reporte por periodo, comisión pactada, estado de cuenta del consignante).
- Garantía de usados por días con política por defecto y comprobante impreso.
- Reportes del segmento: rotación por donador, piezas sin foto, días en patio, margen por pieza.
**M6 — Plan y onboarding comercial (3-4 días)**
- Plan **"Yonke"** (precio menor): sin catálogo TecDoc, límites amplios, módulos ajustados, onboarding sin importación de catálogo nuevo (alta por escaneo/foto).
- Landing y material para el segmento (hoy `grep yonke|usadas` en docs/pitch → 0 resultados).
- **Marketplace B2B entre yonkes** (`is_seller`, hoy nunca activado en ningún tenant) como diferenciador: intercambio de piezas usadas entre deshuesaderos de la red.
---
## 3. Núcleo comercial y fiscal
**CFDI (crítico):**
- Corregir `codigo_postal` → `cp` (`invoicing_bp.py:65`) — desbloquea ingreso, egreso, PDF y factura global.
- **Complementos de pago (REP)** para PPD: `build_pago_payload` existe y nunca se invoca; hoy toda venta a crédito incumple SAT.
- Subir CSD por sucursal (hoy `has_csd:false` → ni timbrando funcionaría).
- Devoluciones: encolar CFDI **egreso** automático + reembolso trazado en caja.
- Moneda/tipo de cambio en facturación (`invoicing_bp.py:83` no lee `currency`).
**Contabilidad:**
- Pólizas para abonos de clientes, cortes de caja, devoluciones y apartados (4 funciones de `accounting_engine` existen y **nunca se llaman**).
- `record_return_entry` (no existe).
- Dejar de perder asientos en silencio: los hooks `try/except: pass` deben loggear (ya se perdió 1 de 18 ventas sin aviso).
- **Exportables para el contador**: pólizas, balanza, estado de resultados, DIOT (hoy solo antigüedad de saldos).
- CxP real con tabla de pagos a proveedores (`paid = 0 # TODO`).
- Caja: pagos mixtos no suman al efectivo esperado; apartados **duplican** el efectivo en el corte.
---
## 4. Catálogo, búsqueda y datos
1. **Arreglar el catálogo público** (visitante nuevo ve 500): `/api/catalog/search`, `/api/search`, `/api/categories`, modo OEM y detalle de parte. Modo `local` por defecto + degradación elegante (el POS ya tiene el guard `_oem_blocked`, el dashboard no).
2. **Conectar Meilisearch de verdad**: los índices `nexus_inventory_*` ya están poblados y actualizados a diario, **ningún código los consulta**; la búsqueda local hace seq-scan de 157k filas (175 ms).
3. **Calidad de fitment**: el fitment QWEN generó **20.5M filas ruidosas** (Corolla 2012 → 411,685 "compatibles" ≈ 40% del catálogo). Política de confianza por fuente + validación + priorizar la compat de proveedor (limpia, 29,760 MYEs). Decidir el destino del catálogo maestro (los 15 GB de JSON TecDoc siguen en disco).
4. **Imágenes masivas** (bloquea catálogo público y MeLi) — mismo pipeline de B1.
5. **Import/export round-trip** con compat, imágenes y min/max/reorder.
6. **Import con vehículo roto**: `bulk-import` devuelve 500 si el CSV trae make/model/year (consulta tablas de vehículos en la conexión del tenant) — justo el flujo de "para qué carro sirve".
7. **Multi-sucursal real**: todo el stock vive en la sucursal 1 y `inventory.branch_id` es NULL; transferencias/conteos/reorden implementados y **sin uso** (reorden además filtra por la columna equivocada y `reorder_point` es NULL al 100%).
8. **Etiquetas**: código de barras generado pero **sin endpoint de impresión de etiquetas** (solo tickets).
---
## 5. SaaS, onboarding y escala
| Tema | Estado | Acción |
|---|---|---|
| Alta de tenants | ❌ Rota (v1.2/v1.6 de master en el runner de tenants) | Marcar como master-only/mover al runner master |
| Template de tenant | Congelado en 32 tablas (le faltan ~56) | Refrescar template en cada alta o eliminarlo del flujo |
| Planes | `demo`/`standard` no existen en `PLANS` → fallback a límites de `free` | Agregar planes reales o mapear; aplicar la expiración de demos |
| Fuga de ingresos | Upgrade a `enterprise` **gratis** desde el POS + demos vencidas sin efecto + `subscriptions` desincronizada | Quitar el autoservicio, sincronizar `tenants.plan`↔`subscriptions.plan`, job diario de expiración |
| Seguridad multi-tenant | `/api/admin/tenants*` del dashboard **sin auth** | Proteger + quitar `?tenant=` de la superficie pública |
| Módulos | Dos nomenclaturas (`module_*` vs `*_enabled`) con defaults contradictorios | Unificar y sembrar en el template |
| Drift de esquema | `v3.5_whatsapp_state_machine` marcada `SKIP` → 6 tenants sin `wa_customer_links`/`wa_learning_sessions` → **el chatbot WhatsApp falla en esos tenants** | Aplicar la migración real |
| Bases huérfanas | 6 DBs `tenant_*` sin fila en `tenants` (~84 MB) | Limpiar |
| Reset de demo | No limpia clientes ni conversaciones (filtra PII entre prospectos) | Completar el truncado |
| Escala a 100 tenants | Pool 2-20 conexiones × 8 workers × 100 tenants > límite de Postgres; todo con superusuario | PgBouncer + roles por tenant + métricas por tenant |
| Secretos | `POS_JWT_SECRET`/API keys en unit files legibles por grupo | EnvironmentFile root-only + rotación |
---
## 6. Roadmap propuesto (6 sprints)
| Sprint | Foco | Entregable verificable |
|---|---|---|
| **S1 — Estabilizar (1 sem)** | Backups (fix `((deleted++))` + verificar drill), alta de tenants, auth de `/api/admin/tenants*`, plan `demo`, expiración de demos | Respaldo fresco de 9 bases verificado con restore; un tenant nuevo creado de punta a punta |
| **S2 — MeLi cierra el ciclo (1 sem)** | Fase A completa (10 tareas) | Publicar 1 SKU desde el POS → vender en ML → orden convertida a venta → CFDI encolado |
| **S3 — Volumen MeLi (2 sem)** | Imágenes masivas + lotes + rate-limit + export/import con compat | 200+ SKUs publicados sin intervención manual, con progreso y reporte de fallos |
| **S4 — Yonke MVP (2-3 sem)** | M1+M2+M3 (modelo, alta rápida por donador, búsqueda por donador) | Un deshuesadero de prueba carga 50 piezas usadas con foto y VIN, y las encuentra por vehículo |
| **S5 — Fiscal y contabilidad (2 sem)** | CFDI (cp, REP, CSD, egresos), pólizas faltantes, caja (mixtos/apartados), exportables contador | Factura timbrada real; estado de resultados cuadra; DIOT exportable |
| **S6 — Vender más + abrir mercado (2-3 sem)** | MeLi Fase C (variaciones, catálogo, reputación, rentabilidad, preguntas IA) + Yonke M4-M6 (ML usados, consignación, plan Yonke) | Panel de rentabilidad por publicación; primera pieza usada publicada en ML como `used` |
---
## 7. Métricas de éxito
- **Confiabilidad:** 30 días consecutivos con respaldo verificado; 0 tenants con esquema incompleto; 0 endpoints 500 en el barrido automatizado.
- **MercadoLibre:** conversión de órdenes → venta en el POS (hoy 0%, meta 100%); publicaciones sincronizadas sin error (hoy 0/52, meta 100%); ≥200 SKUs publicados en volumen; % de órdenes facturadas automáticamente.
- **Yonke:** tiempo de alta de una pieza usada < 30 s; 100% de piezas usadas con foto y VIN de donador; búsqueda por donador con resultados < 500 ms; 1 cliente del segmento en producción.
- **Comercial:** tenants de pago con plan correcto aplicado (hoy 3 con plan desincronizado), fin de la fuga por autoservicio, ≥1 cliente nuevo onboardeado sin intervención manual.

View File

@@ -8,7 +8,6 @@ Panel de control central para gestionar instancias multi-tenant de Nexus POS.
- **Monitorear** salud de todos los servicios (POS, DB, Redis, Quart, Systemd) - **Monitorear** salud de todos los servicios (POS, DB, Redis, Quart, Systemd)
- **Gestionar tenants**: activar/desactivar, resetear datos, eliminar - **Gestionar tenants**: activar/desactivar, resetear datos, eliminar
- **Ejecutar migraciones** de schema en todos los tenants desde una UI - **Ejecutar migraciones** de schema en todos los tenants desde una UI
- **Cartera**: métricas de negocio por tenant (rollup diario), score de salud/riesgo de churn y anomalías entre tenants
- **Dashboard** con estadísticas globales y alertas de demos por expirar - **Dashboard** con estadísticas globales y alertas de demos por expirar
## Estructura ## Estructura
@@ -22,15 +21,12 @@ manager/
├── services/ # Lógica de negocio ├── services/ # Lógica de negocio
│ ├── health_service.py # Health checks de infraestructura │ ├── health_service.py # Health checks de infraestructura
│ ├── tenant_service.py # CRUD tenants (usa tenant_manager del POS) │ ├── tenant_service.py # CRUD tenants (usa tenant_manager del POS)
│ ├── portfolio_service.py# Rollup de métricas, lectura de cartera y anomalías
│ ├── business_metrics.py # Z-score robusto multi-tenant + score de salud
│ └── migration_service.py# Orquestación de migraciones │ └── migration_service.py# Orquestación de migraciones
├── blueprints/ # API REST ├── blueprints/ # API REST
│ ├── auth_bp.py # Login/logout JWT │ ├── auth_bp.py # Login/logout JWT
│ ├── tenants_bp.py # Gestión de tenants │ ├── tenants_bp.py # Gestión de tenants
│ ├── demos_bp.py # Creación de demos │ ├── demos_bp.py # Creación de demos
│ ├── health_bp.py # Health checks │ ├── health_bp.py # Health checks
│ ├── portfolio_bp.py # /api/tenants/metrics y /api/portfolio/anomalies
│ └── admin_bp.py # Dashboard stats y migraciones │ └── admin_bp.py # Dashboard stats y migraciones
├── static/ # Frontend SPA ├── static/ # Frontend SPA
│ ├── css/manager.css │ ├── css/manager.css
@@ -197,29 +193,6 @@ systemctl start nexus-manager
- Ve a **Migraciones** para ver la versión de schema de cada tenant - Ve a **Migraciones** para ver la versión de schema de cada tenant
- **Ejecutar todas pendientes** aplica migraciones en TODOS los tenants - **Ejecutar todas pendientes** aplica migraciones en TODOS los tenants
### Cartera (métricas, salud y anomalías)
- La sección **Cartera** muestra el score de salud/riesgo de churn por tenant y
las anomalías detectadas entre tenants (z-score robusto con mediana + MAD).
- Los datos vienen de la tabla maestra `tenant_metrics_daily`, que se llena con
el rollup diario:
```bash
python3 scripts/rollup_tenant_metrics.py # hoy, tenants activos
python3 scripts/rollup_tenant_metrics.py --days 30 # backfill últimos 30 días
python3 scripts/rollup_tenant_metrics.py --all # incluir inactivos
```
El script crea la tabla de forma idempotente (no usa el runner de migraciones
de tenants) y es seguro re-ejecutarlo para la misma fecha.
- Endpoints (requieren `Authorization: Bearer <token>`):
- `GET /api/tenants/metrics?days=14` — rollup + `{health_score, band, signals}` por tenant
- `GET /api/portfolio/anomalies?date=YYYY-MM-DD&z=3.0&min_tenants=5` — anomalías entre tenants
Con menos de `min_tenants` tenants en la cohorte el detector devuelve
`cold_start: true` y no emite hallazgos. La "actividad" usa `audit_log` como
proxy porque no se persisten logins individuales.
--- ---
## Notas de seguridad ## Notas de seguridad

View File

@@ -15,7 +15,6 @@ from blueprints.tenants_bp import tenants_bp
from blueprints.demos_bp import demos_bp from blueprints.demos_bp import demos_bp
from blueprints.health_bp import health_bp from blueprints.health_bp import health_bp
from blueprints.admin_bp import admin_bp from blueprints.admin_bp import admin_bp
from blueprints.portfolio_bp import portfolio_bp
def create_app(): def create_app():
@@ -32,7 +31,6 @@ def create_app():
app.register_blueprint(demos_bp) app.register_blueprint(demos_bp)
app.register_blueprint(health_bp) app.register_blueprint(health_bp)
app.register_blueprint(admin_bp) app.register_blueprint(admin_bp)
app.register_blueprint(portfolio_bp)
# ─── Frontend Routes ─────────────────────────────────────────────────── # ─── Frontend Routes ───────────────────────────────────────────────────
@app.route("/") @app.route("/")
@@ -59,10 +57,6 @@ def create_app():
def health_page(): def health_page():
return render_template("index.html") return render_template("index.html")
@app.route("/portfolio")
def portfolio_page():
return render_template("index.html")
@app.route("/migrations") @app.route("/migrations")
def migrations_page(): def migrations_page():
return render_template("index.html") return render_template("index.html")

View File

@@ -1,48 +0,0 @@
"""Portfolio operations blueprint: business metrics, health and anomalies.
Endpoints
---------
``GET /api/tenants/metrics`` rollup window + health score per tenant
``GET /api/portfolio/anomalies`` cross-tenant anomalies (median + MAD z-score)
"""
from datetime import date as date_cls
from flask import Blueprint, jsonify, request
from services import portfolio_service
from blueprints.auth_bp import require_manager_auth
portfolio_bp = Blueprint("portfolio", __name__, url_prefix="/api")
@portfolio_bp.route("/tenants/metrics", methods=["GET"])
@require_manager_auth
def tenant_metrics():
days = request.args.get("days", 14, type=int)
include_inactive = request.args.get("all", "false").lower() == "true"
return jsonify(portfolio_service.get_tenant_metrics(
days=days, include_inactive=include_inactive))
@portfolio_bp.route("/portfolio/anomalies", methods=["GET"])
@require_manager_auth
def portfolio_anomalies():
target = request.args.get("date")
if target:
try:
date_cls.fromisoformat(target)
except ValueError:
return jsonify({"error": "date must be YYYY-MM-DD"}), 400
kwargs = {
"date": target,
"include_inactive": request.args.get("all", "false").lower() == "true",
}
z = request.args.get("z", type=float)
if z is not None:
kwargs["z_threshold"] = z
min_tenants = request.args.get("min_tenants", type=int)
if min_tenants is not None:
kwargs["min_tenants"] = min_tenants
return jsonify(portfolio_service.get_portfolio_anomalies(**kwargs))

View File

@@ -1,352 +0,0 @@
"""Portfolio-level business metrics: cross-tenant anomaly detection and a
tenant health / churn-risk heuristic.
This is deliberately *not* conversational AI. At a portfolio of ~8 tenants the
honest tool is a robust statistic and a transparent heuristic, so every number
here is explainable:
* ``detect_portfolio_anomalies`` compares each tenant against the cohort of the
other tenants on the same day using a median + MAD robust z-score. MAD is
used instead of the mean/standard deviation because with a handful of points
a single runaway tenant would otherwise inflate the scale and hide its own
anomaly.
* ``compute_health`` is a weighted penalty/bonus heuristic over usage signals
(sales recency, activity, module usage, error counts and trend).
Both functions are pure (no DB access) so they can be unit-tested and reused
from the manager API and from scripts. Cold start is explicit: with fewer than
``min_tenants`` observations in the cohort the detector reports
``cold_start=True`` and raises no flags rather than inventing signal.
"""
from datetime import UTC, datetime
# Below this many tenants in the cohort we do not trust cross-tenant statistics.
MIN_COHORT = 5
# Robust z threshold. 3.0 robust sigmas with 8 tenants is already conservative
# (MAD is small, so ~3 is a genuinely unusual point, not a normal spread).
DEFAULT_Z = 3.0
# Metrics compared across tenants. Everything is derived from the daily rollup.
COHORT_METRICS = [
("ventas_total", "Ventas (MXN)"),
("ventas_count", "Tickets"),
("ticket_promedio", "Ticket promedio"),
("clientes_nuevos", "Clientes nuevos"),
("actividad_count", "Eventos de actividad"),
("errores_5xx", "Errores 5xx"),
("items_activos", "Items activos"),
("skus", "SKUs"),
("clientes", "Clientes"),
("fitment_pct", "Cobertura fitment (%)"),
]
# Health bands, high to low.
BANDS = [
(80, "healthy", "Saludable"),
(60, "stable", "Estable"),
(40, "at_risk", "En riesgo"),
(0, "critical", "Crítico"),
]
SEVERITY_ORDER = {"high": 0, "medium": 1, "low": 2}
def _median(values):
"""Median of a list of floats (None for empty)."""
vals = sorted(float(v) for v in values if v is not None)
n = len(vals)
if n == 0:
return None
mid = n // 2
if n % 2:
return vals[mid]
return (vals[mid - 1] + vals[mid]) / 2.0
def robust_stats(values):
"""Return ``(median, scale)`` for a sample.
``scale`` is the MAD when non-zero. When most values are identical
(MAD == 0, common for sparse metrics such as daily sales) it falls back to
a scaled mean absolute deviation so a real outlier is still measurable;
when the sample is completely constant it returns 0.0 and the caller skips
the metric instead of dividing by zero.
"""
vals = [float(v) for v in values if v is not None]
if not vals:
return None, None
med = _median(vals)
mad = _median([abs(v - med) for v in vals])
if mad > 1e-9:
return med, mad
meanad = sum(abs(v - med) for v in vals) / len(vals)
if meanad > 1e-9:
return med, meanad * 1.2533
return med, 0.0
def robust_z(value, med, scale):
"""Robust z-score ``0.6745 * (x - med) / scale`` or None if undefinable."""
if value is None or med is None or scale is None or scale <= 1e-9:
return None
return 0.6745 * (float(value) - med) / scale
def detect_portfolio_anomalies(rows, date=None, metrics=None,
min_tenants=MIN_COHORT, z_threshold=DEFAULT_Z):
"""Flag tenants whose metric is unusually high/low versus the cohort.
``rows`` is a list of dicts, one per tenant for a single day, each carrying
the metric keys from :data:`COHORT_METRICS` plus ``tenant_id`` and
``tenant_name``. Returns a report dict with ``anomalies`` sorted by
strength and a ``cold_start`` flag.
"""
metrics = metrics or [m for m, _ in COHORT_METRICS]
labels = dict(COHORT_METRICS)
cohort_size = len(rows)
anomalies = []
evaluated = []
skipped = []
if cohort_size < min_tenants:
for key in metrics:
skipped.append({"metric": key, "reason": "cohorta insuficiente",
"cohort_size": cohort_size})
for key in metrics:
values = [r.get(key) for r in rows if r.get(key) is not None]
if cohort_size < min_tenants:
continue
if len(values) < min_tenants:
skipped.append({"metric": key, "reason": "cohorta insuficiente",
"cohort_size": len(values)})
continue
med, scale = robust_stats(values)
if not scale:
skipped.append({"metric": key, "reason": "sin varianza en la cohorte",
"cohort_size": len(values)})
continue
evaluated.append({
"metric": key,
"label": labels.get(key, key),
"cohort_median": round(med, 2),
"cohort_mad": round(scale, 2),
"cohort_size": len(values),
})
for row in rows:
value = row.get(key)
z = robust_z(value, med, scale)
if z is None or abs(z) <= z_threshold:
continue
abs_z = abs(z)
severity = "high" if abs_z >= z_threshold * 1.5 else "medium"
anomalies.append({
"tenant_id": row.get("tenant_id"),
"tenant_name": row.get("tenant_name"),
"metric": key,
"label": labels.get(key, key),
"value": round(float(value), 2),
"cohort_median": round(med, 2),
"cohort_mad": round(scale, 2),
"z": round(z, 2),
"direction": "peak" if z > 0 else "drop",
"severity": severity,
})
anomalies.sort(key=lambda a: abs(a["z"]), reverse=True)
by_tenant = {}
for a in anomalies:
entry = by_tenant.setdefault(a["tenant_id"], {
"tenant_id": a["tenant_id"],
"tenant_name": a["tenant_name"],
"findings": [],
})
entry["findings"].append({
"metric": a["metric"], "label": a["label"], "value": a["value"],
"z": a["z"], "direction": a["direction"], "severity": a["severity"],
})
return {
"generated_at": datetime.now(UTC).isoformat(),
"date": str(date) if date else None,
"cold_start": cohort_size < min_tenants,
"cohort_size": cohort_size,
"thresholds": {
"method": "robust z-score (median + MAD)",
"z_threshold": z_threshold,
"min_tenants": min_tenants,
},
"metrics_evaluated": evaluated,
"metrics_skipped": skipped,
"anomalies": anomalies,
"by_tenant": by_tenant,
"summary": {
"flagged_tenants": len(by_tenant),
"findings": len(anomalies),
},
}
def _band_for(score):
for threshold, key, label in BANDS:
if score >= threshold:
return key, label
return "critical", "Crítico"
def _days_since(ts, now):
if ts is None:
return None
if isinstance(ts, str):
try:
ts = datetime.fromisoformat(ts.replace("Z", "+00:00"))
except ValueError:
return None
if ts.tzinfo is None:
ts = ts.replace(tzinfo=UTC)
return max(0, (now - ts).days)
def compute_health(tenant, history, now=None):
"""Heuristic health / churn-risk score for one tenant.
``tenant`` is the master row (``id``, ``name``, ``plan``, ``is_active``,
optional ``expires_at``). ``history`` is the tenant's rollup rows, oldest
first. Returns ``{"health_score", "band", "band_label", "signals",
"cold_start", "components"}``.
The score starts at 100 and applies transparent penalties. Activity is
measured from ``audit_log`` events because the platform does not persist
per-login rows; the API response notes this proxy explicitly.
"""
now = now or datetime.now(UTC)
signals = []
components = {}
score = 100.0
def add_penalty(points, code, severity, detail):
nonlocal score
score -= points
signals.append({"code": code, "severity": severity, "detail": detail})
if not history:
return {
"health_score": None,
"band": "unknown",
"band_label": "Sin datos",
"cold_start": True,
"signals": [{"code": "no_metrics", "severity": "medium",
"detail": "Sin rollup de métricas; ejecuta "
"scripts/rollup_tenant_metrics.py"}],
"components": {},
}
latest = history[-1]
recent7 = history[-7:]
prev7 = history[-14:-7]
# ── 1. Tenant lifecycle ────────────────────────────────────────────────
if tenant and not tenant.get("is_active", True):
add_penalty(25, "tenant_inactive", "high", "Tenant desactivado")
expires_at = (tenant or {}).get("expires_at")
if expires_at is not None:
if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=UTC)
if expires_at < now:
add_penalty(15, "subscription_expired", "high",
"Suscripción vencida")
# ── 2. Sales recency ───────────────────────────────────────────────────
days_sale = _days_since(latest.get("ultima_venta_at"), now)
components["dias_desde_ultima_venta"] = days_sale
if days_sale is None:
add_penalty(20, "no_sales_ever", "high",
"Sin ventas registradas en el histórico")
elif days_sale > 30:
add_penalty(30, "stale_sales", "high",
f"Sin ventas en {days_sale} días")
elif days_sale > 14:
add_penalty(15, "slow_sales", "medium",
f"Última venta hace {days_sale} días")
elif days_sale > 7:
add_penalty(6, "quiet_sales", "low",
f"Última venta hace {days_sale} días")
# ── 3. Activity recency (proxy for logins) ─────────────────────────────
days_act = _days_since(latest.get("ultima_actividad_at"), now)
components["dias_desde_ultima_actividad"] = days_act
act7 = sum((r.get("actividad_count") or 0) for r in recent7)
components["actividad_7d"] = act7
if days_act is None:
add_penalty(15, "no_activity", "medium",
"Sin actividad operativa registrada")
elif days_act > 14:
add_penalty(12, "stale_activity", "medium",
f"Sin actividad en {days_act} días")
elif days_act > 7:
add_penalty(6, "slow_activity", "low",
f"Última actividad hace {days_act} días")
elif act7 == 0:
add_penalty(8, "idle_week", "low", "Sin eventos de actividad en 7 días")
# ── 4. Trend (last 7 days vs previous 7) ───────────────────────────────
cur7 = sum(float(r.get("ventas_total") or 0) for r in recent7)
prv7 = sum(float(r.get("ventas_total") or 0) for r in prev7)
components["ventas_7d"] = round(cur7, 2)
components["ventas_7d_previas"] = round(prv7, 2)
if prv7 > 0:
change = (cur7 - prv7) / prv7
components["tendencia_ventas_pct"] = round(change * 100, 1)
if change <= -0.5:
add_penalty(18, "sales_drop", "high",
f"Ventas 7d cayeron {abs(change) * 100:.0f}% vs semana previa")
elif change <= -0.2:
add_penalty(8, "sales_slowdown", "medium",
f"Ventas 7d bajaron {abs(change) * 100:.0f}% vs semana previa")
elif change >= 0.2:
score += 5
signals.append({"code": "sales_growth", "severity": "low",
"detail": f"Ventas 7d crecieron {change * 100:.0f}%"})
elif cur7 > 0:
signals.append({"code": "sales_new", "severity": "low",
"detail": "Ventas en los últimos 7 días sin semana previa comparable"})
# ── 5. Errors ──────────────────────────────────────────────────────────
err7 = sum((r.get("errores_5xx") or 0) for r in recent7)
components["errores_5xx_7d"] = err7
if err7 > 0:
add_penalty(min(20, 4 + err7), "api_errors", "medium",
f"{err7} errores 5xx de la API en 7 días")
# ── 6. Module usage / engagement ───────────────────────────────────────
modules = latest.get("uso_modulos") or {}
enabled = [k for k, v in modules.items()
if k.startswith("module_") and v]
components["modulos_activos"] = len(enabled)
if not enabled:
add_penalty(6, "no_modules", "low",
"Ningún módulo opcional activado")
# ── 7. Catalog ─────────────────────────────────────────────────────────
items = latest.get("items_activos") or 0
components["items_activos"] = items
if items == 0:
add_penalty(15, "empty_catalog", "high", "Inventario activo vacío")
score = max(0.0, min(100.0, score))
band, band_label = _band_for(score)
signals.sort(key=lambda s: SEVERITY_ORDER.get(s["severity"], 9))
return {
"health_score": round(score, 1),
"band": band,
"band_label": band_label,
"cold_start": False,
"signals": signals,
"components": components,
"activity_proxy": "audit_log (no se persisten logins individuales)",
}

View File

@@ -1,522 +0,0 @@
"""Portfolio operations service: per-tenant business metrics rollup, health
scores and cross-tenant anomaly detection for the manager.
Design notes
------------
* The daily rollup lives in the **master** database table
``tenant_metrics_daily`` (one row per tenant per day). It is created
idempotently here and by ``scripts/rollup_tenant_metrics.py`` — the tenant
migration runner is intentionally not involved.
* Point-in-time counts (inventory, customers, employees, db size, fitment)
are snapshots taken at collection time; daily activity metrics (sales,
new customers, audit events, API calls) are computed for the requested day.
* Data sources are guarded: optional tables (``audit_log``,
``api_request_logs``, ``inventory_vehicle_compat``) may be absent in an older
tenant and simply yield 0 / NULL instead of failing the rollup.
* DSNs are resolved from environment exactly like the manager config
(``MASTER_DB_URL`` / ``TENANT_DB_URL_TEMPLATE``) with a local fallback, so the
script can run standalone without the manager's required secrets.
"""
import os
from datetime import date as date_cls
from datetime import datetime, time, timedelta
from zoneinfo import ZoneInfo
import psycopg2
from psycopg2.extras import Json
from services.business_metrics import (
DEFAULT_Z,
MIN_COHORT,
compute_health,
detect_portfolio_anomalies,
)
BUSINESS_TZ = os.environ.get("PORTFOLIO_TZ", "America/Mexico_City")
_PGUSER = os.environ.get("PGUSER", "postgres")
MASTER_DB_URL = (
os.environ.get("MASTER_DB_URL")
or os.environ.get("DATABASE_URL")
or f"postgresql://{_PGUSER}@localhost/nexus_autoparts"
)
TENANT_DB_URL_TEMPLATE = (
os.environ.get("TENANT_DB_URL_TEMPLATE")
or f"postgresql://{_PGUSER}@localhost/{{db_name}}"
)
DDL = """
CREATE TABLE IF NOT EXISTS tenant_metrics_daily (
tenant_id INTEGER NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
date DATE NOT NULL,
ventas_count INTEGER NOT NULL DEFAULT 0,
ventas_total NUMERIC(14,2) NOT NULL DEFAULT 0,
ticket_promedio NUMERIC(12,2) NOT NULL DEFAULT 0,
ventas_canceladas INTEGER NOT NULL DEFAULT 0,
clientes_nuevos INTEGER NOT NULL DEFAULT 0,
items_activos INTEGER NOT NULL DEFAULT 0,
skus INTEGER NOT NULL DEFAULT 0,
clientes INTEGER NOT NULL DEFAULT 0,
empleados_activos INTEGER NOT NULL DEFAULT 0,
fitment_pct NUMERIC(5,2),
uso_modulos JSONB NOT NULL DEFAULT '{}'::jsonb,
ai_calls INTEGER NOT NULL DEFAULT 0,
api_calls INTEGER NOT NULL DEFAULT 0,
errores_5xx INTEGER NOT NULL DEFAULT 0,
actividad_count INTEGER NOT NULL DEFAULT 0,
ultima_venta_at TIMESTAMPTZ,
ultima_actividad_at TIMESTAMPTZ,
db_size_mb NUMERIC(12,2),
collected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (tenant_id, date)
);
CREATE INDEX IF NOT EXISTS idx_tenant_metrics_daily_date
ON tenant_metrics_daily (date);
"""
UPSERT = """
INSERT INTO tenant_metrics_daily (
tenant_id, date, ventas_count, ventas_total, ticket_promedio,
ventas_canceladas, clientes_nuevos, items_activos, skus, clientes,
empleados_activos, fitment_pct, uso_modulos, ai_calls, api_calls,
errores_5xx, actividad_count, ultima_venta_at, ultima_actividad_at,
db_size_mb, collected_at
) VALUES (
%(tenant_id)s, %(date)s, %(ventas_count)s, %(ventas_total)s,
%(ticket_promedio)s, %(ventas_canceladas)s, %(clientes_nuevos)s,
%(items_activos)s, %(skus)s, %(clientes)s, %(empleados_activos)s,
%(fitment_pct)s, %(uso_modulos)s, %(ai_calls)s, %(api_calls)s,
%(errores_5xx)s, %(actividad_count)s, %(ultima_venta_at)s,
%(ultima_actividad_at)s, %(db_size_mb)s, NOW()
)
ON CONFLICT (tenant_id, date) DO UPDATE SET
ventas_count = EXCLUDED.ventas_count,
ventas_total = EXCLUDED.ventas_total,
ticket_promedio = EXCLUDED.ticket_promedio,
ventas_canceladas = EXCLUDED.ventas_canceladas,
clientes_nuevos = EXCLUDED.clientes_nuevos,
items_activos = EXCLUDED.items_activos,
skus = EXCLUDED.skus,
clientes = EXCLUDED.clientes,
empleados_activos = EXCLUDED.empleados_activos,
fitment_pct = EXCLUDED.fitment_pct,
uso_modulos = EXCLUDED.uso_modulos,
ai_calls = EXCLUDED.ai_calls,
api_calls = EXCLUDED.api_calls,
errores_5xx = EXCLUDED.errores_5xx,
actividad_count = EXCLUDED.actividad_count,
ultima_venta_at = EXCLUDED.ultima_venta_at,
ultima_actividad_at = EXCLUDED.ultima_actividad_at,
db_size_mb = EXCLUDED.db_size_mb,
collected_at = NOW()
"""
def get_master_conn():
return psycopg2.connect(MASTER_DB_URL, connect_timeout=10)
def tenant_dsn(db_name):
return TENANT_DB_URL_TEMPLATE.format(db_name=db_name)
def ensure_metrics_table(conn=None):
"""Create ``tenant_metrics_daily`` if missing. Idempotent."""
own = conn is None
conn = conn or get_master_conn()
try:
cur = conn.cursor()
cur.execute(DDL)
conn.commit()
cur.close()
finally:
if own:
conn.close()
def list_tenants(conn=None, include_inactive=True):
own = conn is None
conn = conn or get_master_conn()
try:
cur = conn.cursor()
where = "" if include_inactive else "WHERE t.is_active = true"
cur.execute(f"""
SELECT t.id, t.name, t.db_name, t.plan, t.is_active, t.created_at,
s.expires_at
FROM tenants t
LEFT JOIN subscriptions s ON s.tenant_id = t.id
{where}
ORDER BY t.id
""")
cols = [d[0] for d in cur.description]
rows = [dict(zip(cols, r)) for r in cur.fetchall()]
cur.close()
return rows
finally:
if own:
conn.close()
# ─── Collection ────────────────────────────────────────────────────────────
def _q(cur, sql, params=None, default=None):
"""Run a scalar query, returning ``default`` if the object is missing."""
try:
cur.execute(sql, params or ())
row = cur.fetchone()
return row[0] if row and row[0] is not None else default
except Exception:
cur.connection.rollback()
return default
def _load_modules(cur):
cfg = {}
try:
cur.execute("SELECT key, value FROM tenant_config")
cfg = dict(cur.fetchall())
except Exception:
cur.connection.rollback()
def flag(key):
return (cfg.get(key) or "").lower() == "true"
return {
"module_whatsapp": flag("module_whatsapp"),
"module_marketplace": flag("module_marketplace"),
"module_meli": flag("module_meli"),
"module_catalog": flag("module_catalog"),
"whatsapp_enabled": flag("whatsapp_enabled"),
"vehicle_compat_source": cfg.get("vehicle_compat_source"),
"facturapi": bool(cfg.get("cfdi_facturapi_key")),
}
def _collect_tenant(tenant, start_dt, end_dt, dates):
"""Collect one tenant's rollup rows for ``dates`` inside [start, end)."""
conn = psycopg2.connect(tenant_dsn(tenant["db_name"]), connect_timeout=10)
cur = conn.cursor()
try:
items_activos = _q(cur, "SELECT COUNT(*) FROM inventory WHERE is_active", default=0)
skus = _q(cur, "SELECT COUNT(DISTINCT part_number) FROM inventory "
"WHERE is_active AND part_number IS NOT NULL", default=0)
clientes = _q(cur, "SELECT COUNT(*) FROM customers WHERE is_active", default=0)
empleados = _q(cur, "SELECT COUNT(*) FROM employees WHERE is_active", default=0)
db_size = _q(cur, "SELECT pg_database_size(current_database()) / 1048576.0")
ultima_venta = _q(cur, "SELECT MAX(created_at) FROM sales "
"WHERE status = 'completed'")
ultima_actividad = _q(cur, "SELECT MAX(created_at) FROM audit_log")
modules = _load_modules(cur)
icv = _q(cur, "SELECT to_regclass('public.inventory_vehicle_compat') IS NOT NULL",
default=False)
if icv:
fit = _q(cur, "SELECT COUNT(*) FROM inventory i WHERE i.is_active AND ("
"i.vehicle_compatibility IS NOT NULL OR EXISTS ("
"SELECT 1 FROM inventory_vehicle_compat c "
"WHERE c.inventory_id = i.id))", default=0)
else:
fit = _q(cur, "SELECT COUNT(*) FROM inventory WHERE is_active "
"AND vehicle_compatibility IS NOT NULL", default=0)
fitment_pct = round(100.0 * fit / items_activos, 2) if items_activos else None
# Daily sales (completed / cancelled).
sales = {}
try:
cur.execute("""
SELECT (created_at AT TIME ZONE %s)::date,
COUNT(*) FILTER (WHERE status = 'completed'),
COALESCE(SUM(total) FILTER (WHERE status = 'completed'), 0),
COUNT(*) FILTER (WHERE status = 'cancelled')
FROM sales
WHERE created_at >= %s AND created_at < %s
GROUP BY 1
""", (BUSINESS_TZ, start_dt, end_dt))
for d, vc, vt, vx in cur.fetchall():
sales[d] = (vc or 0, float(vt or 0), vx or 0)
except Exception:
conn.rollback()
nuevos = {}
try:
cur.execute("""
SELECT (created_at AT TIME ZONE %s)::date, COUNT(*)
FROM customers
WHERE created_at >= %s AND created_at < %s
GROUP BY 1
""", (BUSINESS_TZ, start_dt, end_dt))
nuevos = dict(cur.fetchall())
except Exception:
conn.rollback()
actividad = {}
try:
cur.execute("""
SELECT (created_at AT TIME ZONE %s)::date, COUNT(*)
FROM audit_log
WHERE created_at >= %s AND created_at < %s
GROUP BY 1
""", (BUSINESS_TZ, start_dt, end_dt))
actividad = dict(cur.fetchall())
except Exception:
conn.rollback()
apis = {}
has_api = _q(cur, "SELECT to_regclass('public.api_request_logs') IS NOT NULL",
default=False)
if has_api:
try:
cur.execute("""
SELECT (created_at AT TIME ZONE %s)::date,
COUNT(*),
COUNT(*) FILTER (WHERE status_code >= 500),
COUNT(*) FILTER (WHERE path ~*
'(/ai/|/chat|vision|semantic|classif|categoriz|/ml/)')
FROM api_request_logs
WHERE created_at >= %s AND created_at < %s
GROUP BY 1
""", (BUSINESS_TZ, start_dt, end_dt))
for d, calls, err5, ai in cur.fetchall():
apis[d] = (calls or 0, err5 or 0, ai or 0)
except Exception:
conn.rollback()
finally:
cur.close()
conn.close()
rows = []
for d in dates:
vc, vt, vx = sales.get(d, (0, 0.0, 0))
api_calls, err5, ai = apis.get(d, (0, 0, 0))
rows.append({
"tenant_id": tenant["id"],
"date": d,
"ventas_count": vc,
"ventas_total": round(vt, 2),
"ticket_promedio": round(vt / vc, 2) if vc else 0,
"ventas_canceladas": vx,
"clientes_nuevos": nuevos.get(d, 0),
"items_activos": items_activos or 0,
"skus": skus or 0,
"clientes": clientes or 0,
"empleados_activos": empleados or 0,
"fitment_pct": fitment_pct,
"uso_modulos": Json(modules),
"ai_calls": ai,
"api_calls": api_calls,
"errores_5xx": err5,
"actividad_count": actividad.get(d, 0),
"ultima_venta_at": ultima_venta,
"ultima_actividad_at": ultima_actividad,
"db_size_mb": round(db_size, 2) if db_size is not None else None,
})
return rows
def run_rollup(date=None, days=1, tenant_ids=None, include_inactive=True):
"""Collect and upsert rollup rows. Idempotent on (tenant_id, date).
``date`` is the last day of the range (defaults to today in BUSINESS_TZ);
``days`` extends the range backwards (1 == a single day).
"""
tz = ZoneInfo(BUSINESS_TZ)
end_date = date or datetime.now(tz).date()
if isinstance(end_date, str):
end_date = date_cls.fromisoformat(end_date)
dates = [end_date - timedelta(days=i) for i in range(days - 1, -1, -1)]
start_dt = datetime.combine(dates[0], time.min, tzinfo=tz)
end_dt = datetime.combine(dates[-1] + timedelta(days=1), time.min, tzinfo=tz)
conn = get_master_conn()
try:
ensure_metrics_table(conn)
tenants = list_tenants(conn, include_inactive=include_inactive)
if tenant_ids:
tenants = [t for t in tenants
if t["id"] in tenant_ids or t["db_name"] in tenant_ids]
written = 0
errors = []
per_tenant = []
for t in tenants:
try:
rows = _collect_tenant(t, start_dt, end_dt, dates)
except Exception as e: # noqa: BLE001 — report and continue
errors.append({"tenant_id": t["id"], "db_name": t["db_name"],
"error": str(e)})
continue
cur = conn.cursor()
try:
for row in rows:
cur.execute(UPSERT, row)
conn.commit()
written += len(rows)
per_tenant.append({
"tenant_id": t["id"], "tenant_name": t["name"],
"db_name": t["db_name"], "rows": len(rows),
})
except Exception as e: # noqa: BLE001
conn.rollback()
errors.append({"tenant_id": t["id"], "db_name": t["db_name"],
"error": str(e)})
finally:
cur.close()
finally:
conn.close()
return {
"date_from": str(dates[0]),
"date_to": str(dates[-1]),
"days": days,
"rows_written": written,
"tenants": per_tenant,
"errors": errors,
}
# ─── Readers ───────────────────────────────────────────────────────────────
METRIC_FIELDS = [
"ventas_count", "ventas_total", "ticket_promedio", "ventas_canceladas",
"clientes_nuevos", "items_activos", "skus", "clientes",
"empleados_activos", "fitment_pct", "ai_calls", "api_calls",
"errores_5xx", "actividad_count", "db_size_mb",
]
def _serialize_row(row):
out = {}
for k, v in row.items():
if isinstance(v, (datetime, date_cls)):
out[k] = v.isoformat()
elif hasattr(v, "quantize"): # Decimal
out[k] = float(v)
else:
out[k] = v
return out
def _latest_date(conn):
cur = conn.cursor()
try:
cur.execute("SELECT MAX(date) FROM tenant_metrics_daily")
row = cur.fetchone()
return row[0] if row else None
finally:
cur.close()
def get_tenant_metrics(days=14, include_inactive=True):
"""Rollup window + health score for every tenant (manager API payload)."""
days = max(1, min(int(days), 90))
conn = get_master_conn()
try:
ensure_metrics_table(conn)
latest = _latest_date(conn)
tenants = list_tenants(conn, include_inactive=include_inactive)
if latest is None:
return {
"rollup_available": False,
"date": None,
"window_days": days,
"notes": {
"hint": "Sin datos; ejecuta scripts/rollup_tenant_metrics.py",
"activity_proxy": "audit_log (no se persisten logins individuales)",
},
"tenants": [
{"tenant_id": t["id"], "name": t["name"],
"db_name": t["db_name"], "plan": t["plan"],
"is_active": t["is_active"],
"health": compute_health(t, []), "metrics": None}
for t in tenants
],
}
start = latest - timedelta(days=days - 1)
cur = conn.cursor()
cur.execute("""
SELECT tenant_id, date, ventas_count, ventas_total, ticket_promedio,
ventas_canceladas, clientes_nuevos, items_activos, skus,
clientes, empleados_activos, fitment_pct, uso_modulos,
ai_calls, api_calls, errores_5xx, actividad_count,
ultima_venta_at, ultima_actividad_at, db_size_mb
FROM tenant_metrics_daily
WHERE date >= %s AND date <= %s
ORDER BY tenant_id, date
""", (start, latest))
cols = [d[0] for d in cur.description]
by_tenant = {}
for r in cur.fetchall():
row = dict(zip(cols, r)) # raw: tz-aware datetimes for health
by_tenant.setdefault(row["tenant_id"], []).append(row)
cur.close()
out = []
for t in tenants:
history = by_tenant.get(t["id"], [])
health = compute_health(t, history)
out.append({
"tenant_id": t["id"],
"name": t["name"],
"db_name": t["db_name"],
"plan": t["plan"],
"is_active": t["is_active"],
"health": health,
"metrics": _serialize_row(history[-1]) if history else None,
})
finally:
conn.close()
return {
"rollup_available": True,
"date": str(latest),
"window_days": days,
"notes": {
"activity_proxy": "audit_log (no se persisten logins individuales)",
"point_in_time": "items/skus/clientes/empleados/fitment son snapshot al momento del rollup",
},
"tenants": out,
}
def get_portfolio_anomalies(date=None, z_threshold=DEFAULT_Z, min_tenants=MIN_COHORT,
include_inactive=False):
"""Cross-tenant anomaly report built from the daily rollup."""
conn = get_master_conn()
try:
ensure_metrics_table(conn)
target = date or _latest_date(conn)
if target is None:
return detect_portfolio_anomalies([], date=None,
min_tenants=min_tenants,
z_threshold=z_threshold)
where_active = "" if include_inactive else "AND t.is_active = true"
cur = conn.cursor()
cur.execute(f"""
SELECT m.tenant_id, t.name, m.date,
m.ventas_total, m.ventas_count, m.ticket_promedio,
m.clientes_nuevos, m.actividad_count, m.errores_5xx,
m.items_activos, m.skus, m.clientes, m.fitment_pct
FROM tenant_metrics_daily m
JOIN tenants t ON t.id = m.tenant_id
WHERE m.date = %s {where_active}
ORDER BY m.tenant_id
""", (target,))
cols = [d[0] for d in cur.description]
rows = []
for r in cur.fetchall():
row = _serialize_row(dict(zip(cols, r)))
row["tenant_name"] = row.pop("name")
rows.append(row)
cur.close()
finally:
conn.close()
report = detect_portfolio_anomalies(rows, date=target,
min_tenants=min_tenants,
z_threshold=z_threshold)
report["tenants_scanned"] = len(rows)
return report

View File

@@ -193,59 +193,40 @@ def reset_tenant(tenant_id, keep_config=True):
db_name = tenant["db_name"] db_name = tenant["db_name"]
dsn = TENANT_DB_URL_TEMPLATE.format(db_name=db_name) dsn = TENANT_DB_URL_TEMPLATE.format(db_name=db_name)
# Whitelist de tablas de NEGOCIO/PII a limpiar. Antes faltaban customers, tables_to_truncate = [
# whatsapp/wa_*, audit_log, layaways, returns, marketplace_*, consignaciones "inventory_operations",
# → el "reset" dejaba datos personales e historial de conversaciones. "inventory",
candidate_tables = [ "sale_items",
# Inventario "sales",
"inventory_operations", "inventory_stock", "inventory_stock_summary", "customer_payments",
"inventory_images", "inventory_sku_aliases", "inventory_vehicle_compat", "cash_register_closings",
"inventory_transfer_lines", "inventory_transfers", "physical_count_lines", "cash_register_movements",
"physical_counts", "reorder_alerts", "inventory", "inventory_uploads", "cash_registers",
# Ventas / caja "invoices",
"sale_payments", "sale_items", "sales", "customer_payments", "accounting_entries",
"cash_movements", "cash_register_movements", "cash_registers", "journal_entries",
"layaway_payments", "layaway_items", "layaways", "service_orders",
# Devoluciones / garantías "fleet_vehicles",
"return_items", "returns", "warranty_claims", "warranties", "crm_activities",
# Cotizaciones / compras "quotations",
"quotation_items", "quotations", "quotation_items",
"purchase_order_items", "purchase_orders", "savings_transactions",
# Taller / flotilla "savings_accounts",
"service_order_items", "service_order_labor", "service_orders", "supplier_orders",
"fleet_maintenance_logs", "fleet_vehicles", "supplier_order_items",
# CRM / clientes (PII) "warranty_claims",
"customer_activities", "crm_activities", "loyalty_transactions", "notifications",
"loyalty_points", "customers", "inventory_uploads",
# Marketplace / MeLi
"marketplace_order_items", "marketplace_orders", "marketplace_listings",
"marketplace_questions", "meli_sync_queue",
# WhatsApp (PII)
"whatsapp_messages", "whatsapp_sessions", "wa_customer_links",
"wa_learning_sessions",
# Contabilidad / fiscal
"cfdi_queue", "invoices", "journal_lines", "journal_entries",
# Auditoría / sesiones
"audit_log", "employee_sessions", "notifications",
# Consignación (Yonke)
"consignment_items", "consignments",
] ]
conn = psycopg2.connect(dsn) conn = psycopg2.connect(dsn)
cur = conn.cursor() cur = conn.cursor()
try: try:
# Solo se truncan las que existan en este tenant (esquema puede variar). for table in tables_to_truncate:
cur.execute("SELECT tablename FROM pg_tables WHERE schemaname = 'public'")
existing = {r[0] for r in cur.fetchall()}
truncated = 0
for table in candidate_tables:
if table not in existing:
continue
try: try:
cur.execute(f'TRUNCATE TABLE public."{table}" RESTART IDENTITY CASCADE') cur.execute(f"TRUNCATE TABLE {table} RESTART IDENTITY CASCADE")
truncated += 1
except Exception: except Exception:
conn.rollback() pass # Table may not exist
conn.commit() conn.commit()
success = True success = True
except Exception as e: except Exception as e:
@@ -256,7 +237,7 @@ def reset_tenant(tenant_id, keep_config=True):
cur.close() cur.close()
conn.close() conn.close()
return {"success": success, "tenant_id": tenant_id, "tables_reset": truncated} return {"success": success, "tenant_id": tenant_id, "tables_reset": len(tables_to_truncate)}
def delete_tenant(tenant_id): def delete_tenant(tenant_id):

View File

@@ -10,7 +10,6 @@ const routes = {
"#dashboard": "dashboard", "#dashboard": "dashboard",
"#demos": "demos", "#demos": "demos",
"#tenants": "tenants", "#tenants": "tenants",
"#portfolio": "portfolio",
"#health": "health", "#health": "health",
"#migrations": "migrations" "#migrations": "migrations"
}; };
@@ -30,7 +29,6 @@ function navigate() {
dashboard: "Dashboard", dashboard: "Dashboard",
demos: "Crear Demos", demos: "Crear Demos",
tenants: "Tenants", tenants: "Tenants",
portfolio: "Cartera y Salud",
health: "Salud del Sistema", health: "Salud del Sistema",
migrations: "Migraciones" migrations: "Migraciones"
}; };
@@ -40,7 +38,6 @@ function navigate() {
if (page === "dashboard") loadDashboard(); if (page === "dashboard") loadDashboard();
if (page === "demos") loadDemos(); if (page === "demos") loadDemos();
if (page === "tenants") loadTenants(); if (page === "tenants") loadTenants();
if (page === "portfolio") loadPortfolio();
if (page === "health") loadHealth(); if (page === "health") loadHealth();
if (page === "migrations") loadMigrations(); if (page === "migrations") loadMigrations();
} }
@@ -274,77 +271,6 @@ document.getElementById("tenant-search")?.addEventListener("input", (e) => {
}); });
}); });
// ─── Portfolio (business metrics + health + anomalies) ─────────────────────
const BAND_TAG = {
healthy: "success",
stable: "info",
at_risk: "warning",
critical: "danger",
unknown: "default"
};
async function loadPortfolio() {
const tbody = document.getElementById("portfolio-table");
const aBody = document.getElementById("anomalies-table");
const metricsRes = await api("/api/tenants/metrics?days=14");
if (!metricsRes || metricsRes.status !== 200) {
tbody.innerHTML = `<tr><td colspan="8" class="text-muted text-center">Error al cargar métricas</td></tr>`;
return;
}
const m = metricsRes.data;
document.getElementById("portfolio-meta").textContent = m.rollup_available
? `Último día: ${m.date} · ventana ${m.window_days}d`
: "Sin rollup";
if (!m.rollup_available) {
tbody.innerHTML = `<tr><td colspan="8" class="text-muted text-center">Sin datos de rollup. Ejecuta <code>scripts/rollup_tenant_metrics.py</code></td></tr>`;
} else {
tbody.innerHTML = m.tenants.map(t => {
const h = t.health || {};
const x = t.metrics || {};
const score = h.health_score == null ? "—" : h.health_score;
const signals = (h.signals || []).map(s => s.code).join(", ") || "—";
const fitment = x.fitment_pct == null ? "—" : `${x.fitment_pct}%`;
return `<tr>
<td><strong>${escapeHtml(t.name)}</strong><br><span class="text-muted" style="font-size:11px">${escapeHtml(t.db_name)}</span></td>
<td>${tag(`${score} · ${h.band_label || h.band}`, BAND_TAG[h.band] || "default")}</td>
<td>${x.ventas_count ?? 0} / $${(x.ventas_total ?? 0).toLocaleString("es-MX")}</td>
<td>$${(x.ticket_promedio ?? 0).toLocaleString("es-MX")}</td>
<td>${(x.items_activos ?? 0).toLocaleString("es-MX")}</td>
<td>${(x.clientes ?? 0).toLocaleString("es-MX")}</td>
<td>${fitment}</td>
<td style="font-size:11px" class="text-muted">${escapeHtml(signals)}</td>
</tr>`;
}).join("");
}
const anomRes = await api("/api/portfolio/anomalies");
if (!anomRes || anomRes.status !== 200) {
aBody.innerHTML = `<tr><td colspan="6" class="text-muted text-center">Error al cargar anomalías</td></tr>`;
return;
}
const a = anomRes.data;
document.getElementById("anomalies-note").textContent =
`Día ${a.date || "—"} · cohorte ${a.cohort_size} tenants · z≥${a.thresholds?.z_threshold} (${a.thresholds?.method})` +
(a.cold_start ? " · COLD START: cohorte insuficiente" : "");
if (!a.anomalies || !a.anomalies.length) {
aBody.innerHTML = `<tr><td colspan="6" class="text-muted text-center">Sin anomalías entre tenants</td></tr>`;
return;
}
aBody.innerHTML = a.anomalies.map(x => `
<tr>
<td>${escapeHtml(x.tenant_name)}</td>
<td>${escapeHtml(x.label)}</td>
<td>${(x.value ?? 0).toLocaleString("es-MX")}</td>
<td>${(x.cohort_median ?? 0).toLocaleString("es-MX")}</td>
<td>${x.z}</td>
<td>${tag(x.direction === "peak" ? "Pico inusual" : "Caída inusual", x.direction === "peak" ? "info" : "danger")}</td>
</tr>
`).join("");
}
// ─── Health ──────────────────────────────────────────────────────────────── // ─── Health ────────────────────────────────────────────────────────────────
async function loadHealth() { async function loadHealth() {
const res = await api("/api/health"); const res = await api("/api/health");

View File

@@ -56,10 +56,6 @@
<span>Tenants</span> <span>Tenants</span>
<span class="badge" id="tenant-count">0</span> <span class="badge" id="tenant-count">0</span>
</a> </a>
<a href="#/portfolio" class="nav-item" data-page="portfolio">
<i class="fas fa-briefcase"></i>
<span>Cartera</span>
</a>
<a href="#/health" class="nav-item" data-page="health"> <a href="#/health" class="nav-item" data-page="health">
<i class="fas fa-heartbeat"></i> <i class="fas fa-heartbeat"></i>
<span>Salud</span> <span>Salud</span>
@@ -244,63 +240,6 @@
</div> </div>
</section> </section>
<!-- Portfolio Page -->
<section id="page-portfolio" class="page" style="display:none;">
<div class="card">
<div class="card-header">
<h3><i class="fas fa-briefcase"></i> Salud de Cartera</h3>
<div class="card-actions">
<span id="portfolio-meta" class="text-muted" style="font-size:12px;"></span>
<button class="btn btn-sm btn-secondary" onclick="loadPortfolio()">
<i class="fas fa-sync"></i> Refrescar
</button>
</div>
</div>
<div class="card-body">
<table class="table">
<thead>
<tr>
<th>Tenant</th>
<th>Salud</th>
<th>Ventas (día)</th>
<th>Ticket prom.</th>
<th>Items</th>
<th>Clientes</th>
<th>Fitment</th>
<th>Señales</th>
</tr>
</thead>
<tbody id="portfolio-table">
<tr><td colspan="8" class="text-muted">Cargando...</td></tr>
</tbody>
</table>
</div>
</div>
<div class="card">
<div class="card-header">
<h3><i class="fas fa-exclamation-triangle"></i> Anomalías entre Tenants</h3>
</div>
<div class="card-body">
<div id="anomalies-note" class="text-muted" style="font-size:12px;margin-bottom:8px;"></div>
<table class="table">
<thead>
<tr>
<th>Tenant</th>
<th>Métrica</th>
<th>Valor</th>
<th>Mediana cohorte</th>
<th>z</th>
<th>Dirección</th>
</tr>
</thead>
<tbody id="anomalies-table">
<tr><td colspan="6" class="text-muted">Cargando...</td></tr>
</tbody>
</table>
</div>
</div>
</section>
<!-- Health Page --> <!-- Health Page -->
<section id="page-health" class="page" style="display:none;"> <section id="page-health" class="page" style="display:none;">
<div class="grid-3"> <div class="grid-3">

View File

@@ -74,7 +74,7 @@ server {
} }
location = / { location = / {
return 302 /pos/login2; return 302 /pos/login;
} }
location / { location / {

View File

@@ -1,201 +1,5 @@
import logging from flask import Flask
import re
import time
from flask import Flask, request, redirect, make_response, g
from json_provider import OrjsonProvider from json_provider import OrjsonProvider
from services.prom_metrics import CONTENT_TYPE as PROM_CONTENT_TYPE, metrics as prom_metrics
from services.prom_metrics import normalize_path as prom_normalize_path
_alert_log = logging.getLogger('nexus.pos.alerts')
# `+` opcional seguido de 10-15 dígitos (formato internacional).
_ALERT_PHONE_RE = re.compile(r'^\+?\d{10,15}$')
def _extract_payload_tenant_ref(data, alerts):
"""Extrae el tenant referenciado por un payload de Alertmanager.
Busca `labels.tenant_id` (o `labels.tenant`) en el payload, en sus
`commonLabels` y en las etiquetas de cada alerta. Devuelve el primer valor
no vacío o None.
"""
label_dicts = []
for candidate in (data.get('labels'), data.get('commonLabels')):
if isinstance(candidate, dict):
label_dicts.append(candidate)
for alert in alerts or []:
if isinstance(alert, dict) and isinstance(alert.get('labels'), dict):
label_dicts.append(alert['labels'])
for labels in label_dicts:
for key in ('tenant_id', 'tenant'):
value = labels.get(key)
if value not in (None, '', []):
return value
return None
def _resolve_tenant_dbname(tenant_ref):
"""Resuelve una referencia de tenant a su `db_name`.
Acepta id numérico, db_name, subdominio o nombre exacto. Devuelve None si
no existe o si falla la consulta.
"""
from tenant_db import get_master_conn
ref = str(tenant_ref).strip()
if not ref:
return None
try:
conn = get_master_conn()
try:
cur = conn.cursor()
if ref.isdigit():
cur.execute(
"SELECT db_name FROM tenants WHERE id = %s AND is_active = true",
(int(ref),),
)
else:
cur.execute(
"""SELECT db_name FROM tenants
WHERE is_active = true
AND (db_name = %s OR LOWER(subdomain) = %s OR LOWER(name) = %s)
LIMIT 1""",
(ref, ref.lower(), ref.lower()),
)
row = cur.fetchone()
cur.close()
finally:
conn.close()
return row[0] if row and row[0] else None
except Exception as exc:
_alert_log.warning("alert routing: no se pudo resolver tenant %r: %s", tenant_ref, exc)
return None
def _get_tenant_alert_config(db_name):
"""Lee los contactos de alerta del tenant `db_name`.
Devuelve (phones, enabled). Ante cualquier fallo devuelve ([], False) para
degradar sin error.
"""
from tenant_db import get_tenant_conn_by_dbname
phones, enabled = [], False
try:
conn = get_tenant_conn_by_dbname(db_name)
try:
cur = conn.cursor()
cur.execute(
"SELECT key, value FROM tenant_config "
"WHERE key IN ('alert_phones', 'alert_whatsapp_enabled')"
)
rows = {r[0]: r[1] for r in cur.fetchall()}
cur.close()
finally:
conn.close()
phones = [p.strip() for p in str(rows.get('alert_phones') or '').replace('"', '').split(',') if p.strip()]
enabled = str(rows.get('alert_whatsapp_enabled', 'false')).lower() == 'true'
except Exception as exc:
_alert_log.warning("alert routing: no se pudo leer config de %s: %s", db_name, exc)
return phones, enabled
def _global_alert_phones():
"""Fallback de compatibilidad: `ALERT_PHONES` de /etc/nexus/alert.env."""
try:
with open('/etc/nexus/alert.env') as fh:
for line in fh:
if line.startswith('ALERT_PHONES='):
raw = line.split('=', 1)[1].strip().strip('"')
return [p.strip() for p in raw.split(',') if p.strip()]
except Exception:
pass
return []
def _send_via_bridge(url, phones, msg):
"""Envía `msg` a `phones` por el bridge de WhatsApp de `url`.
Devuelve True si al menos un envío se completó. Best effort: no propaga
excepciones.
"""
import requests
if not url:
return False
try:
status = requests.get(url + '/status', timeout=4).json()
if status.get('state') != 'connected':
return False
sent = False
for phone in phones:
try:
requests.post(url + '/send', json={'phone': phone, 'message': msg}, timeout=10)
sent = True
except Exception as exc:
_alert_log.warning("alert routing: fallo enviando a %s: %s", phone, exc)
return sent
except Exception as exc:
_alert_log.warning("alert routing: bridge %s no disponible: %s", url, exc)
return False
def _send_tenant_alert(db_name, phones, msg):
"""Envía la alerta por el bridge configurado en ESE tenant."""
from tenant_db import get_tenant_conn_by_dbname
try:
conn = get_tenant_conn_by_dbname(db_name)
try:
cur = conn.cursor()
cur.execute("SELECT value FROM tenant_config WHERE key = 'whatsapp_bridge_url'")
row = cur.fetchone()
cur.close()
finally:
conn.close()
return _send_via_bridge(row[0] if row and row[0] else '', phones, msg)
except Exception as exc:
_alert_log.warning("alert routing: bridge del tenant %s no disponible: %s", db_name, exc)
return False
def _send_global_alert(phones, msg):
"""Fallback global: envía por el primer bridge de tenant conectado."""
from tenant_db import get_master_conn, get_tenant_conn_by_dbname
try:
conn = get_master_conn()
try:
cur = conn.cursor()
cur.execute("SELECT db_name FROM tenants WHERE is_active = true")
dbs = [r[0] for r in cur.fetchall()]
cur.close()
finally:
conn.close()
for db in dbs:
try:
tconn = get_tenant_conn_by_dbname(db)
try:
tcur = tconn.cursor()
tcur.execute("SELECT value FROM tenant_config WHERE key = 'whatsapp_bridge_url'")
row = tcur.fetchone()
tcur.close()
finally:
tconn.close()
if not row or not row[0]:
continue
if _send_via_bridge(row[0], phones, msg):
return True
except Exception:
continue
return False
except Exception as exc:
_alert_log.warning("alert routing: fallback global sin bridge: %s", exc)
return False
def create_app(): def create_app():
@@ -206,200 +10,17 @@ def create_app():
from middleware_tenant import resolve_tenant from middleware_tenant import resolve_tenant
app.before_request(resolve_tenant) app.before_request(resolve_tenant)
# ─── Prometheus metrics ────────────────────────────────────────
# Se registran DESPUÉS de resolve_tenant, así que corren después de él.
prom_metrics.configure('pos')
@app.before_request
def _metrics_request_start():
if request.path != '/metrics':
g._metrics_t0 = time.perf_counter()
prom_metrics.request_started()
@app.after_request
def _metrics_request_end(response):
t0 = getattr(g, '_metrics_t0', None)
if t0 is not None:
g._metrics_done = True
rule = getattr(request.url_rule, 'rule', None)
prom_metrics.request_finished(
request.method,
prom_normalize_path(rule, request.path),
response.status_code,
time.perf_counter() - t0,
)
return response
@app.teardown_request
def _metrics_request_teardown(exc):
if exc is not None:
prom_metrics.record_exception()
if getattr(g, '_metrics_t0', None) is not None and not getattr(g, '_metrics_done', False):
prom_metrics.request_aborted()
# NOTE: Page-level routing guards are handled client-side by app-init.js
# using the employee's current permissions; API endpoints enforce their own
# permission checks via @require_auth.
# ─── PWA: Service Worker must be served from /pos/ scope ────── # ─── PWA: Service Worker must be served from /pos/ scope ──────
@app.route('/pos/api/notifications/webhook', methods=['POST'])
def notifications_webhook():
"""Recibe alertas de Alertmanager y las reenvía por WhatsApp (best effort).
El destino es POR NEGOCIO: cada tenant configura sus propios números en
su `tenant_config` (`alert_phones` + `alert_whatsapp_enabled`),
editables desde `PUT /pos/api/config/alert-contacts`. Resolución:
(a) si el payload trae `labels.tenant_id` (o `labels.tenant`) → se usan
los contactos de ESE tenant y se envía por su bridge de WhatsApp;
(b) si no, fallback de compatibilidad al `ALERT_PHONES` global de
/etc/nexus/alert.env (best effort por el primer bridge conectado);
(c) si no hay contactos → no-op con `sent:false` (nunca error).
Si un negocio no configura contactos, sus alertas no se envían: no hay
ningún teléfono global obligatorio. Autenticación por `INTERNAL_API_KEY`
(Bearer o X-Internal-Key). Tolera fallos: nunca responde 500.
"""
# Endpoint abierto solo con el secreto interno (Alertmanager lo envía como Bearer).
from config import INTERNAL_API_KEY as _internal_key
if _internal_key:
_tok = request.headers.get('X-Internal-Key', '')
_auth = request.headers.get('Authorization', '')
if _tok != _internal_key and _auth != f'Bearer {_internal_key}':
return jsonify({'error': 'unauthorized'}), 401
data = request.get_json(silent=True) or {}
alerts = data.get('alerts') or []
status = data.get('status', 'firing')
lines = []
for a in alerts[:5]:
ann = a.get('annotations', {})
summary = ann.get('summary') or a.get('labels', {}).get('alertname', 'alerta')
desc = ann.get('description', '')
lines.append(f"• {summary}" + (f" — {desc}" if desc else ''))
if not lines:
return jsonify({'ok': True, 'sent': False, 'reason': 'sin alertas'})
try:
# (a) Destino por negocio si el payload identifica un tenant.
tenant_ref = _extract_payload_tenant_ref(data, alerts)
tenant_db, phones = None, []
if tenant_ref is not None:
tenant_db = _resolve_tenant_dbname(tenant_ref)
if tenant_db:
phones, enabled = _get_tenant_alert_config(tenant_db)
if phones and not enabled:
return jsonify({'ok': True, 'sent': False, 'scope': 'tenant',
'reason': 'alertas WhatsApp deshabilitadas para el negocio'})
else:
# (b) Fallback de compatibilidad al teléfono global.
phones = _global_alert_phones()
if not phones:
return jsonify({'ok': True, 'sent': False, 'reason': 'sin contactos configurados'})
msg = ('🚨 *Nexus Alert* [' + status + ']\n' + '\n'.join(lines))
if tenant_db:
sent = _send_tenant_alert(tenant_db, phones, msg)
scope, reason = 'tenant', 'bridge del negocio no conectado'
else:
sent = _send_global_alert(phones, msg)
scope, reason = 'global', 'sin bridge conectado'
if sent:
return jsonify({'ok': True, 'sent': True, 'scope': scope})
return jsonify({'ok': True, 'sent': False, 'scope': scope, 'reason': reason})
except Exception as exc:
_alert_log.exception("alert routing: fallo inesperado: %s", exc)
return jsonify({'ok': True, 'sent': False, 'reason': 'error interno'})
@app.route('/pos/api/public/media/<path:filename>')
def public_media(filename):
"""Sirve media temporal (imágenes/PDF) para enviar vía WA CRM."""
from flask import send_from_directory, abort
import re as _re
if not _re.fullmatch(r'[0-9a-f]{32}(_[A-Za-z0-9._-]{1,80})?\.(png|jpg|jpeg|pdf)', filename):
abort(404)
return send_from_directory('/tmp/wa_media', filename, max_age=3600)
@app.route('/pos/sw-push.js')
def pos_sw_push():
from flask import send_from_directory, make_response
response = make_response(send_from_directory('static', 'sw-push.js',
mimetype='application/javascript'))
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
return response
@app.route('/pos/sw.js') @app.route('/pos/sw.js')
def pos_sw(): def pos_sw():
from flask import send_from_directory, make_response from flask import send_from_directory
response = make_response(send_from_directory('static/pwa', 'sw.js', return send_from_directory('static/pwa', 'sw.js',
mimetype='application/javascript')) mimetype='application/javascript')
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
response.headers['Pragma'] = 'no-cache'
response.headers['Expires'] = '0'
return response
# ─── Manejo global de tablas retiradas (esquema TecDoc, junio 2026) ───
# Los endpoints que aún consultan el catálogo OEM antiguo devolvían 500 con
# SQL crudo. Aquí se degradan a una respuesta limpia y vacía.
from werkzeug.exceptions import HTTPException
@app.errorhandler(Exception)
def _handle_retired_schema(e):
if isinstance(e, HTTPException):
return e
try:
from psycopg2.errors import UndefinedTable, UndefinedColumn
if isinstance(e, (UndefinedTable, UndefinedColumn)):
name = getattr(e, 'diag', None)
table = getattr(name, 'table_name', '') if name else ''
return jsonify({
'data': [],
'oem_disabled': True,
'error': 'Función no disponible en este despliegue',
'detail': f'La tabla "{table}" fue retirada del catálogo maestro.'
if table else 'Consulta sobre esquema retirado.',
}), 200
except Exception:
pass
raise
# Register blueprints # Register blueprints
from blueprints.auth_bp import auth_bp from blueprints.auth_bp import auth_bp
app.register_blueprint(auth_bp) app.register_blueprint(auth_bp)
# Storefront público por tenant (vitrina web + pedidos)
from blueprints.storefront_bp import storefront_bp
app.register_blueprint(storefront_bp)
@app.route('/tienda')
def public_storefront():
from flask import g, render_template
business_name = 'Refaccionaria'
tid = getattr(g, 'tenant_id', None)
if tid:
try:
from tenant_db import get_master_conn
mconn = get_master_conn()
mcur = mconn.cursor()
mcur.execute("SELECT name FROM tenants WHERE id = %s", (tid,))
row = mcur.fetchone()
mcur.close()
mconn.close()
if row:
business_name = row[0]
except Exception:
pass
return render_template('storefront.html', business_name=business_name)
@app.route('/')
def root_redirect():
# En subdominios de tenants la raíz es la tienda pública;
# en el dominio principal manda al POS.
from flask import g
if getattr(g, 'tenant_id', None):
return redirect('/tienda')
return redirect('/pos/login')
from blueprints.config_bp import config_bp from blueprints.config_bp import config_bp
app.register_blueprint(config_bp) app.register_blueprint(config_bp)
@@ -499,25 +120,11 @@ def create_app():
from blueprints.internal_bp import internal_bp from blueprints.internal_bp import internal_bp
app.register_blueprint(internal_bp) app.register_blueprint(internal_bp)
from blueprints.yonke_bp import yonke_bp
app.register_blueprint(yonke_bp)
from blueprints.b2b_bp import b2b_bp
app.register_blueprint(b2b_bp)
# Health check # Health check
@app.route('/pos/health') @app.route('/pos/health')
def health(): def health():
return {'status': 'ok'} return {'status': 'ok'}
# Prometheus scrape endpoint (no pasa por el contador: se excluye por path)
@app.route('/metrics')
def prometheus_metrics():
response = make_response(prom_metrics.render())
response.headers['Content-Type'] = PROM_CONTENT_TYPE
response.headers['Cache-Control'] = 'no-store'
return response
from flask import g, jsonify, render_template, send_from_directory from flask import g, jsonify, render_template, send_from_directory
@app.route('/favicon.ico') @app.route('/favicon.ico')
@@ -525,23 +132,11 @@ def create_app():
return send_from_directory('static/pwa', 'icon-192.png', mimetype='image/png') return send_from_directory('static/pwa', 'icon-192.png', mimetype='image/png')
@app.route('/pos/login') @app.route('/pos/login')
def pos_login_legacy():
# Redirect to the new login path to bypass any stale browser/SW cache
# of the old /pos/login page that did not send employee_id.
response = make_response(redirect('/pos/login2'))
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
return response
@app.route('/pos/login2')
def pos_login(): def pos_login():
response = make_response(render_template('login.html', return render_template('login.html',
tenant_id=getattr(g, 'tenant_id', None), tenant_id=getattr(g, 'tenant_id', None),
tenant_name=getattr(g, 'tenant_name', None), tenant_name=getattr(g, 'tenant_name', None),
tenant_subdomain=getattr(g, 'tenant_subdomain', None))) tenant_subdomain=getattr(g, 'tenant_subdomain', None))
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
response.headers['Pragma'] = 'no-cache'
response.headers['Expires'] = '0'
return response
@app.route('/pos/supplier-catalog') @app.route('/pos/supplier-catalog')
def supplier_catalog_page(): def supplier_catalog_page():
@@ -573,11 +168,7 @@ def create_app():
@app.route('/pos/dashboard') @app.route('/pos/dashboard')
def pos_dashboard(): def pos_dashboard():
response = make_response(render_template('dashboard.html')) return render_template('dashboard.html')
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
response.headers['Pragma'] = 'no-cache'
response.headers['Expires'] = '0'
return response
@app.route('/pos/config') @app.route('/pos/config')
def pos_config(): def pos_config():
@@ -619,18 +210,6 @@ def create_app():
def pos_historical_sales(): def pos_historical_sales():
return render_template('historical_sales.html') return render_template('historical_sales.html')
@app.route('/pos/remission-notes')
def pos_remission_notes():
response = make_response(render_template('remission_notes.html'))
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
response.headers['Pragma'] = 'no-cache'
response.headers['Expires'] = '0'
return response
@app.route('/pos/yonke')
def pos_yonke():
return render_template('yonke.html')
@app.route('/pos/static/<path:filename>') @app.route('/pos/static/<path:filename>')
def pos_static(filename): def pos_static(filename):
return send_from_directory('static', filename) return send_from_directory('static', filename)
@@ -686,7 +265,7 @@ def create_app():
cur.execute(""" cur.execute("""
SELECT i.part_number, i.name, i.brand, i.price_1, i.tax_rate, SELECT i.part_number, i.name, i.brand, i.price_1, i.tax_rate,
i.category_id, COALESCE(s.stock, 0) AS stock, i.category, COALESCE(s.stock, 0) AS stock,
COALESCE(sv.total_sold, 0) AS total_sold COALESCE(sv.total_sold, 0) AS total_sold
FROM inventory i FROM inventory i
LEFT JOIN ( LEFT JOIN (

View File

@@ -7,62 +7,16 @@ NUMERIC(14,2) in the database.
""" """
import json import json
import csv from datetime import date, datetime
import io from flask import Blueprint, request, jsonify, g
from datetime import date, datetime, timedelta
from flask import Blueprint, request, jsonify, g, Response
from middleware import require_auth from middleware import require_auth
from tenant_db import get_tenant_conn from tenant_db import get_tenant_conn
from services.accounting_engine import create_manual_entry, record_supplier_payment_entry from services.accounting_engine import create_manual_entry
from services.audit import log_action from services.audit import log_action
accounting_bp = Blueprint('accounting', __name__, url_prefix='/pos/api/accounting') accounting_bp = Blueprint('accounting', __name__, url_prefix='/pos/api/accounting')
def _csv_response(rows, header, filename):
"""Build a UTF-8 CSV Response with BOM (Excel-friendly)."""
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(header)
writer.writerows(rows)
csv_data = output.getvalue()
output.close()
return Response(
'\ufeff' + csv_data,
mimetype='text/csv; charset=utf-8',
headers={'Content-Disposition': f'attachment; filename={filename}'}
)
def _period_bounds(year, month):
"""Return (period_start, period_end) ISO dates for a fiscal month."""
period_start = f'{year}-{month:02d}-01'
if month == 12:
period_end = f'{year + 1}-01-01'
else:
period_end = f'{year}-{month + 1:02d}-01'
return period_start, period_end
def _supplier_paid_totals(cur):
"""Return {po_id: paid_amount} from supplier_payments.
The table is created on first supplier payment, so tenants that never paid
a supplier simply have no data (and no table) yet.
"""
cur.execute("SELECT to_regclass('public.supplier_payments')")
if not cur.fetchone()[0]:
return {}
cur.execute("""
SELECT po_id, COALESCE(SUM(amount), 0)
FROM supplier_payments
WHERE po_id IS NOT NULL
GROUP BY po_id
""")
return {r[0]: float(r[1]) for r in cur.fetchall()}
# ─── Chart of Accounts ───────────────────────────── # ─── Chart of Accounts ─────────────────────────────
@accounting_bp.route('/accounts', methods=['GET']) @accounting_bp.route('/accounts', methods=['GET'])
@@ -353,11 +307,29 @@ def create_entry():
# ─── Financial Reports ────────────────────────────── # ─── Financial Reports ──────────────────────────────
def _trial_balance_rows(conn, year, month): @accounting_bp.route('/trial-balance', methods=['GET'])
"""Balanza de comprobacion rows for a fiscal month.""" @require_auth('accounting.view')
def trial_balance():
"""Balanza de comprobacion for a period.
Query params:
year: int (default current year)
month: int (default current month)
Returns each account with: saldo_inicial, cargos, abonos, saldo_final
"""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor() cur = conn.cursor()
period_start, period_end = _period_bounds(year, month) year = int(request.args.get('year', date.today().year))
month = int(request.args.get('month', date.today().month))
# Period start/end dates
period_start = f'{year}-{month:02d}-01'
if month == 12:
period_end = f'{year + 1}-01-01'
else:
period_end = f'{year}-{month + 1:02d}-01'
cur.execute(""" cur.execute("""
WITH initial_balances AS ( WITH initial_balances AS (
@@ -410,62 +382,31 @@ def _trial_balance_rows(conn, year, month):
}) })
cur.close() cur.close()
return rows
@accounting_bp.route('/trial-balance', methods=['GET'])
@require_auth('accounting.view')
def trial_balance():
"""Balanza de comprobacion for a period.
Query params:
year: int (default current year)
month: int (default current month)
Returns each account with: saldo_inicial, cargos, abonos, saldo_final
"""
conn = get_tenant_conn(g.tenant_id)
year = int(request.args.get('year', date.today().year))
month = int(request.args.get('month', date.today().month))
rows = _trial_balance_rows(conn, year, month)
conn.close() conn.close()
return jsonify({'data': rows, 'period': {'year': year, 'month': month}}) return jsonify({'data': rows, 'period': {'year': year, 'month': month}})
@accounting_bp.route('/trial-balance/export', methods=['GET']) @accounting_bp.route('/income-statement', methods=['GET'])
@require_auth('accounting.view') @require_auth('accounting.view')
def export_trial_balance(): def income_statement():
"""Export the balanza de comprobacion as CSV (default) or JSON. """Estado de resultados for a period.
Query params: year, month, format=csv|json Query params: year, month
Groups: Ingresos (400), Costos (500), Gastos (600)
Result: Ingresos - Costos - Gastos = Utilidad/Perdida
""" """
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
year = int(request.args.get('year', date.today().year))
month = int(request.args.get('month', date.today().month))
fmt = request.args.get('format', 'csv').lower()
rows = _trial_balance_rows(conn, year, month)
conn.close()
if fmt == 'json':
return jsonify({'data': rows, 'period': {'year': year, 'month': month}})
return _csv_response(
[[r['code'], r['name'], r['type'], r['saldo_inicial'], r['cargos'],
r['abonos'], r['saldo_final']] for r in rows],
['Cuenta', 'Nombre', 'Tipo', 'Saldo inicial', 'Cargos', 'Abonos', 'Saldo final'],
f'balanza_{year}-{month:02d}.csv'
)
def _income_statement_data(conn, year, month):
"""Estado de resultados grouped by section for a fiscal month."""
cur = conn.cursor() cur = conn.cursor()
period_start, period_end = _period_bounds(year, month) year = int(request.args.get('year', date.today().year))
month = int(request.args.get('month', date.today().month))
period_start = f'{year}-{month:02d}-01'
if month == 12:
period_end = f'{year + 1}-01-01'
else:
period_end = f'{year}-{month + 1:02d}-01'
cur.execute(""" cur.execute("""
SELECT a.id, a.code, a.name, a.type, SELECT a.id, a.code, a.name, a.type,
@@ -513,216 +454,17 @@ def _income_statement_data(conn, year, month):
utilidad_neta = round(utilidad_bruta - total_gastos, 2) utilidad_neta = round(utilidad_bruta - total_gastos, 2)
cur.close() cur.close()
conn.close()
return { return jsonify({
'period': {'year': year, 'month': month}, 'period': {'year': year, 'month': month},
'ingresos': {'items': ingresos, 'total': round(total_ingresos, 2)}, 'ingresos': {'items': ingresos, 'total': round(total_ingresos, 2)},
'costos': {'items': costos, 'total': round(total_costos, 2)}, 'costos': {'items': costos, 'total': round(total_costos, 2)},
'utilidad_bruta': utilidad_bruta, 'utilidad_bruta': utilidad_bruta,
'gastos': {'items': gastos, 'total': round(total_gastos, 2)}, 'gastos': {'items': gastos, 'total': round(total_gastos, 2)},
'utilidad_neta': utilidad_neta, 'utilidad_neta': utilidad_neta,
}
@accounting_bp.route('/income-statement', methods=['GET'])
@require_auth('accounting.view')
def income_statement():
"""Estado de resultados for a period.
Query params: year, month
Groups: Ingresos (400), Costos (500), Gastos (600)
Result: Ingresos - Costos - Gastos = Utilidad/Perdida
"""
conn = get_tenant_conn(g.tenant_id)
year = int(request.args.get('year', date.today().year))
month = int(request.args.get('month', date.today().month))
data = _income_statement_data(conn, year, month)
conn.close()
return jsonify(data)
@accounting_bp.route('/income-statement/export', methods=['GET'])
@require_auth('accounting.view')
def export_income_statement():
"""Export the estado de resultados as CSV (default) or JSON.
Query params: year, month, format=csv|json
"""
conn = get_tenant_conn(g.tenant_id)
year = int(request.args.get('year', date.today().year))
month = int(request.args.get('month', date.today().month))
fmt = request.args.get('format', 'csv').lower()
data = _income_statement_data(conn, year, month)
conn.close()
if fmt == 'json':
return jsonify(data)
rows = []
for item in data['ingresos']['items']:
rows.append(['Ingresos', item['code'], item['name'], item['amount']])
rows.append(['Ingresos', '', 'Total ingresos', data['ingresos']['total']])
for item in data['costos']['items']:
rows.append(['Costos', item['code'], item['name'], item['amount']])
rows.append(['Costos', '', 'Total costos', data['costos']['total']])
rows.append(['', '', 'Utilidad bruta', data['utilidad_bruta']])
for item in data['gastos']['items']:
rows.append(['Gastos', item['code'], item['name'], item['amount']])
rows.append(['Gastos', '', 'Total gastos', data['gastos']['total']])
rows.append(['', '', 'Utilidad neta', data['utilidad_neta']])
return _csv_response(
rows,
['Seccion', 'Cuenta', 'Nombre', 'Importe'],
f'estado_resultados_{year}-{month:02d}.csv'
)
@accounting_bp.route('/entries/export', methods=['GET'])
@require_auth('accounting.view')
def export_entries():
"""Export the polizas (journal entries) of a period with their lines.
Query params: date_from, date_to, type, reference_type, format=csv|json
CSV output has one row per journal entry line (accountant-friendly).
"""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
fmt = request.args.get('format', 'csv').lower()
date_from = request.args.get('date_from')
date_to = request.args.get('date_to')
entry_type = request.args.get('type')
ref_type = request.args.get('reference_type')
where_clauses = ["e.status = 'posted'"]
params = []
if date_from:
where_clauses.append("e.date >= %s")
params.append(date_from)
if date_to:
where_clauses.append("e.date <= %s")
params.append(date_to)
if entry_type:
where_clauses.append("e.type = %s")
params.append(entry_type)
if ref_type:
where_clauses.append("e.reference_type = %s")
params.append(ref_type)
where = " AND ".join(where_clauses)
cur.execute(f"""
SELECT e.entry_number, e.date, e.type, e.description,
e.reference_type, e.reference_id,
a.code, a.name, l.description, l.debit, l.credit
FROM journal_entry_lines l
JOIN journal_entries e ON l.journal_entry_id = e.id
JOIN accounts a ON l.account_id = a.id
WHERE {where}
ORDER BY e.date, e.entry_number, l.id
""", params)
rows = []
for r in cur.fetchall():
rows.append({
'entry_number': r[0],
'date': r[1].isoformat() if r[1] else None,
'type': r[2],
'entry_description': r[3],
'reference_type': r[4],
'reference_id': r[5],
'account_code': r[6],
'account_name': r[7],
'line_description': r[8],
'debit': float(r[9] or 0),
'credit': float(r[10] or 0),
}) })
cur.close()
conn.close()
if fmt == 'json':
return jsonify({'data': rows, 'count': len(rows)})
return _csv_response(
[[r['entry_number'], r['date'], r['type'], r['entry_description'],
r['reference_type'], r['reference_id'], r['account_code'],
r['account_name'], r['line_description'], r['debit'], r['credit']]
for r in rows],
['Poliza', 'Fecha', 'Tipo', 'Descripcion poliza', 'Referencia', 'Ref ID',
'Cuenta', 'Nombre cuenta', 'Descripcion', 'Debe', 'Haber'],
f'polizas_{date.today().isoformat()}.csv'
)
@accounting_bp.route('/diot/export', methods=['GET'])
@require_auth('accounting.view')
def export_diot():
"""Export the DIOT (operations with third parties) for a fiscal month.
Data source: purchase orders received during the month, grouped by
supplier. All amounts are expressed in the purchase-order currency (MXN
for locally created orders).
Query params: year, month, format=csv|json
"""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
year = int(request.args.get('year', date.today().year))
month = int(request.args.get('month', date.today().month))
fmt = request.args.get('format', 'csv').lower()
period_start, period_end = _period_bounds(year, month)
cur.execute("""
SELECT s.rfc, s.name,
COALESCE(SUM(po.subtotal), 0) as base,
COALESCE(SUM(po.tax_total), 0) as iva,
COALESCE(SUM(po.total), 0) as total
FROM purchase_orders po
JOIN suppliers s ON s.id = po.supplier_id
WHERE po.status NOT IN ('draft', 'cancelled')
AND po.received_at >= %s AND po.received_at < %s
GROUP BY s.rfc, s.name
ORDER BY s.name
""", (period_start, period_end))
rows = []
for r in cur.fetchall():
rows.append({
'rfc': r[0] or '',
'supplier_name': r[1],
'tipo_tercero': '04', # Proveedor
'tipo_operacion': '02', # Compra de bienes
'base': round(float(r[2]), 2),
'iva_16': round(float(r[3]), 2),
'iva_retenido': 0.0,
'total': round(float(r[4]), 2),
})
cur.close()
conn.close()
if fmt == 'json':
return jsonify({'data': rows, 'period': {'year': year, 'month': month},
'count': len(rows)})
return _csv_response(
[[r['tipo_tercero'], r['tipo_operacion'], r['rfc'], r['supplier_name'],
r['base'], r['iva_16'], r['iva_retenido'], r['total']] for r in rows],
['Tipo de tercero', 'Tipo de operacion', 'RFC', 'Nombre', 'Base',
'IVA 16%', 'IVA retenido', 'Monto total'],
f'diot_{year}-{month:02d}.csv'
)
@accounting_bp.route('/balance-sheet', methods=['GET']) @accounting_bp.route('/balance-sheet', methods=['GET'])
@require_auth('accounting.view') @require_auth('accounting.view')
@@ -823,141 +565,21 @@ def balance_sheet():
@accounting_bp.route('/aging', methods=['GET']) @accounting_bp.route('/aging', methods=['GET'])
@require_auth('accounting.view') @require_auth('accounting.view')
def aging_report(): def aging_report():
"""Antiguedad de saldos. """Antiguedad de saldos (accounts receivable aging).
Returns individual credit sales with outstanding balance when type=receivable Groups outstanding credit sales by age:
(default), or purchase orders payable to suppliers when type=payable. - Corriente (not yet due)
- 1-30 dias
- 31-60 dias
- 61-90 dias
- 90+ dias
""" """
report_type = request.args.get('type', 'receivable')
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
rows = []
if report_type == 'payable':
paid_by_po = _supplier_paid_totals(cur)
# Accounts payable: purchase orders to suppliers that are not paid/cancelled
cur.execute("""
SELECT po.id, po.supplier_invoice, po.total, po.created_at, po.expected_date,
po.status, s.id, s.name
FROM purchase_orders po
JOIN suppliers s ON s.id = po.supplier_id
WHERE po.status NOT IN ('paid', 'cancelled')
ORDER BY po.created_at DESC
""")
for r in cur.fetchall():
po_id = r[0]
invoice = r[1] or f'OC-{po_id}'
total = float(r[2]) if r[2] else 0
created_at = r[3]
expected_date = r[4]
status = r[5]
vendor_name = r[7]
paid = paid_by_po.get(po_id, 0.0)
balance = round(total - paid, 2)
if balance <= 0:
continue
days_overdue = (datetime.now(created_at.tzinfo) - expected_date).days if expected_date and created_at else 0
if days_overdue > 0:
po_status = 'overdue'
label = 'Vencida'
elif paid > 0:
po_status = 'partial'
label = 'Parcial'
else:
po_status = 'pending'
label = 'Pendiente'
rows.append({
'po_id': po_id,
'invoice': invoice,
'vendor_name': vendor_name,
'issue_date': created_at.isoformat() if created_at else None,
'due_date': expected_date.isoformat() if expected_date else None,
'total': total,
'paid': paid,
'balance': balance,
'days_overdue': days_overdue,
'status': po_status,
'status_label': label,
})
else:
# Accounts receivable: credit sales to customers
cur.execute("""
SELECT s.id, s.total, s.created_at, s.status,
c.id, c.name, c.rfc,
COALESCE((SELECT SUM(amount) FROM sale_payments sp WHERE sp.sale_id = s.id), 0) as payments_total
FROM sales s
JOIN customers c ON c.id = s.customer_id
WHERE s.sale_type = 'credit'
AND s.status = 'completed'
ORDER BY s.created_at DESC
""")
for r in cur.fetchall():
sale_id = r[0]
total = float(r[1]) if r[1] else 0
created_at = r[2]
status = r[3]
customer_name = r[5]
payments_total = float(r[7]) if r[7] else 0
paid = payments_total
balance = round(total - paid, 2)
if balance <= 0:
continue
due_date = created_at + timedelta(days=30) if created_at else None
days_overdue = (datetime.now(created_at.tzinfo) - due_date).days if due_date else 0
if days_overdue > 0:
sale_status = 'overdue'
label = 'Vencida'
elif paid > 0:
sale_status = 'partial'
label = 'Parcial'
else:
sale_status = 'pending'
label = 'Vigente'
rows.append({
'sale_id': sale_id,
'invoice': f'VTA-{sale_id}',
'customer_name': customer_name,
'issue_date': created_at.isoformat() if created_at else None,
'due_date': due_date.isoformat() if due_date else None,
'total': total,
'paid': paid,
'balance': balance,
'days_overdue': days_overdue,
'status': sale_status,
'status_label': label,
})
totals = {
'count': len(rows),
'total': round(sum(r['total'] for r in rows), 2),
'paid': round(sum(r['paid'] for r in rows), 2),
'balance': round(sum(r['balance'] for r in rows), 2),
}
cur.close()
conn.close()
return jsonify({'data': rows, 'totals': totals})
@accounting_bp.route('/aging-summary', methods=['GET'])
@require_auth('accounting.view')
def aging_summary():
"""Customer-level accounts receivable summary (used by reports)."""
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor() cur = conn.cursor()
cur.execute(""" cur.execute("""
SELECT c.id, c.name, c.rfc, c.credit_limit, c.credit_balance, SELECT c.id, c.name, c.rfc, c.credit_limit, c.credit_balance,
s.id as sale_id, s.id as sale_id, s.total, s.created_at,
s.total - COALESCE((SELECT SUM(amount) FROM sale_payments sp WHERE sp.sale_id = s.id), 0) as balance,
s.created_at,
EXTRACT(DAY FROM NOW() - s.created_at)::int as days_outstanding EXTRACT(DAY FROM NOW() - s.created_at)::int as days_outstanding
FROM customers c FROM customers c
JOIN sales s ON s.customer_id = c.id JOIN sales s ON s.customer_id = c.id
@@ -970,11 +592,6 @@ def aging_summary():
customers = {} customers = {}
for r in cur.fetchall(): for r in cur.fetchall():
cust_id = r[0] cust_id = r[0]
balance = round(float(r[6]) if r[6] else 0, 2)
if balance <= 0:
continue
days = r[8] or 0
if cust_id not in customers: if cust_id not in customers:
customers[cust_id] = { customers[cust_id] = {
'id': r[0], 'name': r[1], 'rfc': r[2], 'id': r[0], 'name': r[1], 'rfc': r[2],
@@ -984,25 +601,29 @@ def aging_summary():
'total': 0, 'total': 0,
} }
if days <= 0: amount = float(r[6]) if r[6] else 0
customers[cust_id]['corriente'] += balance days = r[8] or 0
elif days <= 30:
customers[cust_id]['d1_30'] += balance
elif days <= 60:
customers[cust_id]['d31_60'] += balance
elif days <= 90:
customers[cust_id]['d61_90'] += balance
else:
customers[cust_id]['d90_plus'] += balance
customers[cust_id]['total'] += balance if days <= 0:
customers[cust_id]['corriente'] += amount
elif days <= 30:
customers[cust_id]['d1_30'] += amount
elif days <= 60:
customers[cust_id]['d31_60'] += amount
elif days <= 90:
customers[cust_id]['d61_90'] += amount
else:
customers[cust_id]['d90_plus'] += amount
customers[cust_id]['total'] += amount
result = list(customers.values()) result = list(customers.values())
# Round all amounts
for c in result: for c in result:
c['credit_balance'] = round(c['total'], 2)
for key in ('corriente', 'd1_30', 'd31_60', 'd61_90', 'd90_plus', 'total'): for key in ('corriente', 'd1_30', 'd31_60', 'd61_90', 'd90_plus', 'total'):
c[key] = round(c[key], 2) c[key] = round(c[key], 2)
# Totals row
totals = { totals = {
'corriente': round(sum(c['corriente'] for c in result), 2), 'corriente': round(sum(c['corriente'] for c in result), 2),
'd1_30': round(sum(c['d1_30'] for c in result), 2), 'd1_30': round(sum(c['d1_30'] for c in result), 2),
@@ -1010,7 +631,6 @@ def aging_summary():
'd61_90': round(sum(c['d61_90'] for c in result), 2), 'd61_90': round(sum(c['d61_90'] for c in result), 2),
'd90_plus': round(sum(c['d90_plus'] for c in result), 2), 'd90_plus': round(sum(c['d90_plus'] for c in result), 2),
'total': round(sum(c['total'] for c in result), 2), 'total': round(sum(c['total'] for c in result), 2),
'count': len(result),
} }
cur.close() cur.close()
@@ -1018,256 +638,6 @@ def aging_summary():
return jsonify({'data': result, 'totals': totals}) return jsonify({'data': result, 'totals': totals})
@accounting_bp.route('/aging/export', methods=['GET'])
@require_auth('accounting.view')
def export_aging():
"""Export receivables or payables to CSV/PDF."""
report_type = request.args.get('type', 'receivable')
fmt = request.args.get('format', 'csv').lower()
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
if report_type == 'payable':
# Payables always CSV for now
paid_by_po = _supplier_paid_totals(cur)
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(['OC', 'Proveedor', 'Fecha emision', 'Fecha vencimiento',
'Total', 'Pagado', 'Saldo', 'Dias vencido', 'Estado'])
cur.execute("""
SELECT po.id, po.supplier_invoice, po.total, po.created_at, po.expected_date,
po.status, s.name
FROM purchase_orders po
JOIN suppliers s ON s.id = po.supplier_id
WHERE po.status NOT IN ('paid', 'cancelled')
ORDER BY po.created_at DESC
""")
for r in cur.fetchall():
po_id = r[0]
invoice = r[1] or f'OC-{po_id}'
total = float(r[2]) if r[2] else 0
created_at = r[3]
expected = r[4]
status = r[5]
vendor = r[6]
paid = paid_by_po.get(po_id, 0.0)
balance = round(total - paid, 2)
days_overdue = (datetime.now(created_at.tzinfo) - expected).days if expected and created_at else 0
status_label = 'Vencida' if days_overdue > 0 else ('Parcial' if paid > 0 else 'Pendiente')
writer.writerow([
invoice, vendor,
created_at.strftime('%Y-%m-%d') if created_at else '',
expected.strftime('%Y-%m-%d') if expected else '',
total, paid, balance, days_overdue, status_label
])
cur.close(); conn.close()
csv_data = output.getvalue()
output.close()
return Response(
'\ufeff' + csv_data,
mimetype='text/csv; charset=utf-8',
headers={'Content-Disposition': f'attachment; filename=cuentas_por_pagar_{date.today().isoformat()}.csv'}
)
# Receivables: CSV or PDF
cur.execute("""
SELECT s.id, s.subtotal, s.tax_total, s.total, s.created_at,
c.name,
COALESCE((SELECT SUM(amount) FROM sale_payments sp WHERE sp.sale_id = s.id), 0) as payments_total
FROM sales s
JOIN customers c ON c.id = s.customer_id
WHERE s.sale_type = 'credit'
AND s.status = 'completed'
ORDER BY s.created_at DESC
""")
rows = []
for r in cur.fetchall():
sale_id = r[0]
subtotal = float(r[1]) if r[1] else 0
tax = float(r[2]) if r[2] else 0
total = float(r[3]) if r[3] else 0
created_at = r[4]
customer_name = r[5]
payments_total = float(r[6]) if r[6] else 0
balance = round(total - payments_total, 2)
if balance <= 0:
continue
rows.append({
'date': created_at.strftime('%d/%m/%Y') if created_at else '',
'customer': customer_name,
'subtotal': subtotal,
'tax': tax,
'total': total,
'balance': balance,
})
cur.close(); conn.close()
if fmt == 'pdf':
from fpdf import FPDF
class ReceivablesPDF(FPDF):
def header(self):
self.set_font('Arial', 'B', 14)
self.cell(0, 10, 'Reporte de Ventas por Cobrar', 0, 1, 'C')
self.set_font('Arial', '', 10)
self.cell(0, 6, f'Generado: {date.today().strftime("%d/%m/%Y")}', 0, 1, 'C')
self.ln(4)
def footer(self):
self.set_y(-15)
self.set_font('Arial', 'I', 8)
self.cell(0, 10, f'Pagina {self.page_no()}', 0, 0, 'C')
pdf = ReceivablesPDF('L', 'mm', 'A4')
pdf.add_page()
pdf.set_font('Arial', 'B', 10)
pdf.set_fill_color(230, 230, 230)
col_widths = [30, 95, 35, 30, 35, 35]
headers = ['FECHA', 'CLIENTE', 'SUBTOTAL', 'IVA', 'TOTAL', 'SALDO']
for i, h in enumerate(headers):
pdf.cell(col_widths[i], 10, h, 1, 0, 'C', True)
pdf.ln()
pdf.set_font('Arial', '', 9)
totals = {'subtotal': 0, 'tax': 0, 'total': 0, 'balance': 0}
for row in rows:
pdf.cell(col_widths[0], 8, row['date'], 1, 0, 'C')
pdf.cell(col_widths[1], 8, row['customer'][:50], 1, 0, 'L')
pdf.cell(col_widths[2], 8, f"{row['subtotal']:.2f}", 1, 0, 'R')
pdf.cell(col_widths[3], 8, f"{row['tax']:.2f}", 1, 0, 'R')
pdf.cell(col_widths[4], 8, f"{row['total']:.2f}", 1, 0, 'R')
pdf.cell(col_widths[5], 8, f"{row['balance']:.2f}", 1, 1, 'R')
totals['subtotal'] += row['subtotal']
totals['tax'] += row['tax']
totals['total'] += row['total']
totals['balance'] += row['balance']
pdf.set_font('Arial', 'B', 9)
pdf.cell(col_widths[0] + col_widths[1], 8, 'TOTAL', 1, 0, 'R', True)
pdf.cell(col_widths[2], 8, f"{totals['subtotal']:.2f}", 1, 0, 'R', True)
pdf.cell(col_widths[3], 8, f"{totals['tax']:.2f}", 1, 0, 'R', True)
pdf.cell(col_widths[4], 8, f"{totals['total']:.2f}", 1, 0, 'R', True)
pdf.cell(col_widths[5], 8, f"{totals['balance']:.2f}", 1, 1, 'R', True)
pdf_bytes = bytes(pdf.output(dest='S'))
return Response(
pdf_bytes,
mimetype='application/pdf',
headers={'Content-Disposition': f'attachment; filename=ventas_por_cobrar_{date.today().isoformat()}.pdf'}
)
# CSV fallback
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(['Fecha', 'Cliente', 'Subtotal', 'IVA', 'Total', 'Saldo'])
for row in rows:
writer.writerow([row['date'], row['customer'], row['subtotal'], row['tax'], row['total'], row['balance']])
csv_data = output.getvalue()
output.close()
return Response(
'\ufeff' + csv_data,
mimetype='text/csv; charset=utf-8',
headers={'Content-Disposition': f'attachment; filename=ventas_por_cobrar_{date.today().isoformat()}.csv'}
)
# ─── Accounts Payable payments ─────────────────────
@accounting_bp.route('/supplier-payments', methods=['POST'])
@require_auth('accounting.create')
def create_supplier_payment():
"""Register a payment to a supplier against a purchase order (CxP).
Body: {po_id: int, amount: float, payment_method: str, reference: str}
"""
data = request.get_json() or {}
if not data.get('po_id'):
return jsonify({'error': 'po_id is required'}), 400
conn = get_tenant_conn(g.tenant_id)
try:
result = record_supplier_payment_entry(conn, data)
log_action(conn, 'SUPPLIER_PAYMENT', 'purchase_order', result['po_id'],
new_value={'amount': float(data.get('amount', 0)),
'payment_id': result['payment_id'],
'status': result['status']})
conn.commit()
conn.close()
return jsonify(result), 201
except ValueError as e:
conn.rollback()
conn.close()
return jsonify({'error': str(e)}), 400
except Exception as e:
conn.rollback()
conn.close()
return jsonify({'error': str(e)}), 500
@accounting_bp.route('/supplier-payments', methods=['GET'])
@require_auth('accounting.view')
def list_supplier_payments():
"""List supplier payments. Query params: po_id, supplier_id, page, per_page."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("SELECT to_regclass('public.supplier_payments')")
if not cur.fetchone()[0]:
cur.close(); conn.close()
return jsonify({'data': [], 'pagination': {'page': 1, 'per_page': 50, 'total': 0, 'total_pages': 0}})
page = int(request.args.get('page', 1))
per_page = min(int(request.args.get('per_page', 50)), 200)
where_clauses = ["1=1"]
params = []
if request.args.get('po_id'):
where_clauses.append("sp.po_id = %s")
params.append(int(request.args.get('po_id')))
if request.args.get('supplier_id'):
where_clauses.append("sp.supplier_id = %s")
params.append(int(request.args.get('supplier_id')))
where = " AND ".join(where_clauses)
cur.execute(f"SELECT count(*) FROM supplier_payments sp WHERE {where}", params)
total = cur.fetchone()[0]
cur.execute(f"""
SELECT sp.id, sp.po_id, sp.supplier_id, sp.amount, sp.payment_method,
sp.reference, sp.created_at, s.name as supplier_name
FROM supplier_payments sp
LEFT JOIN suppliers s ON sp.supplier_id = s.id
WHERE {where}
ORDER BY sp.created_at DESC
LIMIT %s OFFSET %s
""", params + [per_page, (page - 1) * per_page])
rows = []
for r in cur.fetchall():
rows.append({
'id': r[0], 'po_id': r[1], 'supplier_id': r[2],
'amount': float(r[3]) if r[3] else 0,
'payment_method': r[4], 'reference': r[5],
'created_at': str(r[6]) if r[6] else None,
'supplier_name': r[7],
})
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}
})
# ─── Fiscal Periods ──────────────────────────────── # ─── Fiscal Periods ────────────────────────────────
@accounting_bp.route('/periods', methods=['GET']) @accounting_bp.route('/periods', methods=['GET'])
@@ -1374,7 +744,7 @@ def close_period():
@accounting_bp.route('/stats', methods=['GET']) @accounting_bp.route('/stats', methods=['GET'])
@require_auth('accounting.view') @require_auth('accounting.read')
def api_accounting_stats(): def api_accounting_stats():
"""Return counts for tab badges: receivables (asset accounts with balance) and payables (liability accounts with balance).""" """Return counts for tab badges: receivables (asset accounts with balance) and payables (liability accounts with balance)."""
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)

View File

@@ -5,10 +5,9 @@ import jwt
import bcrypt import bcrypt
import time import time
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
from flask import Blueprint, request, jsonify, g, make_response from flask import Blueprint, request, jsonify, g
from config import JWT_SECRET, JWT_ACCESS_EXPIRES, PIN_MAX_ATTEMPTS_PER_MINUTE, PIN_LOCKOUT_THRESHOLD, PIN_LOCKOUT_MINUTES from config import JWT_SECRET, JWT_ACCESS_EXPIRES, PIN_MAX_ATTEMPTS_PER_MINUTE, PIN_LOCKOUT_THRESHOLD, PIN_LOCKOUT_MINUTES
from tenant_db import get_tenant_conn, get_master_conn from tenant_db import get_tenant_conn, get_master_conn
from middleware import require_auth
auth_bp = Blueprint('auth', __name__, url_prefix='/pos/api/auth') auth_bp = Blueprint('auth', __name__, url_prefix='/pos/api/auth')
@@ -60,7 +59,6 @@ def login_pin():
# Subdomain-resolved tenant takes priority over body param # Subdomain-resolved tenant takes priority over body param
tenant_id = getattr(g, 'tenant_id', None) or data.get('tenant_id') tenant_id = getattr(g, 'tenant_id', None) or data.get('tenant_id')
pin = data.get('pin', '') pin = data.get('pin', '')
employee_id = data.get('employee_id')
device_id = data.get('device_id', request.headers.get('X-Device-Id', 'unknown')) device_id = data.get('device_id', request.headers.get('X-Device-Id', 'unknown'))
# Optional: branch_id from the device for PIN search optimization # Optional: branch_id from the device for PIN search optimization
device_branch_id = data.get('branch_id') device_branch_id = data.get('branch_id')
@@ -91,26 +89,39 @@ def login_pin():
matched_employee = None matched_employee = None
if not employee_id: if device_branch_id:
_record_attempt(device_id, False) # Try branch employees first (fast path for known devices)
cur.close()
conn.close()
return jsonify({'error': 'Empleado no seleccionado'}), 400
# Verify the PIN only for the selected employee.
cur.execute(""" cur.execute("""
SELECT e.id, e.name, e.pin, e.role, e.branch_id, e.max_discount_pct SELECT e.id, e.name, e.pin, e.role, e.branch_id, e.max_discount_pct
FROM employees e FROM employees e
WHERE e.id = %s AND e.is_active = true AND e.pin IS NOT NULL WHERE e.is_active = true AND e.pin IS NOT NULL AND e.branch_id = %s
""", (employee_id,)) """, (device_branch_id,))
emp = cur.fetchone() for emp in cur.fetchall():
if emp:
emp_id, emp_name, emp_pin_hash, emp_role, emp_branch, emp_discount = emp emp_id, emp_name, emp_pin_hash, emp_role, emp_branch, emp_discount = emp
if emp_pin_hash and bcrypt.checkpw(pin.encode(), emp_pin_hash.encode()): if emp_pin_hash and bcrypt.checkpw(pin.encode(), emp_pin_hash.encode()):
matched_employee = { matched_employee = {
'id': emp_id, 'name': emp_name, 'role': emp_role, 'id': emp_id, 'name': emp_name, 'role': emp_role,
'branch_id': emp_branch, 'max_discount_pct': float(emp_discount) if emp_discount else 0 'branch_id': emp_branch, 'max_discount_pct': float(emp_discount) if emp_discount else 0
} }
break
if not matched_employee:
# Fallback: check ALL active employees (covers owners, admins, roaming staff)
cur.execute("""
SELECT e.id, e.name, e.pin, e.role, e.branch_id, e.max_discount_pct
FROM employees e
WHERE e.is_active = true AND e.pin IS NOT NULL
""")
employees = cur.fetchall()
for emp in employees:
emp_id, emp_name, emp_pin_hash, emp_role, emp_branch, emp_discount = emp
if emp_pin_hash and bcrypt.checkpw(pin.encode(), emp_pin_hash.encode()):
matched_employee = {
'id': emp_id, 'name': emp_name, 'role': emp_role,
'branch_id': emp_branch, 'max_discount_pct': float(emp_discount) if emp_discount else 0
}
break
if not matched_employee: if not matched_employee:
_record_attempt(device_id, False) _record_attempt(device_id, False)
@@ -146,77 +157,9 @@ def login_pin():
} }
token = jwt.encode(payload, JWT_SECRET, algorithm='HS256') token = jwt.encode(payload, JWT_SECRET, algorithm='HS256')
response = make_response(jsonify({
'token': token,
'employee': matched_employee,
'permissions': permissions
}))
# Cookie used by server-side route guards; JS can also read it for quick checks.
response.set_cookie(
'pos_role', matched_employee['role'],
path='/pos', samesite='Lax', httponly=False
)
return response
@auth_bp.route('/refresh', methods=['POST'])
@require_auth()
def refresh_token():
"""Reissue the JWT with the employee's current permissions from the DB.
This lets permission changes take effect without forcing a full re-login.
The original expiration time is preserved.
"""
auth_header = request.headers.get('Authorization', '')
try:
payload = jwt.decode(auth_header[7:], JWT_SECRET, algorithms=['HS256'])
except jwt.InvalidTokenError:
return jsonify({'error': 'Invalid token'}), 401
tenant_id = payload.get('tenant_id')
employee_id = payload.get('employee_id')
conn = get_tenant_conn(tenant_id)
cur = conn.cursor()
cur.execute(
"""
SELECT e.id, e.name, e.role, e.branch_id, e.max_discount_pct
FROM employees e
WHERE e.id = %s AND e.is_active = true
""",
(employee_id,)
)
emp = cur.fetchone()
if not emp:
cur.close(); conn.close()
return jsonify({'error': 'Employee not found or inactive'}), 404
cur.execute(
"SELECT permission FROM employee_permissions WHERE employee_id = %s",
(emp[0],)
)
permissions = [r[0] for r in cur.fetchall()]
cur.close(); conn.close()
new_payload = {
'tenant_id': tenant_id,
'employee_id': emp[0],
'name': emp[1],
'role': emp[2],
'branch_id': emp[3],
'max_discount_pct': float(emp[4]) if emp[4] else 0,
'permissions': permissions,
'device_id': payload.get('device_id', 'unknown'),
'type': 'pos_access',
'exp': payload.get('exp'),
'iat': datetime.now(timezone.utc),
}
token = jwt.encode(new_payload, JWT_SECRET, algorithm='HS256')
return jsonify({ return jsonify({
'token': token, 'token': token,
'employee': { 'employee': matched_employee,
'id': emp[0], 'name': emp[1], 'role': emp[2],
'branch_id': emp[3], 'max_discount_pct': new_payload['max_discount_pct']
},
'permissions': permissions 'permissions': permissions
}) })
@@ -254,7 +197,7 @@ def list_login_employees(tenant_id=None):
name = row[1] name = row[1]
parts = name.split() parts = name.split()
initials = ''.join([p[0].upper() for p in parts[:2]]) if parts else '?' initials = ''.join([p[0].upper() for p in parts[:2]]) if parts else '?'
role_labels = {'owner': 'Dueño', 'admin': 'Administrador', 'cashier': 'Cajero', 'counter': 'Mostrador', 'warehouse': 'Almacén', 'accountant': 'Contador', 'workshop': 'Taller', 'mechanic': 'Mecánico', 'driver': 'Repartidor'} role_labels = {'owner': 'Dueño', 'admin': 'Administrador', 'cashier': 'Cajero', 'warehouse': 'Almacén', 'accountant': 'Contador'}
employees.append({ employees.append({
'id': row[0], 'id': row[0],
'name': name, 'name': name,

View File

@@ -1,258 +0,0 @@
# /home/Autopartes/pos/blueprints/b2b_bp.py
"""Nexus B2B yonke marketplace blueprint.
Wholesale trading of used parts between yonkes (deshuesaderos): one tenant
publishes its used pieces, another buys them. Listings and orders live in the
MASTER DB; the pieces stay in each tenant DB.
All endpoints live under /pos/api/b2b.
Permissions: yonke.view for reads, yonke.manage for writes.
Seller:
GET /pos/api/b2b/my-listings my published listings
POST /pos/api/b2b/listings publish/refresh a listing
PATCH /pos/api/b2b/listings/<id> edit price/status/notes
DELETE /pos/api/b2b/listings/<id> soft-delete (status='removed')
GET /pos/api/b2b/seller/status {is_seller, listings_count, ...}
Buyer:
GET /pos/api/b2b/catalog available listings from other tenants
GET /pos/api/b2b/listings/<id> listing detail (+ seller_name)
Orders (both):
POST /pos/api/b2b/orders create an order from listings
GET /pos/api/b2b/orders my orders as buyer
GET /pos/api/b2b/orders/incoming my orders as seller
GET /pos/api/b2b/orders/<id> order detail (participants only)
PUT /pos/api/b2b/orders/<id>/status transition the order status
"""
from flask import Blueprint, g, jsonify, request
from middleware import require_auth
from services import b2b_service
from tenant_db import get_master_conn
b2b_bp = Blueprint('b2b', __name__, url_prefix='/pos/api/b2b')
def _int_arg(name, default, minimum, maximum):
try:
val = int(request.args.get(name, default))
except (TypeError, ValueError):
return default
return min(max(val, minimum), maximum)
def _error_response(e):
"""Map a service exception to (json, status); never leaks a 500."""
if isinstance(e, b2b_service.B2BForbidden):
return jsonify({'error': str(e)}), 403
if isinstance(e, b2b_service.B2BNotFound):
return jsonify({'error': str(e)}), 404
if isinstance(e, b2b_service.B2BValidationError):
return jsonify({'error': str(e)}), 400
return jsonify({'error': str(e)}), 500
# ═══════════════════════════════════════════════════════════════════════════
# SELLER
# ═══════════════════════════════════════════════════════════════════════════
@b2b_bp.route('/my-listings', methods=['GET'])
@require_auth('yonke.view')
def my_listings():
"""Listings published by the current tenant."""
master = get_master_conn()
try:
data = b2b_service.list_my_listings(
master, g.tenant_id, status=request.args.get('status'))
return jsonify({'data': data})
except Exception as e:
return _error_response(e)
finally:
master.close()
@b2b_bp.route('/listings', methods=['POST'])
@require_auth('yonke.manage')
def publish_listing():
"""Publish (or refresh) one of my used/rebuild pieces to the B2B catalog."""
data = request.get_json(silent=True) or {}
master = get_master_conn()
try:
listing = b2b_service.publish_listing(master, g.tenant_id, data)
master.commit()
return jsonify(listing), 201
except Exception as e:
master.rollback()
return _error_response(e)
finally:
master.close()
@b2b_bp.route('/listings/<int:listing_id>', methods=['PATCH'])
@require_auth('yonke.manage')
def update_listing(listing_id):
"""Edit price_wholesale / status / notes of one of my listings."""
data = request.get_json(silent=True) or {}
master = get_master_conn()
try:
listing = b2b_service.update_listing(master, g.tenant_id, listing_id, data)
master.commit()
return jsonify(listing)
except Exception as e:
master.rollback()
return _error_response(e)
finally:
master.close()
@b2b_bp.route('/listings/<int:listing_id>', methods=['DELETE'])
@require_auth('yonke.manage')
def remove_listing(listing_id):
"""Soft-delete a listing (status='removed')."""
master = get_master_conn()
try:
b2b_service.remove_listing(master, g.tenant_id, listing_id)
master.commit()
return jsonify({'ok': True, 'id': listing_id, 'status': 'removed'})
except Exception as e:
master.rollback()
return _error_response(e)
finally:
master.close()
@b2b_bp.route('/seller/status', methods=['GET'])
@require_auth('yonke.view')
def seller_status():
"""{is_seller, listings_count, incoming_orders} for the current tenant."""
master = get_master_conn()
try:
return jsonify(b2b_service.get_seller_status(master, g.tenant_id))
except Exception as e:
return _error_response(e)
finally:
master.close()
# ═══════════════════════════════════════════════════════════════════════════
# BUYER — catalog
# ═══════════════════════════════════════════════════════════════════════════
@b2b_bp.route('/catalog', methods=['GET'])
@require_auth('yonke.view')
def catalog():
"""Available listings from OTHER tenants, with seller_name and pagination."""
master = get_master_conn()
try:
items, pagination = b2b_service.list_catalog(
master, g.tenant_id,
filters={
'q': request.args.get('q'),
'make': request.args.get('make'),
'model': request.args.get('model'),
'year': request.args.get('year'),
'grade': request.args.get('grade'),
},
page=_int_arg('page', 1, 1, 10**9),
per_page=_int_arg('per_page', 12, 1, 100),
)
return jsonify({'data': items, 'pagination': pagination})
except Exception as e:
return _error_response(e)
finally:
master.close()
@b2b_bp.route('/listings/<int:listing_id>', methods=['GET'])
@require_auth('yonke.view')
def get_listing(listing_id):
"""Listing detail with seller_name."""
master = get_master_conn()
try:
listing = b2b_service.get_listing(master, listing_id)
if not listing:
return jsonify({'error': 'Listing no encontrado'}), 404
return jsonify(listing)
except Exception as e:
return _error_response(e)
finally:
master.close()
# ═══════════════════════════════════════════════════════════════════════════
# ORDERS
# ═══════════════════════════════════════════════════════════════════════════
@b2b_bp.route('/orders', methods=['POST'])
@require_auth('yonke.manage')
def create_order():
"""Create a pending order from one or more listings (single seller)."""
data = request.get_json(silent=True) or {}
master = get_master_conn()
try:
order = b2b_service.create_order(
master, g.tenant_id, data.get('items'), notes=data.get('notes'))
master.commit()
return jsonify(order), 201
except Exception as e:
master.rollback()
return _error_response(e)
finally:
master.close()
@b2b_bp.route('/orders', methods=['GET'])
@require_auth('yonke.view')
def list_orders():
"""My orders as buyer (role='buyer')."""
master = get_master_conn()
try:
return jsonify({'data': b2b_service.list_orders(master, g.tenant_id, 'buyer')})
except Exception as e:
return _error_response(e)
finally:
master.close()
@b2b_bp.route('/orders/incoming', methods=['GET'])
@require_auth('yonke.view')
def list_incoming_orders():
"""Orders I must fulfil as seller (role='seller')."""
master = get_master_conn()
try:
return jsonify({'data': b2b_service.list_orders(master, g.tenant_id, 'seller')})
except Exception as e:
return _error_response(e)
finally:
master.close()
@b2b_bp.route('/orders/<int:order_id>', methods=['GET'])
@require_auth('yonke.view')
def get_order(order_id):
"""Order detail (buyer or seller of that order only)."""
master = get_master_conn()
try:
return jsonify(b2b_service.get_order(master, g.tenant_id, order_id))
except Exception as e:
return _error_response(e)
finally:
master.close()
@b2b_bp.route('/orders/<int:order_id>/status', methods=['PUT'])
@require_auth('yonke.manage')
def update_order_status(order_id):
"""Transition an order status (role-checked). 'received' also stocks the buyer."""
data = request.get_json(silent=True) or {}
master = get_master_conn()
try:
result = b2b_service.update_order_status(
master, g.tenant_id, order_id, data.get('status'),
branch_id=g.branch_id)
return jsonify(result)
except Exception as e:
master.rollback()
return _error_response(e)
finally:
master.close()

View File

@@ -1,54 +1,18 @@
# /home/Autopartes/pos/blueprints/cashregister_bp.py # /home/Autopartes/pos/blueprints/cashregister_bp.py
"""Cash register blueprint: open/close register, cash movements, X/Z cuts.""" """Cash register blueprint: open/close register, cash movements, X/Z cuts."""
import logging
from datetime import datetime from datetime import datetime
from flask import Blueprint, request, jsonify, g from flask import Blueprint, request, jsonify, g
from middleware import require_auth, has_permission from middleware import require_auth
from tenant_db import get_tenant_conn from tenant_db import get_tenant_conn
from services.audit import log_action from services.audit import log_action
logger = logging.getLogger('pos')
cashregister_bp = Blueprint('cashregister', __name__, url_prefix='/pos/api/register') cashregister_bp = Blueprint('cashregister', __name__, url_prefix='/pos/api/register')
# Roles expected to operate a cash register even without the explicit pos.sell permission.
_REGISTER_ROLES = {'owner', 'admin', 'cashier', 'counter'}
def _can_operate_register():
return g.employee_role in _REGISTER_ROLES or 'pos.sell' in g.permissions
def _int_arg(name, default=None, min_value=None, max_value=None):
"""Parse an integer query-string argument safely.
Bad input (e.g. ?per_page=abc) falls back to the default instead of
raising ValueError and returning a 500.
"""
raw = request.args.get(name)
if raw is None or raw == '':
value = default
else:
try:
value = int(raw)
except (TypeError, ValueError):
value = default
if value is None:
return None
if min_value is not None and value < min_value:
value = min_value
if max_value is not None and value > max_value:
value = max_value
return value
@cashregister_bp.route('/open', methods=['POST']) @cashregister_bp.route('/open', methods=['POST'])
@require_auth() @require_auth('pos.sell')
def open_register(): def open_register():
if not _can_operate_register():
return jsonify({'error': 'Missing permissions: pos.sell'}), 403
"""Open a cash register session. """Open a cash register session.
Body: {register_number: int, opening_amount: float} Body: {register_number: int, opening_amount: float}
@@ -120,7 +84,7 @@ def open_register():
@cashregister_bp.route('/current', methods=['GET']) @cashregister_bp.route('/current', methods=['GET'])
@require_auth() @require_auth('pos.sell')
def current_register(): def current_register():
"""Get the current open register for this employee.""" """Get the current open register for this employee."""
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
@@ -151,10 +115,8 @@ def current_register():
@cashregister_bp.route('/movement', methods=['POST']) @cashregister_bp.route('/movement', methods=['POST'])
@require_auth() @require_auth('pos.sell')
def cash_movement(): def cash_movement():
if not _can_operate_register():
return jsonify({'error': 'Missing permissions: pos.sell'}), 403
"""Record a cash in/out movement with mandatory reason. """Record a cash in/out movement with mandatory reason.
Body: {type: 'in'|'out', amount: float, reason: str} Body: {type: 'in'|'out', amount: float, reason: str}
@@ -246,35 +208,8 @@ def _compute_register_summary(conn, register_id):
total_sales += amount total_sales += amount
total_sales_count += count total_sales_count += count
# Cash received for the expected-cash calculation: # Cash sales specifically (for expected cash calculation)
# - credit sales collect nothing at sale time (their abonos arrive as cash_from_sales = sales_by_method.get('efectivo', {}).get('amount', 0)
# cash movements), so they contribute 0
# - cash sales contribute their total (change is discounted below)
# - mixed/other sales contribute only their cash portion from sale_payments
# Layaways already log every deposit as a cash movement, so their
# conversion sale is excluded here to avoid counting that cash twice.
cur.execute("""
SELECT COALESCE(SUM(
CASE
WHEN s.sale_type = 'credit' THEN 0
WHEN s.payment_method = 'efectivo' THEN s.total
ELSE COALESCE(cash.cash_amount, 0)
END
), 0)
FROM sales s
LEFT JOIN (
SELECT sale_id, SUM(amount) AS cash_amount
FROM sale_payments
WHERE method = 'efectivo'
GROUP BY sale_id
) cash ON cash.sale_id = s.id
WHERE s.register_id = %s AND s.status = 'completed'
AND s.id NOT IN (
SELECT converted_sale_id FROM layaways
WHERE converted_sale_id IS NOT NULL
)
""", (register_id,))
cash_from_sales = float(cur.fetchone()[0])
# Change given (cash out) # Change given (cash out)
cur.execute(""" cur.execute("""
@@ -283,26 +218,15 @@ def _compute_register_summary(conn, register_id):
""", (register_id,)) """, (register_id,))
change_given = float(cur.fetchone()[0]) change_given = float(cur.fetchone()[0])
# Cash movements. Refunds from devoluciones (reason 'Devolucion #...') are # Cash movements
# kept out of the generic 'out' bucket and subtracted separately below, so
# a refund lowers the expected cash exactly once.
cur.execute(""" cur.execute("""
SELECT type, COALESCE(SUM(amount), 0) FROM cash_movements SELECT type, COALESCE(SUM(amount), 0) FROM cash_movements
WHERE register_id = %s WHERE register_id = %s GROUP BY type
AND NOT (type = 'out' AND reason LIKE 'Devolucion #%%')
GROUP BY type
""", (register_id,)) """, (register_id,))
movements = {'in': 0.0, 'out': 0.0} movements = {'in': 0.0, 'out': 0.0}
for r in cur.fetchall(): for r in cur.fetchall():
movements[r[0]] = float(r[1]) movements[r[0]] = float(r[1])
# Cash refunds handed back at this register (devoluciones in cash)
cur.execute("""
SELECT COALESCE(SUM(amount), 0) FROM cash_movements
WHERE register_id = %s AND type = 'out' AND reason LIKE 'Devolucion #%%'
""", (register_id,))
refunds_out = float(cur.fetchone()[0])
# Cancelled sales # Cancelled sales
cur.execute(""" cur.execute("""
SELECT COUNT(*), COALESCE(SUM(total), 0) FROM sales SELECT COUNT(*), COALESCE(SUM(total), 0) FROM sales
@@ -312,10 +236,9 @@ def _compute_register_summary(conn, register_id):
cancelled_count = cancelled[0] cancelled_count = cancelled[0]
cancelled_amount = float(cancelled[1]) cancelled_amount = float(cancelled[1])
# Expected cash = opening + cash sales - change + cash_in - cash_out - refunds # Expected cash = opening + cash sales - change + cash_in - cash_out
expected_cash = round( expected_cash = round(
opening_amount + cash_from_sales - change_given opening_amount + cash_from_sales - change_given + movements['in'] - movements['out'],
+ movements['in'] - movements['out'] - refunds_out,
2 2
) )
@@ -342,7 +265,6 @@ def _compute_register_summary(conn, register_id):
'change_given': round(change_given, 2), 'change_given': round(change_given, 2),
'cash_movements_in': round(movements['in'], 2), 'cash_movements_in': round(movements['in'], 2),
'cash_movements_out': round(movements['out'], 2), 'cash_movements_out': round(movements['out'], 2),
'refunds_out': round(refunds_out, 2),
'movement_detail': movement_detail, 'movement_detail': movement_detail,
'cancelled_count': cancelled_count, 'cancelled_count': cancelled_count,
'cancelled_amount': round(cancelled_amount, 2), 'cancelled_amount': round(cancelled_amount, 2),
@@ -351,10 +273,8 @@ def _compute_register_summary(conn, register_id):
@cashregister_bp.route('/cut-x', methods=['GET']) @cashregister_bp.route('/cut-x', methods=['GET'])
@require_auth() @require_auth('pos.sell')
def cut_x(): def cut_x():
if not _can_operate_register():
return jsonify({'error': 'Missing permissions: pos.sell'}), 403
"""Partial cut (corte X): read-only summary without closing the register.""" """Partial cut (corte X): read-only summary without closing the register."""
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor() cur = conn.cursor()
@@ -381,10 +301,8 @@ def cut_x():
@cashregister_bp.route('/cut-z', methods=['POST']) @cashregister_bp.route('/cut-z', methods=['POST'])
@require_auth() @require_auth('pos.sell')
def cut_z(): def cut_z():
if not _can_operate_register():
return jsonify({'error': 'Missing permissions: pos.sell'}), 403
"""Final cut (corte Z): close the register. """Final cut (corte Z): close the register.
Body: {closing_amount: float} (the amount physically counted in the register) Body: {closing_amount: float} (the amount physically counted in the register)
@@ -396,7 +314,7 @@ def cut_z():
cur = conn.cursor() cur = conn.cursor()
cur.execute(""" cur.execute("""
SELECT id, register_number FROM cash_registers SELECT id FROM cash_registers
WHERE employee_id = %s AND status = 'open' WHERE employee_id = %s AND status = 'open'
""", (g.employee_id,)) """, (g.employee_id,))
reg = cur.fetchone() reg = cur.fetchone()
@@ -404,7 +322,7 @@ def cut_z():
cur.close(); conn.close() cur.close(); conn.close()
return jsonify({'error': 'No open register'}), 400 return jsonify({'error': 'No open register'}), 400
register_id, register_number = reg register_id = reg[0]
summary = _compute_register_summary(conn, register_id) summary = _compute_register_summary(conn, register_id)
expected = summary['expected_cash'] expected = summary['expected_cash']
@@ -428,22 +346,6 @@ def cut_z():
conn.commit() conn.commit()
# Accounting entry (non-blocking): move the counted cash to Bancos.
# The cut is already committed, so a failure here never loses the corte.
try:
from services.accounting_engine import record_cash_cut_entry
record_cash_cut_entry(conn, {
'id': register_id,
'register_number': register_number,
'closing_amount': closing_amount,
})
conn.commit()
except Exception as acc_err:
conn.rollback()
logger.error(
f'Accounting entry for cash cut #{register_id} failed: {acc_err}'
)
# Push notification to owner if cash difference > $500 # Push notification to owner if cash difference > $500
if abs(difference) > 500: if abs(difference) > 500:
try: try:
@@ -477,23 +379,17 @@ def cut_z():
@cashregister_bp.route('/history', methods=['GET']) @cashregister_bp.route('/history', methods=['GET'])
@require_auth() @require_auth('pos.view')
def register_history(): def register_history():
"""List closed registers with summary. """List closed registers with summary.
Query params: date_from, date_to, employee_id, page, per_page Query params: date_from, date_to, employee_id, page, per_page
Permission rules:
- owner/admin and users with pos.view can query any employee.
- Cashiers/counters without pos.view can only query their own registers.
""" """
can_view_all = g.employee_role in ('owner', 'admin') or has_permission('pos.view')
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor() cur = conn.cursor()
page = _int_arg('page', 1, min_value=1) page = int(request.args.get('page', 1))
per_page = _int_arg('per_page', 50, min_value=1, max_value=200) per_page = min(int(request.args.get('per_page', 50)), 200)
where_clauses = ["cr.status = 'closed'"] where_clauses = ["cr.status = 'closed'"]
params = [] params = []
@@ -504,10 +400,7 @@ def register_history():
date_from = request.args.get('date_from') date_from = request.args.get('date_from')
date_to = request.args.get('date_to') date_to = request.args.get('date_to')
employee_id = _int_arg('employee_id') employee_id = request.args.get('employee_id')
if not can_view_all:
employee_id = g.employee_id
if date_from: if date_from:
where_clauses.append("cr.closed_at >= %s") where_clauses.append("cr.closed_at >= %s")
@@ -517,7 +410,7 @@ def register_history():
params.append(date_to) params.append(date_to)
if employee_id: if employee_id:
where_clauses.append("cr.employee_id = %s") where_clauses.append("cr.employee_id = %s")
params.append(employee_id) params.append(int(employee_id))
where = " AND ".join(where_clauses) where = " AND ".join(where_clauses)
@@ -683,89 +576,3 @@ def daily_summary():
'movements_out': movements['out'], 'movements_out': movements['out'],
'registers': registers, 'registers': registers,
}) })
@cashregister_bp.route('/<int:register_id>/sales', methods=['GET'])
@require_auth()
def register_sales(register_id):
"""List the sales associated with a specific cash register (cash cut).
Returns sales details and a summary by payment method. Accessible to
owners/admins, users with pos.view, or the employee who operated the register.
"""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute(
"SELECT employee_id, branch_id FROM cash_registers WHERE id = %s",
(register_id,)
)
row = cur.fetchone()
if not row:
cur.close(); conn.close()
return jsonify({'error': 'Register not found'}), 404
register_employee_id, register_branch_id = row
can_view = (
g.employee_role in ('owner', 'admin') or
has_permission('pos.view') or
register_employee_id == g.employee_id
)
if not can_view:
cur.close(); conn.close()
return jsonify({'error': 'Missing permissions: pos.view'}), 403
if g.branch_id and register_branch_id and register_branch_id != g.branch_id:
cur.close(); conn.close()
return jsonify({'error': 'Register belongs to another branch'}), 403
where = "s.register_id = %s"
params = [register_id]
if g.branch_id:
where += " AND s.branch_id = %s"
params.append(g.branch_id)
cur.execute(f"""
SELECT s.id, s.sale_type, s.payment_method, s.subtotal, s.discount_total,
s.tax_total, s.total, s.amount_paid, s.change_given, s.status,
s.created_at, c.name as customer_name, e.name as employee_name
FROM sales s
LEFT JOIN customers c ON s.customer_id = c.id
LEFT JOIN employees e ON s.employee_id = e.id
WHERE {where}
ORDER BY s.created_at DESC
""", params)
sales = []
summary = {'total': 0.0, 'count': 0, 'by_method': {}}
for r in cur.fetchall():
sale = {
'id': r[0], 'sale_type': r[1], 'payment_method': r[2],
'subtotal': float(r[3]) if r[3] else 0,
'discount_total': float(r[4]) if r[4] else 0,
'tax_total': float(r[5]) if r[5] else 0,
'total': float(r[6]) if r[6] else 0,
'amount_paid': float(r[7]) if r[7] else 0,
'change_given': float(r[8]) if r[8] else 0,
'status': r[9], 'created_at': str(r[10]),
'customer_name': r[11], 'employee_name': r[12]
}
sales.append(sale)
if sale['status'] == 'completed':
summary['total'] += sale['total']
summary['count'] += 1
m = sale['payment_method'] or 'Otro'
summary['by_method'][m] = (summary['by_method'].get(m, 0) + sale['total'])
cur.close(); conn.close()
return jsonify({
'register_id': register_id,
'sales': sales,
'summary': {
'total': round(summary['total'], 2),
'count': summary['count'],
'by_method': {k: round(v, 2) for k, v in summary['by_method'].items()}
}
})

View File

@@ -19,32 +19,11 @@ from tenant_db import get_master_conn, get_tenant_conn
from services import catalog_service from services import catalog_service
from services.vin_decoder import decode_vin from services.vin_decoder import decode_vin
from services.plate_lookup import search_plate, register_plate, is_valid_mexican_plate, normalize_plate from services.plate_lookup import search_plate, register_plate, is_valid_mexican_plate, normalize_plate
from services.inventory_vehicle_compat import get_inventory_by_vehicle
from config import CATALOG_OEM_ENABLED from config import CATALOG_OEM_ENABLED
catalog_bp = Blueprint('catalog', __name__, url_prefix='/pos/api/catalog') catalog_bp = Blueprint('catalog', __name__, url_prefix='/pos/api/catalog')
# ─── Guard: tablas TecDoc retiradas (junio 2026) ──────────────────────────
_TECDOC_TABLES_CACHE = {'checked': False, 'exists': False}
def _tecdoc_available(conn):
"""True si las tablas TecDoc del catálogo OEM existen en la maestra."""
if _TECDOC_TABLES_CACHE['checked']:
return _TECDOC_TABLES_CACHE['exists']
try:
cur = conn.cursor()
cur.execute("SELECT 1 FROM pg_tables WHERE schemaname='public' AND tablename='parts' LIMIT 1")
exists = cur.fetchone() is not None
cur.close()
except Exception:
exists = False
_TECDOC_TABLES_CACHE['checked'] = True
_TECDOC_TABLES_CACHE['exists'] = exists
return exists
def _oem_blocked(): def _oem_blocked():
"""Return a 403 response if OEM catalog is disabled.""" """Return a 403 response if OEM catalog is disabled."""
if not CATALOG_OEM_ENABLED: if not CATALOG_OEM_ENABLED:
@@ -87,9 +66,6 @@ def _with_conns(fn):
branch_id = request.args.get('branch_id', g.branch_id) branch_id = request.args.get('branch_id', g.branch_id)
return fn(master, tenant, branch_id) return fn(master, tenant, branch_id)
except Exception as e: except Exception as e:
if _is_retired_schema_error(e):
return jsonify({'data': [], 'oem_disabled': True,
'error': 'Función no disponible (esquema TecDoc retirado)'}, 200), 200
return jsonify({'error': str(e)}), 500 return jsonify({'error': str(e)}), 500
finally: finally:
if master: if master:
@@ -100,15 +76,6 @@ def _with_conns(fn):
except: pass except: pass
def _is_retired_schema_error(e):
"""True si el error es por tablas TecDoc retiradas del esquema."""
try:
from psycopg2.errors import UndefinedTable, UndefinedColumn
return isinstance(e, (UndefinedTable, UndefinedColumn))
except Exception:
return 'does not exist' in str(e)
def _master_only(fn): def _master_only(fn):
"""Helper: open only master connection for hierarchy endpoints.""" """Helper: open only master connection for hierarchy endpoints."""
master = None master = None
@@ -116,9 +83,6 @@ def _master_only(fn):
master = get_master_conn() master = get_master_conn()
return fn(master) return fn(master)
except Exception as e: except Exception as e:
if _is_retired_schema_error(e):
return jsonify({'data': [], 'oem_disabled': True,
'error': 'Función no disponible (esquema TecDoc retirado)'}, 200), 200
return jsonify({'error': str(e)}), 500 return jsonify({'error': str(e)}), 500
finally: finally:
if master: if master:
@@ -523,33 +487,6 @@ def decode_vin_route(vin):
# ─── Plate Lookup ─── # ─── Plate Lookup ───
@catalog_bp.route('/vehicle-inventory', methods=['GET'])
@require_auth('catalog.view')
def vehicle_inventory():
"""Local inventory items compatible with a vehicle (model_year_engine_id)."""
mye_id = request.args.get('mye_id', type=int)
if not mye_id:
return jsonify({'error': 'mye_id requerido'}), 400
tenant = get_tenant_conn(g.tenant_id)
try:
rows = get_inventory_by_vehicle(tenant, None, mye_id,
branch_id=getattr(g, 'branch_id', None))
items = [{
'id': r[0], 'part_number': r[1], 'name': r[2], 'brand': r[3] or '',
'price_1': float(r[4] or 0), 'price_2': float(r[5] or 0),
'price_3': float(r[6] or 0), 'image_url': r[7] or '',
'stock': int(r[9] or 0),
} for r in rows]
return jsonify({'data': items})
finally:
try:
tenant.close()
except Exception:
pass
@catalog_bp.route('/plate/<plate>', methods=['GET']) @catalog_bp.route('/plate/<plate>', methods=['GET'])
@require_auth('catalog.view') @require_auth('catalog.view')
def plate_lookup(plate): def plate_lookup(plate):
@@ -604,9 +541,6 @@ def plate_lookup(plate):
return jsonify(response) return jsonify(response)
except Exception as e: except Exception as e:
if _is_retired_schema_error(e):
return jsonify({'data': [], 'oem_disabled': True,
'error': 'Función no disponible (esquema TecDoc retirado)'}, 200), 200
return jsonify({'error': str(e)}), 500 return jsonify({'error': str(e)}), 500
finally: finally:
if tenant: if tenant:
@@ -642,9 +576,6 @@ def plate_register():
) )
return jsonify({'id': rec_id, 'message': 'Placa registrada.'}) return jsonify({'id': rec_id, 'message': 'Placa registrada.'})
except Exception as e: except Exception as e:
if _is_retired_schema_error(e):
return jsonify({'data': [], 'oem_disabled': True,
'error': 'Función no disponible (esquema TecDoc retirado)'}, 200), 200
return jsonify({'error': str(e)}), 500 return jsonify({'error': str(e)}), 500
finally: finally:
if tenant: if tenant:

View File

@@ -1,8 +1,6 @@
# /home/Autopartes/pos/blueprints/config_bp.py # /home/Autopartes/pos/blueprints/config_bp.py
"""Config blueprint: tenant configuration, branches, theming.""" """Config blueprint: tenant configuration, branches, theming."""
import json
import re
from flask import Blueprint, request, jsonify, g from flask import Blueprint, request, jsonify, g
from middleware import require_auth, has_permission from middleware import require_auth, has_permission
from tenant_db import get_tenant_conn from tenant_db import get_tenant_conn
@@ -10,194 +8,6 @@ from tenant_db import get_tenant_conn
config_bp = Blueprint('config', __name__, url_prefix='/pos/api/config') config_bp = Blueprint('config', __name__, url_prefix='/pos/api/config')
# Default permission set per role. Can be overridden per tenant via role_permissions config.
_DEFAULT_ROLE_PERMISSIONS = {
'owner': [], # owner bypasses permission checks
'admin': ['pos.sell', 'pos.discount', 'pos.cancel', 'pos.view_cost',
'inventory.view', 'inventory.create', 'inventory.edit', 'inventory.adjust', 'inventory.transfer',
'yonke.view', 'yonke.manage',
'catalog.view', 'catalog.edit',
'customers.view', 'customers.create', 'customers.edit', 'customers.edit_credit',
'invoicing.view', 'invoicing.create',
'reports.view', 'reports.financial',
'config.view', 'config.edit', 'config.edit_prices',
'workshop.view', 'workshop.edit',
'fleet.view', 'fleet.create', 'fleet.edit', 'fleet.delete'],
'cashier': ['pos.sell', 'pos.discount', 'pos.cancel', 'pos.view', 'pos.remission',
'catalog.view',
'inventory.view', 'inventory.create',
'customers.view', 'customers.create',
'workshop.view', 'workshop.edit', 'workshop.add_items',
'invoicing.view', 'invoicing.create', 'invoicing.cancel'],
'counter': ['pos.remission', 'pos.view',
'catalog.view',
'inventory.view', 'inventory.create',
'customers.view', 'customers.create'],
'warehouse': ['inventory.view', 'inventory.create', 'inventory.edit',
'inventory.adjust', 'inventory.transfer', 'catalog.view'],
'accountant': ['accounting.view', 'accounting.create',
'invoicing.view', 'invoicing.create', 'invoicing.cancel',
'reports.view', 'reports.financial',
'customers.view',
'fleet.view'],
'workshop': ['workshop.view', 'workshop.edit', 'workshop.add_items'],
'mechanic': ['workshop.view'],
'driver': ['pos.remission'],
'sales': ['pos.sell', 'pos.discount', 'pos.view', 'catalog.view',
'customers.view', 'customers.create'],
}
# Statuses used in the workshop kanban. Must stay in sync with workshop.js COLUMNS.
_WORKSHOP_STATUSES = [
'por_revisar', 'en_revision', 'revisada', 'cotizada', 'por_autorizar',
'autorizada', 'autorizacion_parcial', 'en_reparacion', 'reparada',
'por_entregar', 'entregado', 'por_enviar', 'enviado',
'por_facturar', 'facturada', 'por_recolectar', 'cancelada'
]
# Workshop-specific actions configurable per role.
_WORKSHOP_ACTIONS = [
{'key': 'create_order', 'label': 'Crear órdenes'},
{'key': 'edit_order', 'label': 'Editar órdenes'},
{'key': 'delete_order', 'label': 'Eliminar órdenes'},
{'key': 'assign_mechanic', 'label': 'Asignar mecánico'},
{'key': 'add_items', 'label': 'Agregar artículos'},
{'key': 'add_labor', 'label': 'Agregar mano de obra'},
{'key': 'change_status', 'label': 'Cambiar estatus'},
{'key': 'convert_to_sale', 'label': 'Convertir a venta'},
{'key': 'convert_to_remission', 'label': 'Generar nota de remisión'},
{'key': 'view_customer_data', 'label': 'Ver datos del cliente/vehículo'},
{'key': 'view_prices', 'label': 'Ver precios/costos'},
{'key': 'view_notes', 'label': 'Ver bitácora'},
]
# Default workshop permissions per role. Admins see everything; restricted roles only work statuses.
_DEFAULT_WORKSHOP_PERMISSIONS = {
'owner': {'statuses': _WORKSHOP_STATUSES, 'actions': [a['key'] for a in _WORKSHOP_ACTIONS]},
'admin': {'statuses': _WORKSHOP_STATUSES, 'actions': [a['key'] for a in _WORKSHOP_ACTIONS]},
'manager': {'statuses': _WORKSHOP_STATUSES, 'actions': [a['key'] for a in _WORKSHOP_ACTIONS if a['key'] != 'delete_order']},
'counter': {'statuses': _WORKSHOP_STATUSES, 'actions': [a['key'] for a in _WORKSHOP_ACTIONS if a['key'] != 'delete_order']},
'cashier': {'statuses': _WORKSHOP_STATUSES, 'actions': [a['key'] for a in _WORKSHOP_ACTIONS if a['key'] not in ('delete_order', 'convert_to_sale')]},
'workshop': {
'statuses': [s for s in _WORKSHOP_STATUSES if s not in ('por_entregar', 'entregado', 'por_enviar', 'enviado', 'por_recolectar')],
'actions': ['change_status', 'add_labor', 'view_notes']
},
'mechanic': {
'statuses': ['por_revisar', 'en_revision', 'revisada', 'en_reparacion', 'reparada', 'autorizada', 'cancelada'],
'actions': ['change_status', 'add_labor', 'view_notes']
},
}
_AVAILABLE_PERMISSIONS = [
{'module': 'Dashboard', 'permissions': [
{'key': 'dashboard.view', 'label': 'Ver Dashboard'},
]},
{'module': 'Punto de Venta', 'permissions': [
{'key': 'pos.sell', 'label': 'Realizar ventas'},
{'key': 'pos.view', 'label': 'Ver ventas/cotizaciones'},
{'key': 'pos.discount', 'label': 'Aplicar descuentos'},
{'key': 'pos.cancel', 'label': 'Cancelar ventas'},
{'key': 'pos.remission', 'label': 'Notas de remisión'},
{'key': 'pos.view_cost', 'label': 'Ver costos en POS'},
]},
{'module': 'Inventario', 'permissions': [
{'key': 'inventory.view', 'label': 'Ver inventario'},
{'key': 'inventory.create', 'label': 'Crear artículos'},
{'key': 'inventory.edit', 'label': 'Editar artículos'},
{'key': 'inventory.delete', 'label': 'Eliminar artículos'},
{'key': 'inventory.adjust', 'label': 'Ajustar stock'},
{'key': 'inventory.transfer', 'label': 'Transferir entre sucursales'},
{'key': 'inventory.import', 'label': 'Importar artículos masivamente'},
{'key': 'inventory.view_cost', 'label': 'Ver costos'},
]},
{'module': 'Yonke', 'permissions': [
{'key': 'yonke.view', 'label': 'Ver Yonke (partes usadas)'},
{'key': 'yonke.manage', 'label': 'Gestionar Yonke (piezas y consignaciones)'},
]},
{'module': 'Catálogo', 'permissions': [
{'key': 'catalog.view', 'label': 'Ver catálogo'},
{'key': 'catalog.edit', 'label': 'Editar catálogo'},
]},
{'module': 'Clientes', 'permissions': [
{'key': 'customers.view', 'label': 'Ver clientes'},
{'key': 'customers.create', 'label': 'Crear clientes'},
{'key': 'customers.edit', 'label': 'Editar clientes'},
{'key': 'customers.delete', 'label': 'Eliminar clientes'},
{'key': 'customers.edit_credit', 'label': 'Editar límite de crédito'},
]},
{'module': 'Taller', 'permissions': [
{'key': 'workshop.view', 'label': 'Ver módulo Taller'},
{'key': 'workshop.edit', 'label': 'Crear/editar órdenes (deprecado, usar matriz Taller)'},
{'key': 'workshop.add_items', 'label': 'Agregar artículos/mano de obra (deprecado, usar matriz Taller)'},
]},
{'module': 'Facturación', 'permissions': [
{'key': 'invoicing.view', 'label': 'Ver facturas'},
{'key': 'invoicing.create', 'label': 'Crear facturas'},
{'key': 'invoicing.cancel', 'label': 'Cancelar facturas'},
]},
{'module': 'Configuración', 'permissions': [
{'key': 'config.view', 'label': 'Ver configuración'},
{'key': 'config.edit', 'label': 'Editar configuración'},
{'key': 'config.edit_prices', 'label': 'Modificar precios globales'},
]},
{'module': 'Contabilidad', 'permissions': [
{'key': 'accounting.view', 'label': 'Ver contabilidad'},
{'key': 'accounting.create', 'label': 'Crear movimientos contables'},
]},
{'module': 'Reportes', 'permissions': [
{'key': 'reports.view', 'label': 'Ver reportes'},
{'key': 'reports.financial', 'label': 'Reportes financieros'},
]},
{'module': 'Flotillas', 'permissions': [
{'key': 'fleet.view', 'label': 'Ver flotillas'},
{'key': 'fleet.create', 'label': 'Crear flotillas'},
{'key': 'fleet.edit', 'label': 'Editar flotillas'},
{'key': 'fleet.delete', 'label': 'Eliminar flotillas'},
]},
]
def _get_role_permissions(conn, role):
"""Return configured permissions for a role, falling back to defaults."""
cur = conn.cursor()
cur.execute("SELECT value FROM tenant_config WHERE key = 'role_permissions'")
row = cur.fetchone()
cur.close()
if row and row[0]:
try:
configured = json.loads(row[0])
if isinstance(configured, dict) and role in configured:
return list(configured.get(role, []))
except (ValueError, TypeError):
pass
return list(_DEFAULT_ROLE_PERMISSIONS.get(role, []))
def _get_workshop_permissions(conn, role):
"""Return configured workshop permissions for a role, falling back to defaults."""
cur = conn.cursor()
cur.execute("SELECT value FROM tenant_config WHERE key = 'workshop_permissions'")
row = cur.fetchone()
cur.close()
if row and row[0]:
try:
configured = json.loads(row[0])
if isinstance(configured, dict) and role in configured:
return configured[role]
except (ValueError, TypeError):
pass
return _DEFAULT_WORKSHOP_PERMISSIONS.get(role, {'statuses': [], 'actions': []})
def _get_all_workshop_permissions(conn):
"""Return effective workshop permissions for every known role."""
result = {}
for role in _DEFAULT_WORKSHOP_PERMISSIONS:
result[role] = _get_workshop_permissions(conn, role)
return result
@config_bp.route('/branches', methods=['GET']) @config_bp.route('/branches', methods=['GET'])
@require_auth() @require_auth()
def list_branches(): def list_branches():
@@ -340,58 +150,8 @@ def update_branch(branch_id):
return jsonify({'ok': True, 'message': 'Branch updated'}) return jsonify({'ok': True, 'message': 'Branch updated'})
@config_bp.route('/branches/<int:branch_id>', methods=['DELETE'])
@require_auth('config.edit')
def delete_branch(branch_id):
"""Hard-delete a branch. Only owner/admin can delete; main branch cannot be deleted.
Related records keep their data but lose the branch reference; stock and count
rows tied exclusively to the branch are removed.
"""
if g.employee_role not in ('owner', 'admin'):
return jsonify({'error': 'Solo administradores pueden eliminar sucursales'}), 403
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("SELECT is_main FROM branches WHERE id = %s", (branch_id,))
row = cur.fetchone()
if not row:
cur.close(); conn.close()
return jsonify({'error': 'Branch not found'}), 404
if row[0]:
cur.close(); conn.close()
return jsonify({'error': 'No se puede eliminar la sucursal principal'}), 403
# Remove branch-specific stock and count rows first.
cur.execute("DELETE FROM inventory_stock WHERE branch_id = %s", (branch_id,))
cur.execute("DELETE FROM inventory_stock_summary WHERE branch_id = %s", (branch_id,))
cur.execute("DELETE FROM physical_counts WHERE branch_id = %s", (branch_id,))
# Nullify every other FK reference back to branches.
cur.execute("""
SELECT c.relname::text AS tbl, a.attname::text AS col
FROM pg_constraint con
JOIN pg_class c ON c.oid = con.conrelid
JOIN pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = ANY(con.conkey)
WHERE con.confrelid = 'public.branches'::regclass
AND con.contype = 'f'
""")
for tbl, col in cur.fetchall():
if tbl in ('inventory_stock', 'inventory_stock_summary', 'physical_counts'):
continue
cur.execute(f'UPDATE "{tbl}" SET "{col}" = NULL WHERE "{col}" = %s', (branch_id,))
cur.execute("DELETE FROM branches WHERE id = %s", (branch_id,))
conn.commit()
cur.close()
conn.close()
return jsonify({'ok': True, 'message': 'Sucursal eliminada'})
@config_bp.route('/employees', methods=['GET']) @config_bp.route('/employees', methods=['GET'])
@require_auth() @require_auth('config.view')
def list_employees(): def list_employees():
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor() cur = conn.cursor()
@@ -440,7 +200,7 @@ def create_employee():
nxt_name = PLANS[nxt]['name'] if nxt else 'Enterprise' nxt_name = PLANS[nxt]['name'] if nxt else 'Enterprise'
return jsonify({'error': f'Plan limit reached ({limit} employees). Upgrade to {nxt_name}.'}), 403 return jsonify({'error': f'Plan limit reached ({limit} employees). Upgrade to {nxt_name}.'}), 403
valid_roles = ['admin', 'cashier', 'counter', 'warehouse', 'accountant', 'workshop', 'mechanic', 'driver'] valid_roles = ['admin', 'cashier', 'warehouse', 'accountant']
if data['role'] not in valid_roles: if data['role'] not in valid_roles:
return jsonify({'error': f'role must be one of: {", ".join(valid_roles)}'}), 400 return jsonify({'error': f'role must be one of: {", ".join(valid_roles)}'}), 400
@@ -455,21 +215,31 @@ def create_employee():
data['role'], data.get('branch_id'), data.get('max_discount_pct', 0))) data['role'], data.get('branch_id'), data.get('max_discount_pct', 0)))
emp_id = cur.fetchone()[0] emp_id = cur.fetchone()[0]
# Set default permissions by role (configurable per tenant) # Set default permissions by role
for perm in _get_role_permissions(conn, data['role']): role_permissions = {
'admin': ['pos.sell', 'pos.discount', 'pos.cancel', 'pos.view_cost',
'inventory.view', 'inventory.create', 'inventory.edit', 'inventory.adjust', 'inventory.transfer',
'catalog.view', 'catalog.edit',
'customers.view', 'customers.create', 'customers.edit', 'customers.edit_credit',
'invoicing.view', 'invoicing.create',
'reports.view', 'reports.financial',
'config.view', 'config.edit', 'config.edit_prices'],
'cashier': ['pos.sell', 'pos.discount', 'pos.cancel',
'catalog.view', 'customers.view', 'customers.create'],
'warehouse': ['inventory.view', 'inventory.create', 'inventory.edit',
'inventory.adjust', 'inventory.transfer', 'catalog.view'],
'accountant': ['accounting.view', 'accounting.create',
'invoicing.view', 'invoicing.create', 'invoicing.cancel',
'reports.view', 'reports.financial',
'customers.view'],
}
for perm in role_permissions.get(data['role'], []):
cur.execute( cur.execute(
"INSERT INTO employee_permissions (employee_id, permission) VALUES (%s, %s) ON CONFLICT DO NOTHING", "INSERT INTO employee_permissions (employee_id, permission) VALUES (%s, %s) ON CONFLICT DO NOTHING",
(emp_id, perm) (emp_id, perm)
) )
# Drivers also get a courier record so they can be assigned to deliveries
if data['role'] == 'driver':
cur.execute("""
INSERT INTO couriers (tenant_id, name, code, is_active)
VALUES (%s, %s, %s, true)
ON CONFLICT (tenant_id, code) DO NOTHING
""", (g.tenant_id, data['name'], f'emp_{emp_id}'))
from services.audit import log_action from services.audit import log_action
log_action(conn, 'EMPLOYEE_CREATE', 'employee', emp_id, log_action(conn, 'EMPLOYEE_CREATE', 'employee', emp_id,
new_value={'name': data['name'], 'role': data['role']}) new_value={'name': data['name'], 'role': data['role']})
@@ -480,105 +250,6 @@ def create_employee():
return jsonify({'id': emp_id, 'message': 'Employee created'}), 201 return jsonify({'id': emp_id, 'message': 'Employee created'}), 201
@config_bp.route('/role-permissions', methods=['GET'])
@require_auth()
def get_role_permissions_config():
"""Return the configured permissions for each role and the available permission list."""
conn = get_tenant_conn(g.tenant_id)
try:
configured = {}
for role in _DEFAULT_ROLE_PERMISSIONS:
configured[role] = _get_role_permissions(conn, role)
return jsonify({
'roles': configured,
'available': _AVAILABLE_PERMISSIONS,
})
finally:
conn.close()
@config_bp.route('/role-permissions', methods=['PUT'])
@require_auth('config.edit')
def save_role_permissions_config():
"""Save the permission mapping per role. Only owner/admin can edit."""
if g.employee_role not in ('owner', 'admin'):
return jsonify({'error': 'Solo administradores pueden editar permisos de roles'}), 403
data = request.get_json() or {}
if 'roles' not in data:
return jsonify({'error': 'roles object required'}), 400
for role, perms in data['roles'].items():
if role not in _DEFAULT_ROLE_PERMISSIONS:
return jsonify({'error': f'Invalid role: {role}'}), 400
if not isinstance(perms, list):
return jsonify({'error': f'permissions for {role} must be a list'}), 400
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("""
INSERT INTO tenant_config (key, value) VALUES ('role_permissions', %s)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
""", (json.dumps(data['roles']),))
# Apply the new permissions to existing employees of each affected role
for role, perms in data['roles'].items():
cur.execute("DELETE FROM employee_permissions WHERE employee_id IN (SELECT id FROM employees WHERE role = %s)", (role,))
cur.execute("SELECT id FROM employees WHERE role = %s", (role,))
emp_ids = [r[0] for r in cur.fetchall()]
for emp_id in emp_ids:
for perm in perms:
cur.execute(
"INSERT INTO employee_permissions (employee_id, permission) VALUES (%s, %s) ON CONFLICT DO NOTHING",
(emp_id, perm)
)
conn.commit()
cur.close(); conn.close()
return jsonify({'ok': True, 'updated_roles': list(data['roles'].keys())})
@config_bp.route('/role-permissions/workshop', methods=['GET'])
@require_auth('config.view')
def get_workshop_permissions_config():
"""Return the configured workshop permissions (statuses + actions) per role."""
conn = get_tenant_conn(g.tenant_id)
try:
return jsonify({
'roles': _get_all_workshop_permissions(conn),
'statuses': [{'key': s, 'label': s.replace('_', ' ').title()} for s in _WORKSHOP_STATUSES],
'actions': _WORKSHOP_ACTIONS,
})
finally:
conn.close()
@config_bp.route('/role-permissions/workshop', methods=['PUT'])
@require_auth('config.edit')
def save_workshop_permissions_config():
"""Save the workshop permission mapping per role."""
if g.employee_role not in ('owner', 'admin'):
return jsonify({'error': 'Solo administradores pueden editar permisos de roles'}), 403
data = request.get_json() or {}
if 'roles' not in data:
return jsonify({'error': 'roles object required'}), 400
for role, cfg in data['roles'].items():
if role not in _DEFAULT_WORKSHOP_PERMISSIONS:
return jsonify({'error': f'Invalid role: {role}'}), 400
if not isinstance(cfg, dict):
return jsonify({'error': f'config for {role} must be an object'}), 400
if not isinstance(cfg.get('statuses', []), list) or not isinstance(cfg.get('actions', []), list):
return jsonify({'error': f'statuses/actions for {role} must be lists'}), 400
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("""
INSERT INTO tenant_config (key, value) VALUES ('workshop_permissions', %s)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
""", (json.dumps(data['roles']),))
conn.commit()
cur.close(); conn.close()
return jsonify({'ok': True, 'updated_roles': list(data['roles'].keys())})
@config_bp.route('/employees/<int:emp_id>', methods=['PUT']) @config_bp.route('/employees/<int:emp_id>', methods=['PUT'])
@require_auth('config.edit') @require_auth('config.edit')
def update_employee(emp_id): def update_employee(emp_id):
@@ -624,24 +295,6 @@ def update_employee(emp_id):
params.append(emp_id) params.append(emp_id)
cur.execute(f"UPDATE employees SET {', '.join(updates)} WHERE id = %s", params) cur.execute(f"UPDATE employees SET {', '.join(updates)} WHERE id = %s", params)
# If the role changed, re-sync permissions to match the new role defaults/config.
if 'role' in data:
cur.execute("DELETE FROM employee_permissions WHERE employee_id = %s", (emp_id,))
for perm in _get_role_permissions(conn, data['role']):
cur.execute(
"INSERT INTO employee_permissions (employee_id, permission) VALUES (%s, %s) ON CONFLICT DO NOTHING",
(emp_id, perm)
)
# Drivers also get a courier record so they can be assigned to deliveries
if data['role'] == 'driver':
cur.execute("SELECT name FROM employees WHERE id = %s", (emp_id,))
emp_name = cur.fetchone()[0]
cur.execute("""
INSERT INTO couriers (tenant_id, name, code, is_active)
VALUES (%s, %s, %s, true)
ON CONFLICT (tenant_id, code) DO NOTHING
""", (g.tenant_id, emp_name, f'emp_{emp_id}'))
from services.audit import log_action from services.audit import log_action
log_action(conn, 'EMPLOYEE_UPDATE', 'employee', emp_id, log_action(conn, 'EMPLOYEE_UPDATE', 'employee', emp_id,
new_value={k: v for k, v in data.items() if k != 'pin'}) new_value={k: v for k, v in data.items() if k != 'pin'})
@@ -652,53 +305,6 @@ def update_employee(emp_id):
return jsonify({'ok': True, 'message': 'Employee updated'}) return jsonify({'ok': True, 'message': 'Employee updated'})
@config_bp.route('/employees/<int:emp_id>', methods=['DELETE'])
@require_auth('config.edit')
def delete_employee(emp_id):
"""Hard-delete an employee. Foreign-key references are nulled and
dependent rows (permissions/sessions) are removed. Owners cannot be
deleted via UI to prevent locking out the tenant."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("SELECT role FROM employees WHERE id = %s", (emp_id,))
row = cur.fetchone()
if not row:
cur.close(); conn.close()
return jsonify({'error': 'Employee not found'}), 404
if row[0] == 'owner':
cur.close(); conn.close()
return jsonify({'error': 'No se puede eliminar una cuenta de dueno'}), 403
# Resolve every foreign-key column that points back to employees.
cur.execute("""
SELECT c.relname::text AS tbl, a.attname::text AS col
FROM pg_constraint con
JOIN pg_class c ON c.oid = con.conrelid
JOIN pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = ANY(con.conkey)
WHERE con.confrelid = 'public.employees'::regclass
AND con.contype = 'f'
""")
refs = cur.fetchall()
for tbl, col in refs:
if tbl in ('employee_permissions', 'employee_sessions', 'notification_preferences'):
cur.execute(f"DELETE FROM \"{tbl}\" WHERE \"{col}\" = %s", (emp_id,))
else:
cur.execute(f"UPDATE \"{tbl}\" SET \"{col}\" = NULL WHERE \"{col}\" = %s", (emp_id,))
cur.execute("DELETE FROM employees WHERE id = %s", (emp_id,))
from services.audit import log_action
log_action(conn, 'EMPLOYEE_DELETE', 'employee', emp_id)
conn.commit()
cur.close()
conn.close()
return jsonify({'ok': True, 'message': 'Empleado eliminado'})
@config_bp.route('/currency', methods=['GET']) @config_bp.route('/currency', methods=['GET'])
@require_auth() @require_auth()
def get_currency(): def get_currency():
@@ -783,14 +389,7 @@ def get_business():
'nombre': cfg.get('tenant_nombre', cfg.get('tenant_razon_social', '')), 'nombre': cfg.get('tenant_nombre', cfg.get('tenant_razon_social', '')),
'rfc': cfg.get('tenant_rfc', ''), 'rfc': cfg.get('tenant_rfc', ''),
'regimen_fiscal': cfg.get('tenant_regimen_fiscal', ''), 'regimen_fiscal': cfg.get('tenant_regimen_fiscal', ''),
'cp': cfg.get('tenant_cp', ''),
'direccion': cfg.get('tenant_direccion', ''), 'direccion': cfg.get('tenant_direccion', ''),
'numero_exterior': cfg.get('tenant_numero_exterior', ''),
'numero_interior': cfg.get('tenant_numero_interior', ''),
'colonia': cfg.get('tenant_colonia', ''),
'ciudad': cfg.get('tenant_ciudad', ''),
'municipio': cfg.get('tenant_municipio', ''),
'estado': cfg.get('tenant_estado', ''),
'telefono': cfg.get('tenant_telefono', ''), 'telefono': cfg.get('tenant_telefono', ''),
'email': cfg.get('tenant_email', ''), 'email': cfg.get('tenant_email', ''),
}) })
@@ -806,22 +405,14 @@ def update_business():
'nombre': 'tenant_nombre', 'nombre': 'tenant_nombre',
'rfc': 'tenant_rfc', 'rfc': 'tenant_rfc',
'regimen_fiscal': 'tenant_regimen_fiscal', 'regimen_fiscal': 'tenant_regimen_fiscal',
'cp': 'tenant_cp',
'direccion': 'tenant_direccion', 'direccion': 'tenant_direccion',
'numero_exterior': 'tenant_numero_exterior',
'numero_interior': 'tenant_numero_interior',
'colonia': 'tenant_colonia',
'ciudad': 'tenant_ciudad',
'municipio': 'tenant_municipio',
'estado': 'tenant_estado',
'telefono': 'tenant_telefono', 'telefono': 'tenant_telefono',
'email': 'tenant_email', 'email': 'tenant_email',
# Tax params (also keep cfdi_* aliases in sync) # Tax params
'tax_iva': 'tax_iva', 'tax_iva': 'tax_iva',
'tax_ieps': 'tax_ieps', 'tax_ieps': 'tax_ieps',
'invoice_serie': 'invoice_serie', 'invoice_serie': 'invoice_serie',
'invoice_folio': 'invoice_folio', 'invoice_folio': 'invoice_folio',
'cfdi_serie': 'cfdi_serie',
'default_currency': 'default_currency', 'default_currency': 'default_currency',
'default_payment_method': 'default_payment_method', 'default_payment_method': 'default_payment_method',
} }
@@ -900,15 +491,16 @@ def get_billing():
@config_bp.route('/billing/upgrade', methods=['POST']) @config_bp.route('/billing/upgrade', methods=['POST'])
@require_auth('config.edit') @require_auth('config.edit')
def upgrade_billing(): def upgrade_billing():
"""Plan changes are handled by Nexus support, never self-service. """Upgrade tenant plan."""
from services.billing import upgrade_plan
This endpoint previously let any tenant owner upgrade themselves to data = request.get_json() or {}
`enterprise` (unlimited) for free — a direct revenue leak. It now new_plan = data.get('plan')
refuses every change; plans are managed from the master DB / admin panel. if not new_plan:
""" return jsonify({'error': 'plan required'}), 400
return jsonify({ result = upgrade_plan(g.tenant_id, new_plan)
'error': 'Los cambios de plan los gestiona Nexus. Contacta a soporte para actualizar tu plan.' if 'error' in result:
}), 403 return jsonify(result), 400
return jsonify(result)
# ─── Vehicle Compatibility Source ──────────────────── # ─── Vehicle Compatibility Source ────────────────────
@@ -1023,49 +615,6 @@ def update_allowed_brands():
# ─── WhatsApp Configuration ──────────────────────────────────────────────── # ─── WhatsApp Configuration ────────────────────────────────────────────────
INVOICE_TOGGLE_KEYS = {
'invoice_auto': 'invoice_auto',
'invoice_email': 'invoice_email',
'invoice_global': 'invoice_global',
}
@config_bp.route('/invoice-settings', methods=['GET'])
@require_auth('config.view')
def get_invoice_settings():
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("SELECT key, value FROM tenant_config WHERE key = ANY(%s)",
(list(INVOICE_TOGGLE_KEYS.values()),))
rows = {r[0]: r[1] for r in cur.fetchall()}
cur.close()
conn.close()
return jsonify({
'invoice_auto': rows.get('invoice_auto', 'false').lower() == 'true',
'invoice_email': rows.get('invoice_email', 'false').lower() == 'true',
'invoice_global': rows.get('invoice_global', 'false').lower() == 'true',
})
@config_bp.route('/invoice-settings', methods=['PUT'])
@require_auth('config.edit')
def update_invoice_settings():
data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
for json_key, cfg_key in INVOICE_TOGGLE_KEYS.items():
cur.execute("""
INSERT INTO tenant_config (key, value) VALUES (%s, %s)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
""", (cfg_key, 'true' if data.get(json_key) else 'false'))
conn.commit()
cur.close()
conn.close()
return jsonify({'ok': True})
@config_bp.route('/whatsapp', methods=['GET']) @config_bp.route('/whatsapp', methods=['GET'])
@require_auth('config.view') @require_auth('config.view')
def get_whatsapp_config(): def get_whatsapp_config():
@@ -1082,14 +631,6 @@ def get_whatsapp_config():
'bridge_key': rows.get('whatsapp_bridge_key', ''), 'bridge_key': rows.get('whatsapp_bridge_key', ''),
'enabled': rows.get('whatsapp_enabled', 'false').lower() == 'true', 'enabled': rows.get('whatsapp_enabled', 'false').lower() == 'true',
'phone_number': rows.get('whatsapp_phone_number', ''), 'phone_number': rows.get('whatsapp_phone_number', ''),
'provider': rows.get('whatsapp_provider', 'baileys'),
'phone_number_id': rows.get('wa_phone_number_id', ''),
'has_access_token': bool(rows.get('wa_access_token')),
'has_app_secret': bool(rows.get('wa_app_secret')),
'verify_token': rows.get('wa_verify_token', ''),
'api_base': rows.get('wa_api_base', ''),
'has_api_key': bool(rows.get('wa_api_key')),
'public_base': rows.get('wa_public_base', ''),
}) })
@@ -1106,18 +647,7 @@ def update_whatsapp_config():
'whatsapp_bridge_key': data.get('bridge_key', ''), 'whatsapp_bridge_key': data.get('bridge_key', ''),
'whatsapp_enabled': 'true' if data.get('enabled') else 'false', 'whatsapp_enabled': 'true' if data.get('enabled') else 'false',
'whatsapp_phone_number': data.get('phone_number', ''), 'whatsapp_phone_number': data.get('phone_number', ''),
'whatsapp_provider': data.get('provider', 'baileys'),
'wa_phone_number_id': data.get('phone_number_id', ''),
'wa_verify_token': data.get('verify_token', ''),
'wa_api_base': data.get('api_base', ''),
'wa_public_base': data.get('public_base', ''),
} }
if data.get('api_key'):
settings['wa_api_key'] = data['api_key']
if data.get('access_token'):
settings['wa_access_token'] = data['access_token']
if data.get('app_secret'):
settings['wa_app_secret'] = data['app_secret']
for key, value in settings.items(): for key, value in settings.items():
cur.execute(""" cur.execute("""
@@ -1132,151 +662,25 @@ def update_whatsapp_config():
return jsonify({'message': 'WhatsApp configuration updated'}) return jsonify({'message': 'WhatsApp configuration updated'})
# ─── Contactos de alerta (POR NEGOCIO) ─────────────────────────────────────
# Los contactos que reciben alertas por WhatsApp son POR NEGOCIO: cada tenant
# configura sus propios números en su `tenant_config` (`alert_phones`,
# `alert_whatsapp_enabled`). No hay ningún teléfono global obligatorio; si un
# negocio no configura nada, sus alertas simplemente no se envían (no-op, sin
# error). El webhook de Alertmanager (`pos/app.py`) lee estas mismas claves.
# `+` opcional seguido de 10-15 dígitos (formato internacional).
_ALERT_PHONE_RE = re.compile(r'^\+?\d{10,15}$')
def _normalize_alert_phones(raw):
"""Normaliza contactos separados por comas.
Acepta una cadena ("+5215512345678, 5215587654321") o una lista/tupla.
Recorta espacios, valida cada número (`+` opcional + 10-15 dígitos) y
descarta el resto. Devuelve la tupla (validos, descartados).
"""
if isinstance(raw, (list, tuple)):
chunks = []
for item in raw:
chunks.extend(str(item).split(','))
else:
chunks = str(raw or '').split(',')
valid, dropped = [], []
for chunk in chunks:
phone = chunk.strip()
if not phone:
continue
if _ALERT_PHONE_RE.match(phone):
if phone not in valid:
valid.append(phone)
else:
dropped.append(phone)
return valid, dropped
@config_bp.route('/alert-contacts', methods=['GET'])
@require_auth('config.view')
def get_alert_contacts():
"""Contactos de alerta por WhatsApp de ESTE negocio (tenant).
Son por negocio, nunca globales. `alert_phones` se devuelve como lista
normalizada; `alert_whatsapp_enabled` indica si el reenvío está activado.
Si el negocio no ha configurado nada, devuelve lista vacía y `false`
(las alertas serán no-op). `inherited_global` siempre es `false`: no se
hereda ningún teléfono global.
"""
conn = get_tenant_conn(g.tenant_id)
try:
cur = conn.cursor()
cur.execute(
"SELECT key, value FROM tenant_config "
"WHERE key IN ('alert_phones', 'alert_whatsapp_enabled')"
)
rows = {r[0]: r[1] for r in cur.fetchall()}
cur.close()
finally:
conn.close()
phones, _ = _normalize_alert_phones(rows.get('alert_phones'))
return jsonify({
'alert_phones': phones,
'alert_whatsapp_enabled': str(rows.get('alert_whatsapp_enabled', 'false')).lower() == 'true',
'inherited_global': False,
})
@config_bp.route('/alert-contacts', methods=['PUT'])
@require_auth('config.edit')
def update_alert_contacts():
"""Guarda los contactos de alerta por WhatsApp de ESTE negocio.
Normaliza `alert_phones` (separa por comas, recorta y descarta basura:
solo se aceptan `+` opcional + 10-15 dígitos) y guarda
`alert_whatsapp_enabled` como booleano. Un payload vacío es válido y deja
al negocio sin contactos (alertas no-op). Devuelve lo guardado.
"""
data = request.get_json(silent=True)
if data is None:
data = {}
if not isinstance(data, dict):
return jsonify({'error': 'JSON object required'}), 400
phones, dropped = _normalize_alert_phones(data.get('alert_phones'))
enabled = bool(data.get('alert_whatsapp_enabled'))
conn = get_tenant_conn(g.tenant_id)
try:
cur = conn.cursor()
cur.execute("""
INSERT INTO tenant_config (key, value) VALUES ('alert_phones', %s)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
""", (','.join(phones),))
cur.execute("""
INSERT INTO tenant_config (key, value) VALUES ('alert_whatsapp_enabled', %s)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
""", ('true' if enabled else 'false',))
conn.commit()
cur.close()
finally:
conn.close()
if dropped:
import logging
logging.getLogger(__name__).warning(
"alert-contacts: %d contacto(s) descartado(s) para tenant %s",
len(dropped), g.tenant_id,
)
return jsonify({
'alert_phones': phones,
'alert_whatsapp_enabled': enabled,
'inherited_global': False,
'dropped': dropped,
})
@config_bp.route('/modules', methods=['GET']) @config_bp.route('/modules', methods=['GET'])
@require_auth() @require_auth('config.view')
def get_modules(): def get_modules():
"""Get enabled modules for this tenant.""" """Get enabled modules for this tenant."""
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor() cur = conn.cursor()
cur.execute("SELECT key, value FROM tenant_config WHERE key LIKE 'module_%' OR key LIKE '%enabled'") cur.execute("SELECT key, value FROM tenant_config WHERE key LIKE 'module_%'")
rows = {row[0]: row[1] for row in cur.fetchall()} rows = {row[0]: row[1] for row in cur.fetchall()}
cur.close() cur.close()
conn.close() conn.close()
def _bool(name): def _bool(key):
# Unificado 2026-10-11: el POS escribe 'module_X' y el dashboard 'X_enabled'; return rows.get(key, 'true').lower() == 'true'
# aceptamos ambos para que el toggle se refleje en las dos capas.
v = rows.get('module_' + name)
if v is None:
v = rows.get(name + '_enabled')
return (v if v is not None else 'true').lower() == 'true'
return jsonify({ return jsonify({
'whatsapp': _bool('whatsapp'), 'whatsapp': _bool('module_whatsapp'),
'marketplace': _bool('marketplace'), 'marketplace': _bool('module_marketplace'),
'meli': _bool('meli'), 'meli': _bool('module_meli'),
'catalog': _bool('catalog'), 'catalog': _bool('module_catalog'),
'workshop': _bool('workshop'),
'yonke': _bool('yonke'),
}) })
@@ -1288,14 +692,12 @@ def update_modules():
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor() cur = conn.cursor()
# Doble escritura: 'module_X' (POS) + 'X_enabled' (dashboard/consumidores). settings = {
# Solo se tocan las claves presentes en el payload (no se pisan las demás). 'module_whatsapp': 'true' if data.get('whatsapp') else 'false',
settings = {} 'module_marketplace': 'true' if data.get('marketplace') else 'false',
for name in ('whatsapp', 'marketplace', 'meli', 'catalog', 'workshop', 'yonke'): 'module_meli': 'true' if data.get('meli') else 'false',
if name in data: 'module_catalog': 'true' if data.get('catalog') else 'false',
val = 'true' if data.get(name) else 'false' }
settings['module_' + name] = val
settings[name + '_enabled'] = val
for key, value in settings.items(): for key, value in settings.items():
cur.execute(""" cur.execute("""
@@ -1311,78 +713,9 @@ def update_modules():
'whatsapp': data.get('whatsapp'), 'whatsapp': data.get('whatsapp'),
'marketplace': data.get('marketplace'), 'marketplace': data.get('marketplace'),
'meli': data.get('meli'), 'meli': data.get('meli'),
'catalog': data.get('catalog'),
'workshop': data.get('workshop'),
}}) }})
@config_bp.route('/counter-remission', methods=['GET'])
@require_auth()
def get_counter_remission_config():
"""Get counter remission note feature flag for this tenant."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("SELECT value FROM tenant_config WHERE key = 'counter_remission_enabled'")
row = cur.fetchone()
cur.close(); conn.close()
return jsonify({'enabled': str(row[0]).lower() == 'true' if row else False})
@config_bp.route('/counter-remission', methods=['PUT'])
@require_auth('config.edit')
def update_counter_remission_config():
"""Enable/disable counter remission notes for this tenant."""
data = request.get_json() or {}
enabled = 'true' if data.get('enabled') 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
""", ('counter_remission_enabled', enabled))
conn.commit()
cur.close(); conn.close()
return jsonify({'enabled': enabled == 'true'})
@config_bp.route('/sales-settings', methods=['GET'])
@require_auth('pos.view')
def get_sales_settings():
"""Get sales-related settings (zero-price sales, negative stock)."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("SELECT key, value FROM tenant_config WHERE key IN ('allow_zero_price_sales', 'allow_negative_stock')")
rows = {k: v for k, v in cur.fetchall()}
cur.close(); conn.close()
# Default allow_zero_price_sales to true to avoid breaking existing tenants.
allow = str(rows.get('allow_zero_price_sales', 'true')).lower() in ('true', '1', 'yes')
# Default allow_negative_stock to false (safer).
neg = str(rows.get('allow_negative_stock', 'false')).lower() in ('true', '1', 'yes')
return jsonify({'allow_zero_price_sales': allow, 'allow_negative_stock': neg})
@config_bp.route('/sales-settings', methods=['PUT'])
@require_auth('config.edit')
def update_sales_settings():
"""Update sales-related settings."""
data = request.get_json() or {}
allow = 'true' if data.get('allow_zero_price_sales') else 'false'
neg = 'true' if data.get('allow_negative_stock') 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
""", ('allow_zero_price_sales', allow))
cur.execute("""
INSERT INTO tenant_config (key, value) VALUES (%s, %s)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
""", ('allow_negative_stock', neg))
conn.commit()
cur.close(); conn.close()
return jsonify({'allow_zero_price_sales': allow == 'true', 'allow_negative_stock': neg == 'true'})
@config_bp.route('/onboarding-status', methods=['GET']) @config_bp.route('/onboarding-status', methods=['GET'])
@require_auth('pos.view') @require_auth('pos.view')
def get_onboarding_status(): def get_onboarding_status():
@@ -1412,104 +745,3 @@ def set_onboarding_status():
cur.close() cur.close()
conn.close() conn.close()
return jsonify({'completed': completed == 'true'}) return jsonify({'completed': completed == 'true'})
# ─── Receipt / Ticket Customization ────────────────────────────────────────
RECEIPT_CONFIG_KEYS = [
'receipt_logo',
'receipt_store_name',
'receipt_tagline',
'receipt_rfc',
'receipt_address',
'receipt_phone',
'receipt_footer',
'receipt_thanks_message',
'receipt_show_logo',
'receipt_show_rfc',
'receipt_show_address',
'receipt_show_phone',
'receipt_show_iva_breakdown',
'receipt_show_payment_details',
'receipt_show_employee',
'receipt_paper_width',
'receipt_auto_print',
]
@config_bp.route('/receipt', methods=['GET'])
@require_auth('pos.view')
def get_receipt_config():
"""Get receipt customization settings."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute(
"SELECT key, value FROM tenant_config WHERE key = ANY(%s)",
(RECEIPT_CONFIG_KEYS,)
)
rows = {row[0]: row[1] for row in cur.fetchall()}
cur.close()
conn.close()
def _bool(key, default=False):
v = rows.get(key, 'true' if default else 'false')
return str(v).lower() == 'true'
return jsonify({
'logo': rows.get('receipt_logo', ''),
'store_name': rows.get('receipt_store_name', ''),
'tagline': rows.get('receipt_tagline', ''),
'rfc': rows.get('receipt_rfc', ''),
'address': rows.get('receipt_address', ''),
'phone': rows.get('receipt_phone', ''),
'footer': rows.get('receipt_footer', ''),
'thanks_message': rows.get('receipt_thanks_message', 'Gracias por su compra!'),
'show_logo': _bool('receipt_show_logo', True),
'show_rfc': _bool('receipt_show_rfc', True),
'show_address': _bool('receipt_show_address', False),
'show_phone': _bool('receipt_show_phone', False),
'show_iva_breakdown': _bool('receipt_show_iva_breakdown', True),
'show_payment_details': _bool('receipt_show_payment_details', True),
'auto_print': _bool('receipt_auto_print', False),
'show_employee': _bool('receipt_show_employee', False),
'paper_width': rows.get('receipt_paper_width', '80') or '80',
})
@config_bp.route('/receipt', methods=['PUT'])
@require_auth('config.edit')
def update_receipt_config():
"""Update receipt customization settings."""
data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
settings = {
'receipt_logo': data.get('logo', ''),
'receipt_store_name': data.get('store_name', ''),
'receipt_tagline': data.get('tagline', ''),
'receipt_rfc': data.get('rfc', ''),
'receipt_address': data.get('address', ''),
'receipt_phone': data.get('phone', ''),
'receipt_footer': data.get('footer', ''),
'receipt_thanks_message': data.get('thanks_message', 'Gracias por su compra!'),
'receipt_show_logo': 'true' if data.get('show_logo') else 'false',
'receipt_show_rfc': 'true' if data.get('show_rfc') else 'false',
'receipt_show_address': 'true' if data.get('show_address') else 'false',
'receipt_show_phone': 'true' if data.get('show_phone') else 'false',
'receipt_show_iva_breakdown': 'true' if data.get('show_iva_breakdown') else 'false',
'receipt_show_payment_details': 'true' if data.get('show_payment_details') else 'false',
'receipt_show_employee': 'true' if data.get('show_employee') else 'false',
'receipt_paper_width': str(data.get('paper_width', '80') or '80'),
}
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': 'Receipt configuration updated'})

View File

@@ -2,31 +2,18 @@
"""Customers blueprint: CRUD, credit management, vehicles, account statements.""" """Customers blueprint: CRUD, credit management, vehicles, account statements."""
import json import json
import logging
from flask import Blueprint, request, jsonify, g from flask import Blueprint, request, jsonify, g
from middleware import require_auth, has_permission from middleware import require_auth, has_permission
from tenant_db import get_tenant_conn from tenant_db import get_tenant_conn
from services.audit import log_action from services.audit import log_action
logger = logging.getLogger('pos')
customers_bp = Blueprint('customers', __name__, url_prefix='/pos/api/customers') customers_bp = Blueprint('customers', __name__, url_prefix='/pos/api/customers')
def _can_view_customers():
"""Cashiers, counter and workshop employees need customer access for POS/service flows."""
return g.employee_role == 'owner' or 'customers.view' in g.permissions or g.employee_role in ('cashier', 'counter', 'workshop', 'mechanic')
def _can_create_customer():
"""Cashiers, counter and workshop employees can create customers on the fly."""
return g.employee_role == 'owner' or 'customers.create' in g.permissions or g.employee_role in ('cashier', 'counter', 'workshop', 'mechanic')
# ─── Customer CRUD ───────────────────────────── # ─── Customer CRUD ─────────────────────────────
@customers_bp.route('', methods=['GET']) @customers_bp.route('', methods=['GET'])
@require_auth() @require_auth('customers.view')
def list_customers(): def list_customers():
"""Search/list customers. Supports autocomplete-style search by name, RFC, phone. """Search/list customers. Supports autocomplete-style search by name, RFC, phone.
@@ -36,8 +23,6 @@ def list_customers():
per_page: items per page (default 50, max 200) per_page: items per page (default 50, max 200)
branch_id: filter by branch (default: current user's branch) branch_id: filter by branch (default: current user's branch)
""" """
if not _can_view_customers():
return jsonify({'error': 'Missing permissions: customers.view'}), 403
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor() cur = conn.cursor()
@@ -45,24 +30,10 @@ def list_customers():
per_page = min(int(request.args.get('per_page', 50)), 200) per_page = min(int(request.args.get('per_page', 50)), 200)
search = request.args.get('q', '').strip() search = request.args.get('q', '').strip()
branch_id = request.args.get('branch_id') branch_id = request.args.get('branch_id')
price_tier = request.args.get('price_tier', '').strip()
status = request.args.get('status', '').strip().lower()
where_clauses = [] where_clauses = ["c.is_active = true"]
params = [] params = []
if status == 'inactive':
where_clauses.append("c.is_active = false")
elif status == 'overdue':
where_clauses.append(
"c.is_active = true AND c.credit_limit > 0 AND c.credit_balance > c.credit_limit"
)
elif status == 'all':
pass # no is_active filter
else:
# Default to active customers for backwards compatibility
where_clauses.append("c.is_active = true")
if branch_id: if branch_id:
where_clauses.append("c.branch_id = %s") where_clauses.append("c.branch_id = %s")
params.append(int(branch_id)) params.append(int(branch_id))
@@ -71,20 +42,8 @@ def list_customers():
"(c.name ILIKE %s OR c.rfc ILIKE %s OR c.phone ILIKE %s OR c.razon_social ILIKE %s)" "(c.name ILIKE %s OR c.rfc ILIKE %s OR c.phone ILIKE %s OR c.razon_social ILIKE %s)"
) )
params.extend([f'%{search}%'] * 4) params.extend([f'%{search}%'] * 4)
if price_tier:
# Support numeric tier or Spanish labels
tier_map = {'taller': 2, 'mostrador': 1, 'mayoreo': 3}
tier_val = tier_map.get(price_tier.lower())
if tier_val is None:
try:
tier_val = int(price_tier)
except ValueError:
tier_val = None
if tier_val in (1, 2, 3):
where_clauses.append("c.price_tier = %s")
params.append(tier_val)
where = " AND ".join(where_clauses) if where_clauses else "true" where = " AND ".join(where_clauses)
# Count # Count
cur.execute(f"SELECT count(*) FROM customers c WHERE {where}", params) cur.execute(f"SELECT count(*) FROM customers c WHERE {where}", params)
@@ -95,8 +54,7 @@ def list_customers():
SELECT c.id, c.name, c.rfc, c.razon_social, c.phone, c.email, SELECT c.id, c.name, c.rfc, c.razon_social, c.phone, c.email,
c.address, c.cp, c.address, c.cp,
c.price_tier, c.credit_limit, c.credit_balance, c.vehicle_info, c.price_tier, c.credit_limit, c.credit_balance, c.vehicle_info,
c.branch_id, c.is_active, c.created_at, c.branch_id
(SELECT MAX(s.created_at) FROM sales s WHERE s.customer_id = c.id) AS last_purchase
FROM customers c FROM customers c
WHERE {where} WHERE {where}
ORDER BY c.name ORDER BY c.name
@@ -113,9 +71,6 @@ def list_customers():
'credit_balance': float(r[10]) if r[10] else 0, 'credit_balance': float(r[10]) if r[10] else 0,
'vehicle_info': r[11], 'vehicle_info': r[11],
'branch_id': r[12], 'branch_id': r[12],
'is_active': r[13],
'created_at': str(r[14]) if r[14] else None,
'last_purchase': str(r[15]) if r[15] else None,
}) })
cur.close() cur.close()
@@ -129,10 +84,8 @@ def list_customers():
@customers_bp.route('/<int:customer_id>', methods=['GET']) @customers_bp.route('/<int:customer_id>', methods=['GET'])
@require_auth() @require_auth('customers.view')
def get_customer(customer_id): def get_customer(customer_id):
if not _can_view_customers():
return jsonify({'error': 'Missing permissions: customers.view'}), 403
"""Get customer details with credit info, vehicle history, and recent purchases.""" """Get customer details with credit info, vehicle history, and recent purchases."""
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor() cur = conn.cursor()
@@ -158,14 +111,6 @@ def get_customer(customer_id):
customer['created_at'] = str(customer['created_at']) if customer['created_at'] else None customer['created_at'] = str(customer['created_at']) if customer['created_at'] else None
# Credit risk score (A9). Never block customer detail on scoring errors.
try:
from services.credit_scoring import score_customer
customer['credit_score'] = score_customer(conn, customer_id)
except Exception as exc:
logger.warning('credit score for customer %s failed: %s', customer_id, exc)
customer['credit_score'] = None
# Recent purchases (last 20) # Recent purchases (last 20)
cur.execute(""" cur.execute("""
SELECT s.id, s.total, s.payment_method, s.sale_type, s.status, s.created_at, SELECT s.id, s.total, s.payment_method, s.sale_type, s.status, s.created_at,
@@ -190,93 +135,19 @@ def get_customer(customer_id):
float(customer['credit_limit']) - float(customer['credit_balance']), 2 float(customer['credit_limit']) - float(customer['credit_balance']), 2
) )
# Fleet vehicles assigned to this customer
cur.execute("""
SELECT id, plate, vin, make, model, year, current_mileage, color, owner_name, is_active, created_at
FROM fleet_vehicles
WHERE customer_id = %s
ORDER BY is_active DESC, created_at DESC
""", (customer_id,))
customer['fleet_vehicles'] = []
for r in cur.fetchall():
customer['fleet_vehicles'].append({
'id': r[0], 'plate': r[1], 'vin': r[2], 'make': r[3], 'model': r[4],
'year': r[5], 'current_mileage': r[6], 'color': r[7], 'owner_name': r[8],
'is_active': r[9], 'created_at': str(r[10]) if r[10] else None,
})
cur.close() cur.close()
conn.close() conn.close()
return jsonify(customer) return jsonify(customer)
@customers_bp.route('/<int:customer_id>/credit-score', methods=['GET'])
@require_auth()
def get_customer_credit_score(customer_id):
"""Credit risk score for a customer (A9).
Returns {score, tier, recommended_limit, reasons, data_points, ...}.
"""
if not _can_view_customers():
return jsonify({'error': 'Missing permissions: customers.view'}), 403
conn = get_tenant_conn(g.tenant_id)
try:
from services.credit_scoring import score_customer
return jsonify(score_customer(conn, customer_id))
except ValueError as e:
return jsonify({'error': str(e)}), 404
except Exception as e:
logger.error('credit-score for customer %s failed: %s', customer_id, e)
return jsonify({'error': 'Could not compute credit score'}), 500
finally:
conn.close()
@customers_bp.route('/<int:customer_id>/purchases', methods=['GET'])
@require_auth()
def get_customer_purchases(customer_id):
if not _can_view_customers():
return jsonify({'error': 'Missing permissions: customers.view'}), 403
"""Return full purchase history for a customer."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("""
SELECT s.id, s.total, s.payment_method, s.sale_type, s.status, s.created_at,
e.name as employee_name
FROM sales s
LEFT JOIN employees e ON s.employee_id = e.id
WHERE s.customer_id = %s
ORDER BY s.created_at DESC
""", (customer_id,))
purchases = []
for r in cur.fetchall():
purchases.append({
'id': r[0],
'total': float(r[1]) if r[1] else 0,
'payment_method': r[2],
'sale_type': r[3],
'status': r[4],
'created_at': str(r[5]),
'employee_name': r[6],
})
cur.close()
conn.close()
return jsonify({'data': purchases})
@customers_bp.route('', methods=['POST']) @customers_bp.route('', methods=['POST'])
@require_auth() @require_auth('customers.create')
def create_customer(): def create_customer():
"""Create a new customer. """Create a new customer.
Body: {name, rfc, razon_social, regimen_fiscal, uso_cfdi, cp, email, Body: {name, rfc, razon_social, regimen_fiscal, uso_cfdi, cp, email,
phone, address, price_tier, credit_limit, vehicle_info} phone, address, price_tier, credit_limit, vehicle_info}
""" """
if not _can_create_customer():
return jsonify({'error': 'Missing permissions: customers.create'}), 403
data = request.get_json() or {} data = request.get_json() or {}
if not data.get('name'): if not data.get('name'):
return jsonify({'error': 'name is required'}), 400 return jsonify({'error': 'name is required'}), 400
@@ -369,57 +240,9 @@ def update_customer(customer_id):
return jsonify({'message': 'Customer updated'}) return jsonify({'message': 'Customer updated'})
@customers_bp.route('/<int:customer_id>', methods=['DELETE'])
@require_auth('customers.delete')
def delete_customer(customer_id):
"""Hard-delete a customer.
Related records (sales, service orders, vehicles, etc.) keep their data but
lose the customer reference. Layaways for this customer are removed because
they require a customer_id.
"""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("SELECT id FROM customers WHERE id = %s", (customer_id,))
if not cur.fetchone():
cur.close(); conn.close()
return jsonify({'error': 'Customer not found'}), 404
# Remove layaway dependencies first, then the layaways themselves
cur.execute("DELETE FROM layaway_items WHERE layaway_id IN (SELECT id FROM layaways WHERE customer_id = %s)", (customer_id,))
cur.execute("DELETE FROM layaway_payments WHERE layaway_id IN (SELECT id FROM layaways WHERE customer_id = %s)", (customer_id,))
cur.execute("DELETE FROM layaways WHERE customer_id = %s", (customer_id,))
# Nullify every other FK reference back to customers.
cur.execute("""
SELECT c.relname::text AS tbl, a.attname::text AS col
FROM pg_constraint con
JOIN pg_class c ON c.oid = con.conrelid
JOIN pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = ANY(con.conkey)
WHERE con.confrelid = 'public.customers'::regclass
AND con.contype = 'f'
""")
refs = cur.fetchall()
for tbl, col in refs:
# Cascade-delete tables and already-handled layaways are skipped.
if tbl in ('customer_activities', 'customer_tag_assignments', 'loyalty_points', 'loyalty_redemptions', 'layaways'):
continue
cur.execute(f'UPDATE "{tbl}" SET "{col}" = NULL WHERE "{col}" = %s', (customer_id,))
cur.execute("DELETE FROM customers WHERE id = %s", (customer_id,))
log_action(conn, 'CUSTOMER_DELETE', 'customer', customer_id)
conn.commit()
cur.close()
conn.close()
return jsonify({'message': 'Cliente eliminado'})
@customers_bp.route('/<int:customer_id>/statement', methods=['GET']) @customers_bp.route('/<int:customer_id>/statement', methods=['GET'])
@require_auth() @require_auth('customers.view')
def customer_statement(customer_id): def customer_statement(customer_id):
if not _can_view_customers():
return jsonify({'error': 'Missing permissions: customers.view'}), 403
"""Account statement: sales (invoices), payments, running balance. """Account statement: sales (invoices), payments, running balance.
Query params: Query params:
@@ -518,10 +341,8 @@ def customer_statement(customer_id):
@customers_bp.route('/<int:customer_id>/vehicles', methods=['GET']) @customers_bp.route('/<int:customer_id>/vehicles', methods=['GET'])
@require_auth() @require_auth('customers.view')
def customer_vehicles(customer_id): def customer_vehicles(customer_id):
if not _can_view_customers():
return jsonify({'error': 'Missing permissions: customers.view'}), 403
"""Get customer's vehicle list with last purchases per vehicle. """Get customer's vehicle list with last purchases per vehicle.
Vehicle info is stored as JSONB in customers.vehicle_info: Vehicle info is stored as JSONB in customers.vehicle_info:
@@ -618,32 +439,6 @@ def record_customer_payment(customer_id):
UPDATE customers SET credit_balance = %s WHERE id = %s UPDATE customers SET credit_balance = %s WHERE id = %s
""", (new_balance, customer_id)) """, (new_balance, customer_id))
# Allocate the customer payment to oldest unpaid credit sales so the
# aging report and per-sale balances reflect the remaining debt.
remaining = amount
cur.execute("""
SELECT s.id, s.total - COALESCE(SUM(sp.amount), 0) as balance
FROM sales s
LEFT JOIN sale_payments sp ON sp.sale_id = s.id
WHERE s.customer_id = %s
AND s.sale_type = 'credit'
AND s.status = 'completed'
GROUP BY s.id, s.total, s.created_at
HAVING s.total - COALESCE(SUM(sp.amount), 0) > 0
ORDER BY s.created_at
""", (customer_id,))
for sale_id, balance in cur.fetchall():
if remaining <= 0:
break
pay = round(min(remaining, float(balance)), 2)
cur.execute("""
INSERT INTO sale_payments
(sale_id, register_id, method, amount, reference)
VALUES (%s, %s, %s, %s, %s)
""", (sale_id, register_id, payment_method, pay,
f'Abono cliente #{customer_id}'))
remaining = round(remaining - pay, 2)
# Record cash movement on register if cash payment # Record cash movement on register if cash payment
if register_id and payment_method == 'efectivo': if register_id and payment_method == 'efectivo':
cur.execute(""" cur.execute("""
@@ -657,24 +452,6 @@ def record_customer_payment(customer_id):
new_value={'credit_balance': new_balance, 'payment': amount}) new_value={'credit_balance': new_balance, 'payment': amount})
conn.commit() conn.commit()
# Accounting entry (non-blocking): Cobro de credito
# Debit Caja/Bancos, Credit Clientes
try:
from services.accounting_engine import record_credit_payment_entry
record_credit_payment_entry(conn, {
'customer_id': customer_id,
'customer_name': cust[0],
'amount': amount,
'payment_method': payment_method,
})
conn.commit()
except Exception as acc_err:
conn.rollback()
logger.error(
f'Accounting entry for customer payment #{payment_id} failed: {acc_err}'
)
cur.close(); conn.close() cur.close(); conn.close()
return jsonify({ return jsonify({

View File

@@ -118,70 +118,3 @@ def get_employee_stats():
finally: finally:
cur.close() cur.close()
conn.close() conn.close()
@dashboard_stats_bp.route('/credit-alerts', methods=['GET'])
@require_auth()
def credit_alerts():
"""Credit sales that are overdue or due within the next 7 days."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
try:
cur.execute("""
SELECT s.id,
c.name as customer_name,
s.total,
s.created_at,
s.created_at + INTERVAL '30 days' as due_date,
COALESCE(SUM(sp.amount), 0) as paid
FROM sales s
JOIN customers c ON c.id = s.customer_id
LEFT JOIN sale_payments sp ON sp.sale_id = s.id
WHERE s.sale_type = 'credit'
AND s.status = 'completed'
GROUP BY s.id, c.name, s.total, s.created_at
HAVING s.total - COALESCE(SUM(sp.amount), 0) > 0
AND s.created_at + INTERVAL '30 days' <= NOW() + INTERVAL '30 days'
ORDER BY due_date
LIMIT 50
""")
rows = []
now = datetime.utcnow()
for r in cur.fetchall():
sale_id = r[0]
customer_name = r[1]
total = float(r[2]) if r[2] else 0
paid = float(r[5]) if r[5] else 0
balance = round(total - paid, 2)
created_at = r[3]
due_date = r[4]
days_until_due = (due_date.replace(tzinfo=None) - now).days if due_date else None
if days_until_due is None:
continue
status = 'overdue' if days_until_due < 0 else ('due_soon' if days_until_due <= 7 else 'current')
label = 'Vencida' if status == 'overdue' else ('Por vencer' if status == 'due_soon' else 'Al corriente')
rows.append({
'sale_id': sale_id,
'folio': f'VTA-{sale_id}',
'customer_name': customer_name,
'issue_date': created_at.isoformat() if created_at else None,
'due_date': due_date.isoformat() if due_date else None,
'days_until_due': days_until_due,
'total': total,
'paid': paid,
'balance': balance,
'status': status,
'status_label': label,
})
return jsonify({
'data': rows,
'overdue_count': sum(1 for r in rows if r['status'] == 'overdue'),
'due_soon_count': sum(1 for r in rows if r['status'] == 'due_soon'),
})
finally:
cur.close()
conn.close()

View File

@@ -13,7 +13,7 @@ fleet_bp = Blueprint('fleet', __name__, url_prefix='/pos/api/fleet')
# ─── Vehicles CRUD ───────────────────────────── # ─── Vehicles CRUD ─────────────────────────────
@fleet_bp.route('/vehicles', methods=['GET']) @fleet_bp.route('/vehicles', methods=['GET'])
@require_auth('fleet.view') @require_auth()
def list_vehicles(): def list_vehicles():
"""List fleet vehicles with pagination and search. """List fleet vehicles with pagination and search.
@@ -83,7 +83,7 @@ def list_vehicles():
@fleet_bp.route('/vehicles/<int:vehicle_id>', methods=['GET']) @fleet_bp.route('/vehicles/<int:vehicle_id>', methods=['GET'])
@require_auth('fleet.view') @require_auth()
def get_vehicle(vehicle_id): def get_vehicle(vehicle_id):
"""Vehicle detail with maintenance schedules and recent logs.""" """Vehicle detail with maintenance schedules and recent logs."""
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
@@ -155,128 +155,16 @@ def get_vehicle(vehicle_id):
return jsonify(vehicle) return jsonify(vehicle)
@fleet_bp.route('/vehicles/schedules', methods=['GET'])
@require_auth('fleet.view')
def list_all_schedules():
"""Return all active maintenance schedules joined with vehicle data.
Replaces the N+1 pattern of fetching schedules per vehicle.
"""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
where_clauses = ["v.is_active = true", "s.is_active = true"]
params = []
if g.branch_id:
where_clauses.append("v.branch_id = %s")
params.append(g.branch_id)
vehicle_ids = request.args.get('vehicle_ids', '').strip()
if vehicle_ids:
ids = [int(x) for x in vehicle_ids.split(',') if x.strip().isdigit()]
if ids:
where_clauses.append("v.id = ANY(%s)")
params.append(ids)
where = " AND ".join(where_clauses)
cur.execute(f"""
SELECT s.id, s.vehicle_id, s.maintenance_type, s.interval_km,
s.interval_months, s.last_done_at, s.last_done_km,
s.next_due_at, s.next_due_km, s.notes,
v.plate, v.make, v.model, v.current_mileage, v.color
FROM fleet_maintenance_schedules s
JOIN fleet_vehicles v ON v.id = s.vehicle_id
WHERE {where}
ORDER BY v.plate, s.next_due_at NULLS LAST, s.next_due_km NULLS LAST
""", params)
schedules = []
for r in cur.fetchall():
schedules.append({
'id': r[0], 'vehicle_id': r[1], 'maintenance_type': r[2],
'interval_km': r[3], 'interval_months': r[4],
'last_done_at': str(r[5]) if r[5] else None, 'last_done_km': r[6],
'next_due_at': str(r[7]) if r[7] else None, 'next_due_km': r[8],
'notes': r[9],
'vehicle': {
'plate': r[10], 'make': r[11], 'model': r[12],
'current_mileage': r[13], 'color': r[14]
}
})
cur.close(); conn.close()
return jsonify({'data': schedules})
@fleet_bp.route('/vehicles/history', methods=['GET'])
@require_auth('fleet.view')
def list_all_history():
"""Return recent maintenance logs for all vehicles joined with vehicle data.
Replaces the N+1 pattern of fetching logs per vehicle.
"""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
limit = min(int(request.args.get('limit', 200)), 500)
where_clauses = ["v.is_active = true"]
params = [limit]
if g.branch_id:
where_clauses.append("v.branch_id = %s")
params.append(g.branch_id)
vehicle_ids = request.args.get('vehicle_ids', '').strip()
if vehicle_ids:
ids = [int(x) for x in vehicle_ids.split(',') if x.strip().isdigit()]
if ids:
where_clauses.append("v.id = ANY(%s)")
params.append(ids)
where = " AND ".join(where_clauses)
cur.execute(f"""
SELECT l.id, l.vehicle_id, l.schedule_id, l.maintenance_type,
l.mileage_at, l.cost, l.parts_used, l.notes, l.created_at,
e.name as employee_name,
v.plate, v.make, v.model, v.color
FROM fleet_maintenance_logs l
JOIN fleet_vehicles v ON v.id = l.vehicle_id
LEFT JOIN employees e ON l.employee_id = e.id
WHERE {where}
ORDER BY l.created_at DESC
LIMIT %s
""", params)
logs = []
for r in cur.fetchall():
logs.append({
'id': r[0], 'vehicle_id': r[1], 'schedule_id': r[2],
'maintenance_type': r[3], 'mileage_at': r[4],
'cost': float(r[5]) if r[5] else 0, 'parts_used': r[6],
'notes': r[7], 'created_at': str(r[8]) if r[8] else None,
'employee_name': r[9],
'vehicle': {'plate': r[10], 'make': r[11], 'model': r[12], 'color': r[13]}
})
cur.close(); conn.close()
return jsonify({'data': logs})
@fleet_bp.route('/vehicles', methods=['POST']) @fleet_bp.route('/vehicles', methods=['POST'])
@require_auth('fleet.create') @require_auth()
def create_vehicle(): def create_vehicle():
"""Create a fleet vehicle. """Create a fleet vehicle.
Body: {customer_id, plate, make, model, year, current_mileage, fuel_type, color, owner_name, notes} Body: {plate, vin, make, model, year, current_mileage, fuel_type, color, owner_name, notes}
Brand and model are required; plate is optional; VIN is not used.
""" """
data = request.get_json() or {} data = request.get_json() or {}
if not data.get('make') or not data.get('model'): if not data.get('plate') and not data.get('vin'):
return jsonify({'error': 'Marca y modelo son obligatorios'}), 400 return jsonify({'error': 'plate or vin is required'}), 400
if not data.get('customer_id'):
return jsonify({'error': 'El vehiculo debe estar asignado a un cliente'}), 400
branch_id = data.get('branch_id', g.branch_id) branch_id = data.get('branch_id', g.branch_id)
@@ -284,23 +172,17 @@ def create_vehicle():
cur = conn.cursor() cur = conn.cursor()
try: try:
cur.execute("SELECT name FROM customers WHERE id = %s", (data['customer_id'],))
cust = cur.fetchone()
if not cust:
cur.close(); conn.close()
return jsonify({'error': 'Cliente no encontrado'}), 404
cur.execute(""" cur.execute("""
INSERT INTO fleet_vehicles INSERT INTO fleet_vehicles
(branch_id, customer_id, plate, make, model, year, (branch_id, plate, vin, make, model, year,
current_mileage, fuel_type, color, owner_name, notes) current_mileage, fuel_type, color, owner_name, notes)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
RETURNING id RETURNING id
""", ( """, (
branch_id, data['customer_id'], data.get('plate'), branch_id, data.get('plate'), data.get('vin'),
data.get('make'), data.get('model'), data.get('year'), data.get('make'), data.get('model'), data.get('year'),
data.get('current_mileage', 0), data.get('fuel_type', 'gasolina'), data.get('current_mileage', 0), data.get('fuel_type', 'gasolina'),
data.get('color'), data.get('owner_name') or cust[0], data.get('notes'), data.get('color'), data.get('owner_name'), data.get('notes'),
)) ))
vehicle_id = cur.fetchone()[0] vehicle_id = cur.fetchone()[0]
@@ -319,7 +201,7 @@ def create_vehicle():
@fleet_bp.route('/vehicles/<int:vehicle_id>', methods=['PUT']) @fleet_bp.route('/vehicles/<int:vehicle_id>', methods=['PUT'])
@require_auth('fleet.edit') @require_auth()
def update_vehicle(vehicle_id): def update_vehicle(vehicle_id):
"""Update vehicle fields including mileage. """Update vehicle fields including mileage.
@@ -363,7 +245,7 @@ def update_vehicle(vehicle_id):
@fleet_bp.route('/vehicles/<int:vehicle_id>', methods=['DELETE']) @fleet_bp.route('/vehicles/<int:vehicle_id>', methods=['DELETE'])
@require_auth('fleet.delete') @require_auth()
def deactivate_vehicle(vehicle_id): def deactivate_vehicle(vehicle_id):
"""Soft-delete: set is_active = false.""" """Soft-delete: set is_active = false."""
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
@@ -384,7 +266,7 @@ def deactivate_vehicle(vehicle_id):
# ─── Maintenance Schedules ───────────────────────────── # ─── Maintenance Schedules ─────────────────────────────
@fleet_bp.route('/vehicles/<int:vehicle_id>/schedules', methods=['GET']) @fleet_bp.route('/vehicles/<int:vehicle_id>/schedules', methods=['GET'])
@require_auth('fleet.view') @require_auth()
def list_schedules(vehicle_id): def list_schedules(vehicle_id):
"""Maintenance schedules for a vehicle.""" """Maintenance schedules for a vehicle."""
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
@@ -417,7 +299,7 @@ def list_schedules(vehicle_id):
@fleet_bp.route('/vehicles/<int:vehicle_id>/schedules', methods=['POST']) @fleet_bp.route('/vehicles/<int:vehicle_id>/schedules', methods=['POST'])
@require_auth('fleet.create') @require_auth()
def create_schedule(vehicle_id): def create_schedule(vehicle_id):
"""Create maintenance schedule for a vehicle. """Create maintenance schedule for a vehicle.
@@ -462,7 +344,7 @@ def create_schedule(vehicle_id):
# ─── Maintenance Logs ───────────────────────────── # ─── Maintenance Logs ─────────────────────────────
@fleet_bp.route('/vehicles/<int:vehicle_id>/log', methods=['POST']) @fleet_bp.route('/vehicles/<int:vehicle_id>/log', methods=['POST'])
@require_auth('fleet.create') @require_auth()
def record_maintenance(vehicle_id): def record_maintenance(vehicle_id):
"""Record maintenance done. Updates schedule next_due if schedule_id provided. """Record maintenance done. Updates schedule next_due if schedule_id provided.
@@ -545,7 +427,7 @@ def record_maintenance(vehicle_id):
# ─── Alerts ───────────────────────────── # ─── Alerts ─────────────────────────────
@fleet_bp.route('/alerts', methods=['GET']) @fleet_bp.route('/alerts', methods=['GET'])
@require_auth('fleet.view') @require_auth()
def fleet_alerts(): def fleet_alerts():
"""Vehicles with overdue maintenance (next_due_at < NOW() or next_due_km < current_mileage).""" """Vehicles with overdue maintenance (next_due_at < NOW() or next_due_km < current_mileage)."""
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
@@ -590,7 +472,7 @@ def fleet_alerts():
# ─── Stats ───────────────────────────── # ─── Stats ─────────────────────────────
@fleet_bp.route('/stats', methods=['GET']) @fleet_bp.route('/stats', methods=['GET'])
@require_auth('fleet.view') @require_auth()
def fleet_stats(): def fleet_stats():
"""Fleet summary: total vehicles, overdue count, upcoming this month, total cost this month.""" """Fleet summary: total vehicles, overdue count, upcoming this month, total cost this month."""
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)

View File

@@ -7,7 +7,7 @@ import subprocess
import socket import socket
from flask import Blueprint, request, jsonify from flask import Blueprint, request, jsonify
from config import INTERNAL_API_KEY from config import INTERNAL_API_KEY
from tenant_db import get_master_conn, get_tenant_conn, get_tenant_conn_by_dbname from tenant_db import get_master_conn, get_tenant_conn
internal_bp = Blueprint('internal', __name__, url_prefix='/pos/api/internal') internal_bp = Blueprint('internal', __name__, url_prefix='/pos/api/internal')

View File

@@ -6,13 +6,9 @@ import json
import os import os
import csv import csv
from datetime import datetime, timedelta from datetime import datetime, timedelta
from flask import Blueprint, request, jsonify, g, Response from flask import Blueprint, request, jsonify, g
from middleware import require_auth, has_permission from middleware import require_auth, has_permission
from tenant_db import get_tenant_conn from tenant_db import get_tenant_conn
from services.reorder_engine import (
generate_alerts, list_alerts, acknowledge_alert, resolve_alert,
suggest_po_from_alerts,
)
from services.inventory_engine import ( from services.inventory_engine import (
get_stock, get_stock_bulk, record_purchase, record_return, get_stock, get_stock_bulk, record_purchase, record_return,
record_adjustment, record_transfer, record_initial, record_adjustment, record_transfer, record_initial,
@@ -69,275 +65,14 @@ def _to_int(val, default=0):
return default return default
# ─── Vehicle resolution (tablas de vehículos viven en la MAESTRA) ──────────
_MYE_LOOKUP_CACHE = {}
_MYE_TABLES_MISSING = {'flag': False}
def _resolve_mye_master(make, model, year):
"""Resuelve model_year_engine_id contra la BD maestra; None si no existe.
`brands`/`models`/`years`/`model_year_engine` sólo existen en la maestra:
consultarlas en la conexión del tenant lanzaba UndefinedTable y devolvía
500 en todo el bulk-import cuando el CSV traía make/model/year.
"""
key = (make.strip().lower(), model.strip().lower(), int(year))
if key in _MYE_LOOKUP_CACHE:
return _MYE_LOOKUP_CACHE[key]
if _MYE_TABLES_MISSING['flag']:
return None
result = None
try:
mconn = get_master_conn()
try:
mcur = mconn.cursor()
try:
mcur.execute("""
SELECT mye.id_mye FROM model_year_engine mye
JOIN models m ON m.id_model = mye.model_id
JOIN brands b ON b.id_brand = m.brand_id
JOIN years y ON y.id_year = mye.year_id
WHERE LOWER(b.name_brand) = LOWER(%s)
AND LOWER(m.name_model) = LOWER(%s)
AND y.year_car = %s
LIMIT 1
""", (make, model, year))
row = mcur.fetchone()
result = row[0] if row else None
finally:
mcur.close()
finally:
mconn.close()
except Exception as e:
try:
from psycopg2.errors import UndefinedTable, UndefinedColumn
missing = isinstance(e, (UndefinedTable, UndefinedColumn))
except Exception:
missing = 'does not exist' in str(e)
if missing:
# Esquema ausente: no se reintenta; la compat se guarda como texto.
_MYE_TABLES_MISSING['flag'] = True
return None
_MYE_LOOKUP_CACHE[key] = result
return result
# ─── AI Classification ─────────────────────────── # ─── AI Classification ───────────────────────────
@inventory_bp.route('/classify/<part_number>', methods=['GET', 'POST']) @inventory_bp.route('/classify/<part_number>', methods=['GET'])
@require_auth('inventory.create') @require_auth('inventory.create')
def classify_part_endpoint(part_number): def classify_part_endpoint(part_number):
"""Ask AI to identify a part by its OEM number and persist its category. """Ask AI to identify a part by its OEM number."""
In addition to the raw LLM result ({name, brand, vehicle, category}) this
now resolves the returned category to a tenant ``categories.id`` and stores
it in ``inventory.category_id`` when the part exists in the tenant catalog.
"""
from services.ai_chat import classify_part from services.ai_chat import classify_part
from services.product_categorizer import CategoryResolver, map_llm_category
result = classify_part(part_number) result = classify_part(part_number)
category_id = None
category_name = None
persisted = 0
raw_cat = (result or {}).get('category') if isinstance(result, dict) else None
if raw_cat:
canonical = map_llm_category(raw_cat)
if canonical:
conn = get_tenant_conn(g.tenant_id)
try:
resolver = CategoryResolver(conn, create=True)
category_id, category_name = resolver.resolve(canonical)
if category_id is not None:
cur = conn.cursor()
cur.execute(
"UPDATE inventory SET category_id = %s "
"WHERE part_number = %s AND is_active = true "
"AND (category_id IS NULL OR category_id <> %s)",
(category_id, part_number, category_id),
)
persisted = cur.rowcount
conn.commit()
cur.close()
except Exception:
try:
conn.rollback()
except Exception:
pass
finally:
conn.close()
if isinstance(result, dict):
result['category_id'] = category_id
result['category_name'] = category_name
result['persisted'] = persisted
return jsonify(result)
@inventory_bp.route('/categorize', methods=['POST'])
@require_auth('inventory.edit')
def categorize_inventory_endpoint():
"""Auto-categorize inventory items and persist ``category_id``.
Body options:
{"items": [{"part_number", "name", "brand"}, ...]} # explicit list
{"all": true, "limit": N, "only_uncategorized": true} # batch over catalog
Optional: use_llm (LLM fallback per item), create_categories (default true).
"""
from services.product_categorizer import categorize_items, categorize_inventory
data = request.get_json(silent=True) or {}
use_llm = bool(data.get('use_llm'))
create_categories = bool(data.get('create_categories', True))
conn = get_tenant_conn(g.tenant_id)
try:
if data.get('all'):
limit = min(int(data.get('limit', 200) or 200), 5000)
summary = categorize_inventory(
conn, limit=limit,
only_uncategorized=bool(data.get('only_uncategorized', True)),
use_llm=use_llm, persist=True,
create_categories=create_categories,
offset=int(data.get('offset', 0) or 0),
after_id=data.get('after_id'),
)
return jsonify({'mode': 'all', **summary})
items = data.get('items')
if not items or not isinstance(items, list):
return jsonify({'error': 'items[] o all:true requerido'}), 400
summary = categorize_items(
conn, items[:2000], use_llm=use_llm, persist=True,
create_categories=create_categories,
)
return jsonify({'mode': 'items', **summary})
except Exception as e:
try:
conn.rollback()
except Exception:
pass
return jsonify({'error': str(e)}), 500
finally:
conn.close()
@inventory_bp.route('/match-catalog', methods=['POST'])
@require_auth('inventory.edit')
def match_catalog_endpoint():
"""Match inventory SKUs against the supplier catalog.
Populates ``inventory.catalog_part_id`` and registers matched supplier SKUs
as ``inventory_sku_aliases``. Body options: limit, offset, only_unmatched,
create_aliases, use_names, name_threshold, apply(false=dry run), after_id.
"""
from services.sku_matcher import match_inventory, count_links
data = request.get_json(silent=True) or {}
limit = data.get('limit')
if limit is not None:
limit = min(int(limit), 50000)
tenant_conn = get_tenant_conn(g.tenant_id)
master_conn = get_master_conn()
try:
before = count_links(tenant_conn)
summary = match_inventory(
tenant_conn, master_conn, limit=limit,
offset=int(data.get('offset', 0) or 0),
only_unmatched=bool(data.get('only_unmatched', True)),
create_aliases=bool(data.get('create_aliases', True)),
use_names=bool(data.get('use_names', True)),
name_threshold=float(data.get('name_threshold', 0.82)),
apply=bool(data.get('apply', True)),
supplier=data.get('supplier'),
after_id=data.get('after_id'),
)
after = count_links(tenant_conn)
summary['before'] = {'total': before[0], 'linked': before[1]}
summary['after'] = {'total': after[0], 'linked': after[1]}
return jsonify(summary)
except Exception as e:
try:
tenant_conn.rollback()
except Exception:
pass
return jsonify({'error': str(e)}), 500
finally:
tenant_conn.close()
master_conn.close()
@inventory_bp.route('/supplier-invoice/parse', methods=['POST'])
@require_auth('inventory.create')
def supplier_invoice_parse_endpoint():
"""OCR/parse a supplier invoice (image or PDF) into structured lines.
Accepts multipart ``file`` or JSON ``{file_base64, filename}``. Returns the
detected lines plus an inventory/catalog match for each part number. Does
NOT create stock — the front-end confirms before a purchase is recorded.
"""
import base64 as _b64
from services.invoice_ocr import parse_invoice
data = None
filename = None
content_type = None
f = request.files.get('file')
if f:
data = f.read()
filename = f.filename
content_type = f.content_type
else:
body = request.get_json(silent=True) or {}
raw = body.get('file_base64')
if raw:
if raw.startswith('data:'):
raw = raw.split(',', 1)[1]
try:
data = _b64.b64decode(raw)
except Exception:
return jsonify({'error': 'file_base64 inválido'}), 400
filename = body.get('filename')
if not data:
return jsonify({'error': 'file (multipart) o file_base64 requerido'}), 400
use_vision = request.args.get('vision', '1').lower() not in ('0', 'false', 'no')
result = parse_invoice(data, filename=filename, content_type=content_type,
use_vision=use_vision)
# Enrich each line with the tenant inventory item it points at.
norm = {}
for ln in result.get('lines', []):
pn = (ln.get('part_number') or '').upper().replace(' ', '')
if pn:
norm[pn] = None
if norm:
conn = get_tenant_conn(g.tenant_id)
try:
cur = conn.cursor()
cur.execute(
"SELECT id, UPPER(REPLACE(part_number, ' ', '')) AS npn, name, cost "
"FROM inventory WHERE is_active = true AND "
"UPPER(REPLACE(part_number, ' ', '')) = ANY(%s)",
(list(norm.keys()),),
)
for inv_id, npn, name, cost in cur.fetchall():
norm[npn] = {'inventory_id': inv_id, 'name': name,
'cost': float(cost) if cost is not None else None}
cur.close()
finally:
conn.close()
for ln in result.get('lines', []):
pn = (ln.get('part_number') or '').upper().replace(' ', '')
hit = norm.get(pn)
ln['matched_inventory_id'] = hit['inventory_id'] if hit else None
if hit and not ln.get('name'):
ln['name'] = hit['name']
return jsonify(result) return jsonify(result)
@@ -368,13 +103,8 @@ def list_items():
# branch_id no longer filters inventory rows (shared catalog). # branch_id no longer filters inventory rows (shared catalog).
# It is used only to show per-branch stock. # It is used only to show per-branch stock.
if search: if search:
# Search also matches alternate / alias SKUs. where_clauses.append("(i.part_number ILIKE %s OR i.name ILIKE %s OR i.barcode ILIKE %s)")
where_clauses.append( params.extend([f'%{search}%', f'%{search}%', f'%{search}%'])
"(i.part_number ILIKE %s OR i.name ILIKE %s OR i.barcode ILIKE %s "
"OR EXISTS (SELECT 1 FROM inventory_sku_aliases a "
"WHERE a.inventory_id = i.id AND a.is_active = true AND a.sku ILIKE %s))"
)
params.extend([f'%{search}%', f'%{search}%', f'%{search}%', f'%{search}%'])
if category: if category:
where_clauses.append("i.category_id = %s") where_clauses.append("i.category_id = %s")
params.append(int(category)) params.append(int(category))
@@ -679,119 +409,6 @@ def create_item():
return jsonify({'error': str(e)}), 500 return jsonify({'error': str(e)}), 500
@inventory_bp.route('/export', methods=['GET'])
@require_auth('inventory.view')
def export_inventory():
"""Export the inventory as CSV, compatible with the bulk importer.
Query params:
branch_id: optional. When given, the stock (and location) columns reflect
only that branch, and a branch_id column is appended so the
file can be re-imported into the same branch.
Columns: part_number, sku_secondary, name, brand, category, cost, price,
price_2, price_3, stock, location, description, barcode, unit[, branch_id]
- without branch_id, stock is the total across all branches.
- cost is blank unless the user has the inventory.view_cost permission.
Streamed in chunks to support large catalogs.
"""
include_cost = has_permission('inventory.view_cost')
branch_id = request.args.get('branch_id', type=int)
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
branch_name = None
if branch_id:
cur.execute("SELECT name FROM branches WHERE id = %s", (branch_id,))
brow = cur.fetchone()
if not brow:
cur.close(); conn.close()
return jsonify({'error': f'Branch {branch_id} not found'}), 400
branch_name = brow[0]
cur.execute("""
SELECT i.part_number,
(SELECT a.sku FROM inventory_sku_aliases a
WHERE a.inventory_id = i.id ORDER BY a.id LIMIT 1) AS sku_secondary,
i.name, i.brand, c.name AS category, i.cost,
i.price_1, i.price_2, i.price_3,
COALESCE(ist.stock, 0) AS stock,
COALESCE(ist.location, i.location) AS location,
i.description, i.barcode, i.unit
FROM inventory i
LEFT JOIN categories c ON c.id = i.category_id
LEFT JOIN inventory_stock ist
ON ist.inventory_id = i.id AND ist.branch_id = %s
WHERE i.is_active = true
ORDER BY i.part_number
""", (branch_id,))
else:
cur.execute("""
SELECT i.part_number,
(SELECT a.sku FROM inventory_sku_aliases a
WHERE a.inventory_id = i.id ORDER BY a.id LIMIT 1) AS sku_secondary,
i.name, i.brand, c.name AS category, i.cost,
i.price_1, i.price_2, i.price_3,
COALESCE(st.total_stock, 0) AS stock,
i.location, i.description, i.barcode, i.unit
FROM inventory i
LEFT JOIN categories c ON c.id = i.category_id
LEFT JOIN (
SELECT inventory_id, SUM(stock) AS total_stock
FROM inventory_stock GROUP BY inventory_id
) st ON st.inventory_id = i.id
WHERE i.is_active = true
ORDER BY i.part_number
""")
def generate():
output = io.StringIO()
writer = csv.writer(output)
try:
header = ['part_number', 'sku_secondary', 'name', 'brand',
'category', 'cost', 'price', 'price_2', 'price_3',
'stock', 'location', 'description', 'barcode', 'unit']
if branch_id:
header.append('branch_id')
writer.writerow(header)
# BOM so Excel opens UTF-8 correctly
yield '\ufeff' + output.getvalue()
output.seek(0); output.truncate(0)
while True:
rows = cur.fetchmany(1000)
if not rows:
break
for r in rows:
row = [
r[0] or '', r[1] or '', r[2] or '', r[3] or '', r[4] or '',
f'{float(r[5]):.2f}' if include_cost and r[5] is not None else '',
f'{float(r[6]):.2f}' if r[6] is not None else '0.00',
f'{float(r[7]):.2f}' if r[7] is not None else '',
f'{float(r[8]):.2f}' if r[8] is not None else '',
int(r[9]) if r[9] is not None else 0,
r[10] or '', r[11] or '', r[12] or '', r[13] or '',
]
if branch_id:
row.append(branch_id)
writer.writerow(row)
yield output.getvalue()
output.seek(0); output.truncate(0)
finally:
cur.close()
conn.close()
if branch_id:
safe_branch = ''.join(ch if ch.isalnum() else '_' for ch in (branch_name or str(branch_id)))
filename = f"inventario_{safe_branch}_{datetime.now().strftime('%Y%m%d_%H%M')}.csv"
else:
filename = f"inventario_completo_{datetime.now().strftime('%Y%m%d_%H%M')}.csv"
return Response(
generate(),
mimetype='text/csv; charset=utf-8',
headers={'Content-Disposition': f'attachment; filename="{filename}"'},
)
@inventory_bp.route('/items/bulk-import', methods=['POST']) @inventory_bp.route('/items/bulk-import', methods=['POST'])
@require_auth('inventory.edit') @require_auth('inventory.edit')
def bulk_import_items(): def bulk_import_items():
@@ -800,15 +417,11 @@ def bulk_import_items():
Expects multipart/form-data with a 'file' (CSV/Excel) or JSON body. Expects multipart/form-data with a 'file' (CSV/Excel) or JSON body.
Headers: Headers:
X-Import-Mode: 'strict' (default) aborts on first error; 'lenient' skips bad rows. X-Import-Mode: 'strict' (default) aborts on first error; 'lenient' skips bad rows.
X-Import-Strategy: 'skip' (default) ignores missing compat and never X-Import-Strategy: 'qwen' (default) auto-generates missing compat via QWEN;
calls QWEN; 'qwen' auto-generates missing compat via 'skip' ignores missing compat; 'reject' requires all compat.
QWEN (opt-in: 20.7M noisy fitment rows so far);
'reject' requires all compat.
X-Import-Stock: 'add' (default) sums stock to existing items;
'skip' only updates data/prices of existing items without touching stock.
Expected CSV columns (case-insensitive): Expected CSV columns (case-insensitive):
sku/part_number, name, brand, price, stock, cost, sku/part_number, name, brand, price, stock, cost,
sku_secondary, description, category, make, model, year, engine, engine_code location, description, category, make, model, year, engine, engine_code
Optional compat columns: make, model, year, engine, engine_code Optional compat columns: make, model, year, engine, engine_code
""" """
from services.qwen_fitment import get_vehicle_fitment from services.qwen_fitment import get_vehicle_fitment
@@ -816,8 +429,7 @@ def bulk_import_items():
import services.inventory_vehicle_compat as ivc_service import services.inventory_vehicle_compat as ivc_service
mode = request.headers.get('X-Import-Mode', 'strict').lower() mode = request.headers.get('X-Import-Mode', 'strict').lower()
strategy = request.headers.get('X-Import-Strategy', 'skip').lower() strategy = request.headers.get('X-Import-Strategy', 'qwen').lower()
stock_mode = request.headers.get('X-Import-Stock', 'add').lower()
errors = [] errors = []
warnings = [] warnings = []
created_ids = [] created_ids = []
@@ -833,17 +445,8 @@ def bulk_import_items():
try: try:
ext = os.path.splitext(file.filename)[1].lower() ext = os.path.splitext(file.filename)[1].lower()
if ext == '.csv': if ext == '.csv':
raw = file.stream.read() stream = io.TextIOWrapper(file.stream, encoding='utf-8-sig')
decoded = None reader = csv.DictReader(stream)
for enc in ('utf-8-sig', 'cp1252', 'latin-1'):
try:
decoded = raw.decode(enc)
break
except UnicodeDecodeError:
continue
if decoded is None:
return jsonify({'error': 'Unable to detect CSV encoding. Please save the file as UTF-8.'}), 400
reader = csv.DictReader(io.StringIO(decoded))
rows = list(reader) rows = list(reader)
elif ext in ('.xls', '.xlsx', '.xlsm'): elif ext in ('.xls', '.xlsx', '.xlsm'):
try: try:
@@ -887,7 +490,6 @@ def bulk_import_items():
'marca': 'brand', 'precio': 'price', 'costo': 'cost', 'marca': 'brand', 'precio': 'price', 'costo': 'cost',
'cantidad': 'stock', 'existencia': 'stock', 'inventario': 'stock', 'cantidad': 'stock', 'existencia': 'stock', 'inventario': 'stock',
'ubicacion': 'location', 'categoria': 'category', 'ubicacion': 'location', 'categoria': 'category',
'sku_secundario': 'sku_secondary', 'sku_alt': 'sku_secondary', 'sku_alternativo': 'sku_secondary',
'fabricante': 'make', 'vehiculo': 'make', 'auto': 'make', 'fabricante': 'make', 'vehiculo': 'make', 'auto': 'make',
'modelo': 'model', 'anio': 'year', 'ano': 'year', 'modelo': 'model', 'anio': 'year', 'ano': 'year',
'motor': 'engine', 'codigo_motor': 'engine_code', 'motor': 'engine', 'codigo_motor': 'engine_code',
@@ -918,10 +520,6 @@ def bulk_import_items():
db_name = db_name_row[0] if db_name_row else None db_name = db_name_row[0] if db_name_row else None
mcur.close(); mconn.close() mcur.close(); mconn.close()
# Pre-fetch category name -> id mapping
cur.execute("SELECT id, name FROM categories")
category_map = {str(r[1]).strip().lower(): r[0] for r in cur.fetchall()}
for row_num, row in enumerate(rows, start=1): for row_num, row in enumerate(rows, start=1):
part_number = str(row.get('part_number', '')).strip() part_number = str(row.get('part_number', '')).strip()
name = str(row.get('name', '')).strip() name = str(row.get('name', '')).strip()
@@ -950,10 +548,7 @@ def bulk_import_items():
cost = _to_decimal(row.get('cost'), 0) cost = _to_decimal(row.get('cost'), 0)
location = str(row.get('location', '')).strip() location = str(row.get('location', '')).strip()
description = str(row.get('description', '')).strip() description = str(row.get('description', '')).strip()
category_name = str(row.get('category', '')).strip() category = str(row.get('category', '')).strip()
category_id = category_map.get(category_name.lower()) if category_name else None
if category_name and category_id is None:
warnings.append(f'Row {row_num}: categoria "{category_name}" no encontrada')
# Check if item already exists (catalog is shared across branches) # Check if item already exists (catalog is shared across branches)
cur.execute("SELECT id FROM inventory WHERE part_number = %s", (part_number,)) cur.execute("SELECT id FROM inventory WHERE part_number = %s", (part_number,))
@@ -971,14 +566,14 @@ def bulk_import_items():
price_1 = CASE WHEN %s > 0 THEN %s ELSE price_1 END, price_1 = CASE WHEN %s > 0 THEN %s ELSE price_1 END,
location = COALESCE(NULLIF(%s,''), location), location = COALESCE(NULLIF(%s,''), location),
description = COALESCE(NULLIF(%s,''), description), description = COALESCE(NULLIF(%s,''), description),
category_id = COALESCE(%s, category_id) category = COALESCE(NULLIF(%s,''), category)
WHERE id = %s WHERE id = %s
""", """,
(name, brand, cost, cost, price_1, price_1, location, description, category_id, item_id) (name, brand, cost, cost, price_1, price_1, location, description, category, item_id)
) )
was_inserted = False was_inserted = False
# Record stock adjustment for existing item if stock > 0 (unless skipped) # Record stock adjustment for existing item if stock > 0
if stock > 0 and stock_mode != 'skip': if stock > 0:
record_initial(conn, item_id, branch_id, stock, cost if cost > 0 else None) record_initial(conn, item_id, branch_id, stock, cost if cost > 0 else None)
else: else:
# Generate barcode for new item # Generate barcode for new item
@@ -987,11 +582,11 @@ def bulk_import_items():
cur.execute( cur.execute(
""" """
INSERT INTO inventory INSERT INTO inventory
(part_number, barcode, name, brand, cost, price_1, location, description, category_id, unit) (part_number, barcode, name, brand, cost, price_1, location, description, category, unit)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id RETURNING id
""", """,
(part_number, barcode, name, brand, cost, price_1, location, description, category_id, 'PZA') (part_number, barcode, name, brand, cost, price_1, location, description, category, 'PZA')
) )
item_id = cur.fetchone()[0] item_id = cur.fetchone()[0]
was_inserted = True was_inserted = True
@@ -1004,20 +599,7 @@ def bulk_import_items():
created_ids.append(item_id) created_ids.append(item_id)
created += 1 created += 1
# ---------- 2. Secondary SKU alias ---------- # ---------- 2. Vehicle compatibility ----------
sku_secondary = str(row.get('sku_secondary', '')).strip()
if sku_secondary:
cur.execute(
"""
INSERT INTO inventory_sku_aliases (inventory_id, sku, label)
VALUES (%s, %s, %s)
ON CONFLICT DO NOTHING
""",
(item_id, sku_secondary, 'Secundario')
)
conn.commit()
# ---------- 3. Vehicle compatibility ----------
make = str(row.get('make', '')).strip() make = str(row.get('make', '')).strip()
model = str(row.get('model', '')).strip() model = str(row.get('model', '')).strip()
year_str = str(row.get('year', '')).strip() year_str = str(row.get('year', '')).strip()
@@ -1031,9 +613,24 @@ def bulk_import_items():
year = _to_int(year_str, None) year = _to_int(year_str, None)
mye_id = None mye_id = None
if make and model and year: if make and model and year:
# Resolver contra la BD maestra (no la del tenant). # Try exact match against model_year_engine
mye_id = _resolve_mye_master(make, model, year) cur.execute(
if mye_id is None: """
SELECT mye.id FROM model_year_engine mye
JOIN models m ON m.id = mye.model_id
JOIN brands b ON b.id = m.brand_id
JOIN years y ON y.id = mye.year_id
WHERE LOWER(b.name) = LOWER(%s)
AND LOWER(m.name) = LOWER(%s)
AND y.year = %s
LIMIT 1
""",
(make, model, year)
)
r = cur.fetchone()
if r:
mye_id = r[0]
else:
warnings.append( warnings.append(
f'Row {row_num}: vehicle "{make} {model} {year}" not found in catalog; ' f'Row {row_num}: vehicle "{make} {model} {year}" not found in catalog; '
'saving as text-only compatibility.' 'saving as text-only compatibility.'
@@ -1446,251 +1043,6 @@ def api_adjustment():
return jsonify({'error': str(e)}), 400 return jsonify({'error': str(e)}), 400
@inventory_bp.route('/items/lookup', methods=['GET'])
@require_auth('inventory.view')
def lookup_item_by_code():
"""Exact lookup by barcode / part_number / SKU alias (for scanner flows)."""
code = (request.args.get('code') or '').strip()
if not code:
return jsonify({'error': 'code requerido'}), 400
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
norm = code.upper().replace(' ', '')
cur.execute("""
SELECT i.id, i.part_number, i.name, i.barcode,
COALESCE(s.stock, 0) AS stock, i.price_1
FROM inventory i
LEFT JOIN (
SELECT inventory_id, SUM(stock) AS stock FROM inventory_stock
GROUP BY inventory_id
) s ON s.inventory_id = i.id
WHERE i.is_active = true AND (
UPPER(i.barcode) = %s
OR UPPER(REPLACE(i.part_number, ' ', '')) = %s
OR i.id IN (
SELECT a.inventory_id FROM inventory_sku_aliases a
WHERE UPPER(REPLACE(a.sku, ' ', '')) = %s))
LIMIT 1
""", (code, norm, norm))
row = cur.fetchone()
cur.close()
conn.close()
if not row:
return jsonify({'found': False}), 404
return jsonify({'found': True, 'item': {
'id': row[0], 'part_number': row[1], 'name': row[2],
'barcode': row[3], 'stock': int(row[4] or 0),
'price_1': float(row[5] or 0),
}})
# ─── Transfer documents (in-transit tracking) ──────────────────────────────
@inventory_bp.route('/transfer-docs', methods=['GET'])
@require_auth('inventory.view')
def list_transfer_docs():
"""List transfer documents. Query: status, page, per_page."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
status = request.args.get('status')
where = "WHERE t.status = %s" if status else ""
params = [status] if status else []
cur.execute(f"""
SELECT t.id, t.from_branch_id, b1.name, t.to_branch_id, b2.name,
t.status, t.notes, t.created_at, t.sent_at, t.received_at,
COALESCE(SUM(l.quantity), 0) AS total_qty
FROM inventory_transfers t
JOIN branches b1 ON b1.id = t.from_branch_id
JOIN branches b2 ON b2.id = t.to_branch_id
LEFT JOIN inventory_transfer_lines l ON l.transfer_id = t.id
{where}
GROUP BY t.id, b1.name, b2.name
ORDER BY t.id DESC
LIMIT 100
""", params)
docs = [{
'id': r[0], 'from_branch_id': r[1], 'from_name': r[2],
'to_branch_id': r[3], 'to_name': r[4], 'status': r[5],
'notes': r[6], 'created_at': str(r[7]), 'sent_at': str(r[8]),
'received_at': str(r[9]), 'total_qty': int(r[10] or 0),
} for r in cur.fetchall()]
cur.close()
conn.close()
return jsonify({'data': docs})
@inventory_bp.route('/transfer-docs', methods=['POST'])
@require_auth('inventory.transfer')
def create_transfer_doc():
"""Create a draft transfer document.
Body: { to_branch_id, items: [{inventory_id, quantity}], notes }
"""
data = request.get_json() or {}
to_branch = data.get('to_branch_id')
items = data.get('items', [])
if not to_branch or not items:
return jsonify({'error': 'to_branch_id e items son requeridos'}), 400
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
from_branch = data.get('from_branch_id') or g.branch_id
if not from_branch:
cur.execute("SELECT id FROM branches ORDER BY id LIMIT 1")
from_branch = cur.fetchone()[0]
if int(to_branch) == int(from_branch):
cur.close(); conn.close()
return jsonify({'error': 'Origen y destino no pueden ser la misma sucursal'}), 400
cur.execute("""
INSERT INTO inventory_transfers (from_branch_id, to_branch_id, status, notes, employee_id)
VALUES (%s, %s, 'draft', %s, %s) RETURNING id
""", (from_branch, to_branch, data.get('notes'), getattr(g, 'employee_id', None)))
doc_id = cur.fetchone()[0]
for it in items:
qty = int(it.get('quantity', 0) or 0)
if qty <= 0 or not it.get('inventory_id'):
continue
cur.execute("""
INSERT INTO inventory_transfer_lines (transfer_id, inventory_id, quantity)
VALUES (%s, %s, %s)
""", (doc_id, it['inventory_id'], qty))
conn.commit()
cur.close()
conn.close()
return jsonify({'id': doc_id, 'status': 'draft'}), 201
@inventory_bp.route('/transfer-docs/<int:doc_id>', methods=['GET'])
@require_auth('inventory.view')
def get_transfer_doc(doc_id):
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("""
SELECT t.id, t.from_branch_id, b1.name, t.to_branch_id, b2.name, t.status,
t.notes, t.created_at, t.sent_at, t.received_at
FROM inventory_transfers t
JOIN branches b1 ON b1.id = t.from_branch_id
JOIN branches b2 ON b2.id = t.to_branch_id
WHERE t.id = %s
""", (doc_id,))
row = cur.fetchone()
if not row:
cur.close(); conn.close()
return jsonify({'error': 'not found'}), 404
doc = {
'id': row[0], 'from_branch_id': row[1], 'from_name': row[2],
'to_branch_id': row[3], 'to_name': row[4], 'status': row[5],
'notes': row[6], 'created_at': str(row[7]), 'sent_at': str(row[8]),
'received_at': str(row[9]), 'lines': [],
}
cur.execute("""
SELECT l.id, l.inventory_id, i.part_number, i.name, l.quantity,
COALESCE(l.qty_received, 0), COALESCE(s.stock, 0)
FROM inventory_transfer_lines l
JOIN inventory i ON i.id = l.inventory_id
LEFT JOIN (
SELECT inventory_id, SUM(stock) AS stock FROM inventory_stock
WHERE branch_id = %s GROUP BY inventory_id
) s ON s.inventory_id = l.inventory_id
WHERE l.transfer_id = %s ORDER BY l.id
""", (doc['to_branch_id'], doc_id))
doc['lines'] = [{
'id': r[0], 'inventory_id': r[1], 'part_number': r[2], 'name': r[3],
'quantity': int(r[4]), 'qty_received': int(r[5] or 0), 'dest_stock': int(r[6] or 0),
} for r in cur.fetchall()]
cur.close()
conn.close()
return jsonify(doc)
@inventory_bp.route('/transfer-docs/<int:doc_id>/send', methods=['POST'])
@require_auth('inventory.transfer')
def send_transfer_doc(doc_id):
"""Mark draft as sent (goods leave origin: stock -qty in origin branch)."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("SELECT from_branch_id, to_branch_id, status FROM inventory_transfers WHERE id = %s", (doc_id,))
row = cur.fetchone()
if not row:
cur.close(); conn.close()
return jsonify({'error': 'not found'}), 404
if row[2] != 'draft':
cur.close(); conn.close()
return jsonify({'error': f'Solo borradores pueden enviarse (estado: {row[2]})'}), 400
cur.execute("SELECT inventory_id, quantity FROM inventory_transfer_lines WHERE transfer_id = %s", (doc_id,))
lines = cur.fetchall()
for inv_id, qty in lines:
record_transfer(conn, inv_id, row[0], row[1], qty,
notes=f'Transferencia #{doc_id} enviada')
cur.execute("""
UPDATE inventory_transfers SET status = 'sent', sent_at = NOW() WHERE id = %s
""", (doc_id,))
conn.commit()
cur.close()
conn.close()
return jsonify({'ok': True, 'status': 'sent'})
@inventory_bp.route('/transfer-docs/<int:doc_id>/receive', methods=['POST'])
@require_auth('inventory.transfer')
def receive_transfer_doc(doc_id):
"""Confirm receipt at destination. Adjusts destination stock to the
received quantities (discrepancies become adjustments)."""
data = request.get_json() or {}
received = {int(k): int(v) for k, v in (data.get('received') or {}).items()}
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("SELECT to_branch_id, status FROM inventory_transfers WHERE id = %s", (doc_id,))
row = cur.fetchone()
if not row:
cur.close(); conn.close()
return jsonify({'error': 'not found'}), 404
if row[1] != 'sent':
cur.close(); conn.close()
return jsonify({'error': f'Solo transferencias enviadas pueden recibirse (estado: {row[1]})'}), 400
to_branch = row[0]
cur.execute("""
SELECT l.id, l.inventory_id, l.quantity FROM inventory_transfer_lines l
WHERE l.transfer_id = %s
""", (doc_id,))
lines = cur.fetchall()
differences = 0
for line_id, inv_id, qty in lines:
rec = received.get(line_id)
qty_rec = rec if rec is not None else qty
cur.execute("UPDATE inventory_transfer_lines SET qty_received = %s WHERE id = %s",
(qty_rec, line_id))
if qty_rec != qty:
differences += 1
record_adjustment(conn, inv_id, to_branch, qty_rec - qty,
reason=f'Diferencia en recepción de transferencia #{doc_id}')
cur.execute("""
UPDATE inventory_transfers SET status = 'received', received_at = NOW() WHERE id = %s
""", (doc_id,))
conn.commit()
cur.close()
conn.close()
return jsonify({'ok': True, 'status': 'received', 'differences': differences})
@inventory_bp.route('/transfer-docs/<int:doc_id>/cancel', methods=['POST'])
@require_auth('inventory.transfer')
def cancel_transfer_doc(doc_id):
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("UPDATE inventory_transfers SET status = 'cancelled' WHERE id = %s AND status = 'draft'",
(doc_id,))
conn.commit()
cur.close()
conn.close()
return jsonify({'ok': True})
@inventory_bp.route('/transfer', methods=['POST']) @inventory_bp.route('/transfer', methods=['POST'])
@require_auth('inventory.transfer') @require_auth('inventory.transfer')
def api_transfer(): def api_transfer():
@@ -2919,22 +2271,6 @@ def list_inventory_subcategories(category_id):
conn.close() conn.close()
@inventory_bp.route('/categories/all', methods=['GET'])
@require_auth()
def list_all_inventory_categories():
"""Return all active categories (flat, with parent_id) for selectors."""
conn = get_tenant_conn(g.tenant_id)
try:
cur = conn.cursor()
cur.execute(
"SELECT id, name, parent_id FROM categories WHERE is_active = true ORDER BY name"
)
rows = cur.fetchall()
return jsonify({'categories': [{'id': r[0], 'name': r[1], 'parent_id': r[2]} for r in rows]})
finally:
conn.close()
# ─── Global Tier Discounts ─────────────────────── # ─── Global Tier Discounts ───────────────────────
@inventory_bp.route('/tier-discounts', methods=['GET']) @inventory_bp.route('/tier-discounts', methods=['GET'])

View File

@@ -12,7 +12,6 @@ from flask import Blueprint, g, jsonify, request
from middleware import require_auth from middleware import require_auth
from services import facturapi_service from services import facturapi_service
from services.audit import log_action from services.audit import log_action
from services.facturapi_service import FacturapiError
from services.cfdi_facturapi_builder import ( from services.cfdi_facturapi_builder import (
build_egreso_payload, build_egreso_payload,
build_ingreso_payload, build_ingreso_payload,
@@ -20,7 +19,6 @@ from services.cfdi_facturapi_builder import (
from services.cfdi_queue import ( from services.cfdi_queue import (
cancel_cfdi, cancel_cfdi,
enqueue_cfdi, enqueue_cfdi,
enqueue_pending_complements,
get_queue_status, get_queue_status,
process_queue, process_queue,
retry_failed, retry_failed,
@@ -45,16 +43,9 @@ def _get_issuer_config(cur, branch_id=None):
result = { result = {
"rfc": config.get("tenant_rfc", ""), "rfc": config.get("tenant_rfc", ""),
"razon_social": config.get("tenant_razon_social", ""), "razon_social": config.get("tenant_razon_social", ""),
"regimen_fiscal": config.get("cfdi_regimen_fiscal") or config.get("tenant_regimen_fiscal", "601"), "regimen_fiscal": config.get("cfdi_regimen_fiscal", "601"),
"cp": config.get("tenant_cp", "00000"), "cp": config.get("tenant_cp", "00000"),
"direccion": config.get("tenant_direccion", ""), "serie": config.get("cfdi_serie", "A"),
"exterior": config.get("tenant_numero_exterior", ""),
"interior": config.get("tenant_numero_interior", ""),
"colonia": config.get("tenant_colonia", ""),
"ciudad": config.get("tenant_ciudad", ""),
"municipio": config.get("tenant_municipio", ""),
"estado": config.get("tenant_estado", ""),
"serie": config.get("cfdi_serie") or config.get("invoice_serie", "A"),
"facturapi_key": config.get("cfdi_facturapi_key", ""), "facturapi_key": config.get("cfdi_facturapi_key", ""),
"facturapi_org_id": config.get("cfdi_facturapi_org_id", ""), "facturapi_org_id": config.get("cfdi_facturapi_org_id", ""),
} }
@@ -63,7 +54,7 @@ def _get_issuer_config(cur, branch_id=None):
if branch_id: if branch_id:
cur.execute( cur.execute(
""" """
SELECT rfc, razon_social, regimen_fiscal, cp, serie_cfdi SELECT rfc, razon_social, regimen_fiscal, codigo_postal, serie_cfdi
FROM branches WHERE id = %s FROM branches WHERE id = %s
""", """,
(branch_id,), (branch_id,),
@@ -85,8 +76,7 @@ def _get_sale_with_items(cur, sale_id):
""" """
SELECT id, branch_id, customer_id, employee_id, sale_type, SELECT id, branch_id, customer_id, employee_id, sale_type,
payment_method, subtotal, discount_total, tax_total, total, payment_method, subtotal, discount_total, tax_total, total,
metodo_pago_sat, forma_pago_sat, status, created_at, metodo_pago_sat, forma_pago_sat, status, created_at
currency, exchange_rate
FROM sales WHERE id = %s FROM sales WHERE id = %s
""", """,
(sale_id,), (sale_id,),
@@ -110,8 +100,6 @@ def _get_sale_with_items(cur, sale_id):
"forma_pago_sat": row[11] or "01", "forma_pago_sat": row[11] or "01",
"status": row[12], "status": row[12],
"created_at": str(row[13]), "created_at": str(row[13]),
"currency": row[14] or "MXN",
"exchange_rate": float(row[15]) if row[15] else None,
} }
cur.execute( cur.execute(
@@ -370,44 +358,6 @@ def trigger_process_queue():
return jsonify({"error": str(e)}), 500 return jsonify({"error": str(e)}), 500
@invoicing_bp.route("/complementos/sync", methods=["POST"])
@require_auth("invoicing.create")
def sync_complementos():
"""Enqueue the missing payment complements (REP) and credit notes (egreso).
Payments on stamped PPD invoices become CFDI type "P" linked to the
original UUID; returns of stamped sales become CFDI type "E". Already
enqueued movements are not duplicated.
Body: {
backfill: bool (default false) — reconcile the whole history,
since: ISO date (optional) — reconcile movements created after it
}
"""
data = request.get_json() or {}
backfill = bool(data.get("backfill"))
since = data.get("since")
if since:
try:
datetime.fromisoformat(str(since))
except ValueError:
return jsonify({"error": "since must be an ISO 8601 date/datetime"}), 400
conn = get_tenant_conn(g.tenant_id)
try:
result = enqueue_pending_complements(conn, since=since, backfill=backfill)
conn.close()
result["enqueued"] = len(result["pagos"]) + len(result["egresos"])
return jsonify(result), 201
except Exception as e:
conn.rollback()
conn.close()
return jsonify({"error": str(e)}), 500
# ─── Cancel CFDI ──────────────────────────────────── # ─── Cancel CFDI ────────────────────────────────────
@@ -535,7 +485,7 @@ def get_sale_pdf(sale_id):
@invoicing_bp.route("/stats", methods=["GET"]) @invoicing_bp.route("/stats", methods=["GET"])
@require_auth("invoicing.view") @require_auth("invoicing.read")
def api_invoicing_stats(): def api_invoicing_stats():
"""Return counts for tab badges: invoices, credit notes, payment complements, cancellations.""" """Return counts for tab badges: invoices, credit notes, payment complements, cancellations."""
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
@@ -717,7 +667,6 @@ def facturapi_setup():
(result["org_id"],), (result["org_id"],),
) )
if result.get("api_key"):
cur.execute( cur.execute(
""" """
INSERT INTO tenant_config (key, value) INSERT INTO tenant_config (key, value)
@@ -733,30 +682,18 @@ def facturapi_setup():
cur.close() cur.close()
conn.close() conn.close()
status_conn = get_tenant_conn(g.tenant_id) return jsonify(
try: {
status_cur = status_conn.cursor() "org_id": result["org_id"],
status = facturapi_service.get_org_status(_get_issuer_config(status_cur)) "message": "Facturapi organization created. Complete pending steps in Facturapi dashboard.",
status["org_id"] = result["org_id"] }
if not result.get("legal_updated"): )
status["error"] = status.get("error") or "Datos fiscales no pudieron configurarse automáticamente. Configúralos en el dashboard de Facturapi."
status_cur.close()
status_conn.close()
except Exception:
status_conn.close()
raise
return jsonify(status)
except ValueError as e: except ValueError as e:
conn.rollback() conn.rollback()
cur.close() cur.close()
conn.close() conn.close()
return jsonify({"error": str(e)}), 400 return jsonify({"error": str(e)}), 400
except FacturapiError as e:
conn.rollback()
cur.close()
conn.close()
return jsonify({"error": str(e)}), 400
except Exception as e: except Exception as e:
conn.rollback() conn.rollback()
cur.close() cur.close()

View File

@@ -10,19 +10,11 @@ Routes:
Listings Listings
GET /pos/api/marketplace-ext/listings GET /pos/api/marketplace-ext/listings
POST /pos/api/marketplace-ext/listings POST /pos/api/marketplace-ext/listings
POST /pos/api/marketplace-ext/listings/import-existing
POST /pos/api/marketplace-ext/listings/backfill-inventory
POST /pos/api/marketplace-ext/listings/<id>/sync POST /pos/api/marketplace-ext/listings/<id>/sync
POST /pos/api/marketplace-ext/listings/<id>/pause POST /pos/api/marketplace-ext/listings/<id>/pause
POST /pos/api/marketplace-ext/listings/<id>/activate POST /pos/api/marketplace-ext/listings/<id>/activate
DELETE /pos/api/marketplace-ext/listings/<id> DELETE /pos/api/marketplace-ext/listings/<id>
Questions
GET /pos/api/marketplace-ext/questions
POST /pos/api/marketplace-ext/questions/sync
POST /pos/api/marketplace-ext/questions/<id>/answer
GET /pos/api/marketplace-ext/questions/<id>/suggestion (AI, A7)
Orders Orders
GET /pos/api/marketplace-ext/orders GET /pos/api/marketplace-ext/orders
GET /pos/api/marketplace-ext/orders/<id> GET /pos/api/marketplace-ext/orders/<id>
@@ -32,8 +24,6 @@ Routes:
POST /pos/api/marketplace-ext/webhook/meli POST /pos/api/marketplace-ext/webhook/meli
""" """
import urllib.parse
from flask import Blueprint, request, jsonify, g from flask import Blueprint, request, jsonify, g
from middleware import require_auth, has_permission from middleware import require_auth, has_permission
from tenant_db import get_tenant_conn, get_master_conn from tenant_db import get_tenant_conn, get_master_conn
@@ -70,84 +60,10 @@ def _require_meli_manage():
return None return None
def _safe_int(value, default):
"""Parse an int from a query param, falling back to a default instead of 500."""
try:
return int(value)
except (TypeError, ValueError):
return default
def _get_meli_config(tenant_id):
"""Return the tenant's ML config dict, or None if the tenant is unknown."""
try:
conn = get_tenant_conn(tenant_id)
except Exception:
return None
try:
return meli_svc.get_meli_config(conn)
except Exception:
return None
finally:
conn.close()
def _resolve_tenant_by_meli_user(meli_user_id):
"""Find the tenant whose tenant_config.meli_user_id matches.
tenant_config lives in each tenant DB (not in master), so we iterate the
active tenants from master and probe each one.
"""
if not meli_user_id:
return None
mconn = get_master_conn()
try:
mcur = mconn.cursor()
mcur.execute("SELECT id FROM tenants WHERE is_active = true ORDER BY id")
tenant_ids = [r[0] for r in mcur.fetchall()]
mcur.close()
except Exception:
return None
finally:
mconn.close()
target = str(meli_user_id)
for tid in tenant_ids:
try:
conn = get_tenant_conn(tid)
except Exception:
continue
try:
cur = conn.cursor()
cur.execute(
"SELECT 1 FROM tenant_config WHERE key = 'meli_user_id' AND value = %s LIMIT 1",
(target,),
)
found = cur.fetchone() is not None
cur.close()
if found:
return tid
except Exception:
continue
finally:
conn.close()
return None
# ═══════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════
# CONFIG # CONFIG
# ═══════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════
def _meli_auth_error(e):
"""Respuesta 401 para MeliAuthError: propaga el detalle accionable
(p. ej. credenciales de app rechazadas) en vez del mensaje genérico."""
detail = str(e)
if ('credenciales' in detail.lower() or 'refresh failed' in detail.lower()
or 'invalid_client' in detail.lower()):
return jsonify({'error': detail}), 401
return jsonify({'error': 'La sesión de MercadoLibre expiró. Reconecta la cuenta en la pestaña de configuración.'}), 401
@marketplace_ext_bp.route("/config", methods=["GET"]) @marketplace_ext_bp.route("/config", methods=["GET"])
@require_auth() @require_auth()
def get_config(): def get_config():
@@ -160,55 +76,11 @@ def get_config():
if k not in ("meli_access_token", "meli_refresh_token", "meli_client_secret") if k not in ("meli_access_token", "meli_refresh_token", "meli_client_secret")
} }
safe["connected"] = bool(cfg.get("meli_access_token")) safe["connected"] = bool(cfg.get("meli_access_token"))
safe["has_refresh_token"] = bool(cfg.get("meli_refresh_token"))
return jsonify(safe) return jsonify(safe)
finally: finally:
conn.close() conn.close()
@marketplace_ext_bp.route("/connect/init", methods=["POST"])
@require_auth()
def init_meli_connect():
"""Store client credentials server-side and return the MercadoLibre auth URL.
The frontend no longer keeps the client_secret in localStorage.
"""
err = _require_meli_manage()
if err:
return err
data = request.get_json() or {}
client_id = data.get("client_id", "").strip()
client_secret = data.get("client_secret", "").strip()
category = data.get("category", "").strip()
shipping = data.get("shipping", "").strip()
if not client_id or not client_secret:
return jsonify({"error": "client_id and client_secret required"}), 400
base = _get_public_base_url().rstrip("/")
redirect_uri = f"{base}/pos/marketplace-external/callback"
conn = get_tenant_conn(g.tenant_id)
try:
meli_svc.save_meli_config(conn, {
"meli_client_id": client_id,
"meli_client_secret": client_secret,
"meli_default_category_id": category,
"meli_shipping_mode": shipping,
})
finally:
conn.close()
auth_url = (
"https://auth.mercadolibre.com.mx/authorization?response_type=code"
f"&client_id={urllib.parse.quote(client_id)}"
f"&redirect_uri={urllib.parse.quote(redirect_uri)}"
"&scope=read+write+offline_access"
)
return jsonify({"auth_url": auth_url, "redirect_uri": redirect_uri})
@marketplace_ext_bp.route("/connect", methods=["POST"]) @marketplace_ext_bp.route("/connect", methods=["POST"])
@require_auth() @require_auth()
def connect_meli(): def connect_meli():
@@ -218,21 +90,12 @@ def connect_meli():
data = request.get_json() or {} data = request.get_json() or {}
code = data.get("code") code = data.get("code")
client_id = data.get("client_id")
client_secret = data.get("client_secret")
redirect_uri = data.get("redirect_uri", "") redirect_uri = data.get("redirect_uri", "")
if not code: if not code or not client_id or not client_secret:
return jsonify({"error": "code required"}), 400 return jsonify({"error": "code, client_id and client_secret required"}), 400
conn = get_tenant_conn(g.tenant_id)
try:
cfg = meli_svc.get_meli_config(conn)
client_id = data.get("client_id") or cfg.get("meli_client_id")
client_secret = data.get("client_secret") or cfg.get("meli_client_secret")
finally:
conn.close()
if not client_id or not client_secret:
return jsonify({"error": "ML credentials not configured"}), 400
try: try:
token_data = MeliService.exchange_code(code, client_id, client_secret, redirect_uri) token_data = MeliService.exchange_code(code, client_id, client_secret, redirect_uri)
@@ -261,17 +124,12 @@ def connect_meli():
"meli_client_id": client_id, "meli_client_id": client_id,
"meli_client_secret": client_secret, "meli_client_secret": client_secret,
}) })
resp = { return jsonify({
"ok": True, "ok": True,
"user_id": user_id or user.get("id"), "user_id": user_id or user.get("id"),
"nickname": user.get("nickname"), "nickname": user.get("nickname"),
"site_id": user.get("site_id"), "site_id": user.get("site_id"),
} })
if not refresh_token:
# The ML app lacks offline_access scope: the connection will die
# in ~6h and cannot be auto-renewed. Warn so it can be fixed.
resp["warning"] = "no_refresh_token"
return jsonify(resp)
finally: finally:
conn.close() conn.close()
@@ -302,13 +160,13 @@ def search_categories():
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
try: try:
cfg = meli_svc.get_meli_config(conn) cfg = meli_svc.get_meli_config(conn)
svc = meli_svc._get_meli_service(cfg, conn) svc = meli_svc._get_meli_service(cfg)
if not svc: if not svc:
return jsonify({"error": "MercadoLibre no está conectado. Conecta la cuenta en la pestaña de configuración."}), 400 return jsonify({"error": "MercadoLibre not connected"}), 400
result = svc.search_categories(site_id, q) result = svc.search_categories(site_id, q)
return jsonify({"categories": result}) return jsonify({"categories": result})
except MeliAuthError as e: except MeliAuthError:
return _meli_auth_error(e) return jsonify({"error": "MercadoLibre token expired. Please reconnect."}), 401
except Exception as e: except Exception as e:
return jsonify({"error": str(e)}), 500 return jsonify({"error": str(e)}), 500
finally: finally:
@@ -322,16 +180,16 @@ def search_categories():
@marketplace_ext_bp.route("/listings", methods=["GET"]) @marketplace_ext_bp.route("/listings", methods=["GET"])
@require_auth() @require_auth()
def list_listings(): def list_listings():
page = max(1, _safe_int(request.args.get("page"), 1)) page = int(request.args.get("page", 1))
per_page = min(max(1, _safe_int(request.args.get("per_page"), 50)), 200) per_page = min(int(request.args.get("per_page", 50)), 200)
status = request.args.get("status") status = request.args.get("status")
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
try: try:
result = meli_svc.get_listings(conn, page=page, per_page=per_page, status=status) result = meli_svc.get_listings(conn, page=page, per_page=per_page, status=status)
return jsonify(result) return jsonify(result)
except MeliAuthError as e: except MeliAuthError:
return _meli_auth_error(e) return jsonify({"error": "MercadoLibre token expired. Please reconnect."}), 401
except Exception as e: except Exception as e:
return jsonify({"error": str(e)}), 500 return jsonify({"error": str(e)}), 500
finally: finally:
@@ -367,7 +225,6 @@ def create_listings():
shipping_mode=shipping_mode, shipping_mode=shipping_mode,
custom_data=custom_data, custom_data=custom_data,
base_url=_get_public_base_url(), base_url=_get_public_base_url(),
use_llm_description=bool(data.get("use_llm_description", False)),
) )
return jsonify(result), 201 return jsonify(result), 201
except ValueError as e: except ValueError as e:
@@ -398,36 +255,6 @@ def import_existing_listings():
conn.close() conn.close()
@marketplace_ext_bp.route("/listings/backfill-inventory", methods=["POST"])
@require_auth()
def backfill_listing_inventory():
"""Map already-imported listings to local inventory (populate inventory_id).
Body: {"dry_run": bool (default false), "limit": int|null,
"only_unmapped": bool (default true)}.
"""
err = _require_meli_manage()
if err:
return err
data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id)
try:
result = meli_svc.backfill_listing_inventory(
conn,
limit=_safe_int(data.get("limit"), None) if data.get("limit") else None,
only_unmapped=bool(data.get("only_unmapped", True)),
dry_run=bool(data.get("dry_run", False)),
)
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"]) @marketplace_ext_bp.route("/inventory-check", methods=["POST"])
@require_auth() @require_auth()
def inventory_check(): def inventory_check():
@@ -457,8 +284,7 @@ def sync_stock_to_meli():
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
try: try:
# Manual run from the UI: bypass the meli_auto_sync_stock_price gate. result = meli_svc.process_meli_sync_queue(conn)
result = meli_svc.process_meli_sync_queue(conn, force=True)
return jsonify(result) return jsonify(result)
except ValueError as e: except ValueError as e:
return jsonify({"error": str(e)}), 400 return jsonify({"error": str(e)}), 400
@@ -475,41 +301,15 @@ def category_attributes(category_id):
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
try: try:
cfg = meli_svc.get_meli_config(conn) cfg = meli_svc.get_meli_config(conn)
svc = meli_svc._get_meli_service(cfg, conn) svc = meli_svc._get_meli_service(cfg)
if not svc: if not svc:
return jsonify({"error": "MercadoLibre no está conectado. Conecta la cuenta en la pestaña de configuración."}), 400 return jsonify({"error": "MercadoLibre not connected"}), 400
attrs = svc.get_category_attributes(category_id) attrs = svc.get_category_attributes(category_id)
# Filter to required attributes only for the UI # Filter to required attributes only for the UI
required = [a for a in attrs if a.get("tags", {}).get("required")] required = [a for a in attrs if a.get("tags", {}).get("required")]
return jsonify({"attributes": required, "all": attrs}) return jsonify({"attributes": required, "all": attrs})
except MeliAuthError as e: except MeliAuthError:
return _meli_auth_error(e) 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("/listing-prices", methods=["GET"])
@require_auth()
def listing_prices():
"""Fee estimator: return MeLi fees per listing type for a price + category."""
price = request.args.get("price", type=float)
category_id = request.args.get("category_id", "")
site_id = request.args.get("site_id", "MLM")
if not price or price <= 0 or not category_id:
return jsonify({"error": "price y category_id son requeridos"}), 400
conn = get_tenant_conn(g.tenant_id)
try:
cfg = meli_svc.get_meli_config(conn)
svc = meli_svc._get_meli_service(cfg, conn)
if not svc:
return jsonify({"error": "MercadoLibre no está conectado. Conecta la cuenta en la pestaña de configuración."}), 400
result = svc.get_listing_prices(site_id, price, category_id)
return jsonify({"listing_prices": result})
except MeliAuthError as e:
return _meli_auth_error(e)
except Exception as e: except Exception as e:
return jsonify({"error": str(e)}), 500 return jsonify({"error": str(e)}), 500
finally: finally:
@@ -546,7 +346,6 @@ def validate_listings():
shipping_mode=shipping_mode, shipping_mode=shipping_mode,
custom_data=custom_data, custom_data=custom_data,
base_url=_get_public_base_url(), base_url=_get_public_base_url(),
use_llm_description=bool(data.get("use_llm_description", False)),
) )
return jsonify(result) return jsonify(result)
except ValueError as e: except ValueError as e:
@@ -587,8 +386,6 @@ def create_listings_async():
shipping_mode=shipping_mode, shipping_mode=shipping_mode,
custom_data=custom_data, custom_data=custom_data,
base_url=_get_public_base_url(), base_url=_get_public_base_url(),
chunk_size=_safe_int(data.get("chunk_size"), 10),
use_llm_description=bool(data.get("use_llm_description", False)),
) )
return jsonify({"task_id": task.id, "status": "queued"}), 202 return jsonify({"task_id": task.id, "status": "queued"}), 202
except Exception as e: except Exception as e:
@@ -601,7 +398,7 @@ def get_async_listing_status(task_id):
"""Get status of an async ML publishing task.""" """Get status of an async ML publishing task."""
try: try:
from celery.result import AsyncResult from celery.result import AsyncResult
from celery_app import celery as celery_app from app import celery as celery_app
result = AsyncResult(task_id, app=celery_app) result = AsyncResult(task_id, app=celery_app)
if result.ready(): if result.ready():
return jsonify({"status": "done", "result": result.result or {}}) return jsonify({"status": "done", "result": result.result or {}})
@@ -765,34 +562,6 @@ def answer_question(question_id):
conn.close() conn.close()
@marketplace_ext_bp.route("/questions/<int:question_id>/suggestion", methods=["GET"])
@require_auth()
def question_suggestion(question_id):
"""AI intent classification + suggested answer for a buyer question.
Query param: ?use_llm=0 to skip the LLM fallback and use templates only.
The answer is only a suggestion; auto-answering is opt-in per tenant via
tenant_config `meli_auto_answer_questions`.
"""
err = _require_meli_manage()
if err:
return err
use_llm = request.args.get("use_llm", "1").lower() not in ("0", "false", "no")
conn = get_tenant_conn(g.tenant_id)
try:
result = meli_svc.suggest_question_answer(conn, question_id, use_llm=use_llm)
result["auto_answer_enabled"] = bool(
meli_svc.get_meli_config(conn).get("meli_auto_answer_questions")
)
return jsonify(result)
except ValueError as e:
return jsonify({"error": str(e)}), 404
except Exception as e:
return jsonify({"error": str(e)}), 500
finally:
conn.close()
# ═══════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════
# ORDERS # ORDERS
# ═══════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════
@@ -820,8 +589,8 @@ def sync_orders():
@marketplace_ext_bp.route("/orders", methods=["GET"]) @marketplace_ext_bp.route("/orders", methods=["GET"])
@require_auth() @require_auth()
def list_orders(): def list_orders():
page = max(1, _safe_int(request.args.get("page"), 1)) page = int(request.args.get("page", 1))
per_page = min(max(1, _safe_int(request.args.get("per_page"), 50)), 200) per_page = min(int(request.args.get("per_page", 50)), 200)
status = request.args.get("status") status = request.args.get("status")
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
@@ -850,15 +619,11 @@ def get_order(order_id):
def convert_order(order_id): def convert_order(order_id):
data = request.get_json() or {} data = request.get_json() or {}
register_id = data.get("register_id") register_id = data.get("register_id")
# Mirrors the counter-sale "Facturar CFDI" checkbox; when omitted the sale
# is only invoiced if the tenant has meli_auto_invoice = true.
generate_cfdi = bool(data.get("generate_cfdi", False))
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
try: try:
result = meli_svc.convert_order_to_sale( result = meli_svc.convert_order_to_sale(
conn, order_id, employee_id=g.employee_id, register_id=register_id, conn, order_id, employee_id=g.employee_id, register_id=register_id
generate_cfdi=generate_cfdi,
) )
return jsonify(result) return jsonify(result)
except ValueError as e: except ValueError as e:
@@ -897,35 +662,38 @@ def update_order_status_route(order_id):
def meli_webhook(): def meli_webhook():
"""Receive MercadoLibre notifications. """Receive MercadoLibre notifications.
ML sends a lightweight payload with topic + resource URL. We resolve the ML sends a lightweight payload with topic + resource URL.
tenant from ML's user_id, validate the X-Signature header when present, ack We ack immediately and enqueue Celery for async processing.
immediately and enqueue Celery to process the specific resource.
""" """
raw_body = request.get_data()
data = request.get_json(force=True, silent=True) or {} data = request.get_json(force=True, silent=True) or {}
topic = data.get("topic", "") topic = data.get("topic", "")
resource = data.get("resource", "") resource = data.get("resource", "")
user_id = data.get("user_id") user_id = data.get("user_id")
# tenant_config lives in each tenant DB, not in master # Resolve tenant by meli_user_id
tenant_id = _resolve_tenant_by_meli_user(user_id) tenant_id = None
if not tenant_id: if user_id:
print(f"[ML Webhook] No tenant found for meli_user_id={user_id}") try:
return jsonify({"error": "unknown tenant"}), 403 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
cfg = _get_meli_config(tenant_id) or {} if tenant_id and topic:
secret = cfg.get("meli_client_secret")
signature = request.headers.get("X-Signature")
if signature:
if not secret or not MeliService.validate_webhook_signature(secret, raw_body, signature):
print(f"[ML Webhook] Invalid X-Signature for tenant {tenant_id}")
return jsonify({"error": "invalid signature"}), 401
else:
# ML only signs notifications when signature validation is enabled on
# the app; without the header there is nothing to verify.
print(f"[ML Webhook] Notification without X-Signature (tenant {tenant_id}, topic={topic})")
if topic:
try: try:
from tasks import process_meli_webhook_task from tasks import process_meli_webhook_task
process_meli_webhook_task.delay(tenant_id, topic, resource) process_meli_webhook_task.delay(tenant_id, topic, resource)

File diff suppressed because it is too large Load Diff

View File

@@ -123,10 +123,7 @@ def public_catalog_search():
from services.catalog_service import smart_search from services.catalog_service import smart_search
master = get_master_conn() master = get_master_conn()
try: try:
# Catálogo público: scope 'catalog' → flag SEARCH_RERANK_ENABLED results = smart_search(master, q, conn, branch_id=None, limit=limit)
# (el POS, sensible a latencia, usa el scope 'pos' apagado por default).
results = smart_search(master, q, conn, branch_id=None, limit=limit,
rerank_scope='catalog')
finally: finally:
master.close() master.close()

View File

@@ -3,21 +3,16 @@
Prefix: /pos/api/service-orders Prefix: /pos/api/service-orders
""" """
import json
from functools import wraps
from flask import Blueprint, g, jsonify, request from flask import Blueprint, g, jsonify, request
from middleware import require_auth from middleware import require_auth
from services.service_order_engine import ( from services.service_order_engine import (
add_item, add_item,
add_labor, add_labor,
assign_mechanic, assign_mechanic,
convert_to_remission,
convert_to_sale, convert_to_sale,
create_service_catalog_item, create_service_catalog_item,
create_service_order, create_service_order,
delete_service_catalog_item, delete_service_catalog_item,
delete_service_order,
get_kanban_summary, get_kanban_summary,
get_service_order, get_service_order,
list_service_catalog, list_service_catalog,
@@ -33,136 +28,17 @@ from services.service_order_engine import (
update_status, update_status,
) )
from tenant_db import get_tenant_conn from tenant_db import get_tenant_conn
from blueprints.config_bp import _get_workshop_permissions, _DEFAULT_WORKSHOP_PERMISSIONS
service_order_bp = Blueprint('service_orders', __name__, url_prefix='/pos/api/service-orders') service_order_bp = Blueprint('service_orders', __name__, url_prefix='/pos/api/service-orders')
def _tenant_allows_zero_price(conn):
"""Return True if the tenant allows selling items at $0. Defaults to True."""
cur = conn.cursor()
cur.execute("SELECT value FROM tenant_config WHERE key = 'allow_zero_price_sales'")
row = cur.fetchone()
cur.close()
return str(row[0]).lower() in ('true', '1', 'yes') if row else True
def _validate_zero_price(conn, items):
"""Raise ValueError if any item line would be <= $0 and the tenant forbids it."""
if _tenant_allows_zero_price(conn):
return
for item in items:
unit_price = float(item.get('unit_price', 0) or 0)
quantity = float(item.get('quantity', 1) or 1)
discount_pct = float(item.get('discount_pct', 0) or 0)
line_total = unit_price * quantity * (1 - discount_pct / 100)
if line_total <= 0:
name = item.get('name') or item.get('part_number') or item.get('inventory_id')
raise ValueError(f"No está permitido vender artículos en $0 ({name})")
# Roles allowed to access the workshop module at all.
_WORKSHOP_VIEW_ROLES = {'owner', 'admin', 'counter', 'cashier', 'workshop', 'mechanic'}
def _can_view_workshop():
return g.employee_role in _WORKSHOP_VIEW_ROLES or 'workshop.view' in g.permissions
def _get_ws_perms():
"""Return effective workshop permissions for the current user."""
conn = get_tenant_conn(g.tenant_id)
try:
return _get_workshop_permissions(conn, g.employee_role)
finally:
conn.close()
def _visible_statuses():
if g.employee_role in ('owner', 'admin'):
return list(_DEFAULT_WORKSHOP_PERMISSIONS['admin']['statuses'])
return _get_ws_perms().get('statuses', [])
def _can_do_action(action):
if g.employee_role in ('owner', 'admin'):
return True
return action in _get_ws_perms().get('actions', [])
def _redact_order_for_restricted(order):
"""Remove customer/commercial data when the role cannot view sensitive data."""
if g.employee_role in ('owner', 'admin') or _can_do_action('view_customer_data'):
return order
sensitive = {
'customer_id', 'customer_name', 'customer_phone', 'customer_address',
'workshop_name', 'vehicle_description', 'vehicle_plate', 'vehicle_make',
'vehicle_model', 'delivery_method', 'courier_id', 'courier_name',
'requires_invoice', 'employee_id', 'employee_name', 'estimated_cost',
'final_cost', 'total_parts', 'total_labor', 'total',
}
def redact_value(v):
if isinstance(v, str):
return '—'
if isinstance(v, (int, float)):
return 0
return None
redacted = {}
for k, v in order.items():
if k in sensitive:
redacted[k] = redact_value(v)
elif k == 'items' and isinstance(v, list):
redacted[k] = [_redact_item_for_restricted(it) for it in v]
else:
redacted[k] = v
return redacted
def _redact_item_for_restricted(item):
"""Hide sensitive item rows for restricted viewers."""
item = dict(item)
for k in ('mechanic_id', 'unit_cost', 'unit_price'):
if k in item:
item[k] = None if not isinstance(item[k], (int, float)) else 0
return item
def require_workshop_view(f):
@wraps(f)
@require_auth()
def decorated(*args, **kwargs):
if not _can_view_workshop():
return jsonify({'error': 'Missing permissions: workshop.view'}), 403
return f(*args, **kwargs)
return decorated
def require_workshop_action(action):
def decorator(f):
@wraps(f)
@require_auth()
def decorated(*args, **kwargs):
if not _can_view_workshop():
return jsonify({'error': 'Missing permissions: workshop.view'}), 403
if not _can_do_action(action):
return jsonify({'error': f'Missing permissions: workshop.{action}'}), 403
return f(*args, **kwargs)
return decorated
return decorator
@service_order_bp.route('', methods=['GET']) @service_order_bp.route('', methods=['GET'])
@require_workshop_view @require_auth()
def list_orders(): def list_orders():
status = request.args.get('status') status = request.args.get('status')
priority = request.args.get('priority') priority = request.args.get('priority')
customer_id = request.args.get('customer_id', type=int) customer_id = request.args.get('customer_id', type=int)
employee_id = request.args.get('employee_id', type=int) employee_id = request.args.get('employee_id', type=int)
delivery_method = request.args.get('delivery_method')
is_direct = request.args.get('is_direct', type=lambda v: v.lower() == 'true') if 'is_direct' in request.args else None
q = request.args.get('q')
page = int(request.args.get('page', 1)) page = int(request.args.get('page', 1))
per_page = min(int(request.args.get('per_page', 50)), 200) per_page = min(int(request.args.get('per_page', 50)), 200)
@@ -171,65 +47,15 @@ def list_orders():
result = list_service_orders( result = list_service_orders(
conn, status=status, branch_id=g.branch_id, conn, status=status, branch_id=g.branch_id,
customer_id=customer_id, priority=priority, customer_id=customer_id, priority=priority,
employee_id=employee_id, delivery_method=delivery_method, employee_id=employee_id, page=page, per_page=per_page
is_direct=is_direct, q=q, page=page, per_page=per_page
) )
if g.employee_role not in ('owner', 'admin'):
visible = _visible_statuses()
result['data'] = [
_redact_order_for_restricted(o) for o in result.get('data', [])
if o.get('status') in visible
]
return jsonify(result) return jsonify(result)
finally: finally:
conn.close() conn.close()
@service_order_bp.route('/from-pos', methods=['POST'])
@require_auth('pos.sell')
def create_order_from_pos():
"""Create a service order directly from the POS cart (quotation)."""
data = request.get_json() or {}
items = data.get('items', [])
estimated_cost = sum(
(it.get('unit_price') or 0) * (it.get('quantity') or 1)
for it in items
)
conn = get_tenant_conn(g.tenant_id)
try:
_validate_zero_price(conn, items)
result = create_service_order(conn, {
'tenant_id': g.tenant_id,
'branch_id': data.get('branch_id', g.branch_id),
'customer_id': data.get('customer_id'),
'vehicle_id': data.get('vehicle_id'),
'priority': data.get('priority', 'normal'),
'reception_notes': data.get('reception_notes'),
'estimated_cost': estimated_cost or None,
'estimated_completion': data.get('estimated_completion'),
'employee_id': data.get('employee_id'),
'mileage_in': data.get('mileage_in'),
'created_by': getattr(g, 'employee_id', None),
'delivery_method': data.get('delivery_method'),
'courier_id': data.get('courier_id'),
'is_direct': data.get('is_direct', False),
'requires_invoice': data.get('requires_invoice', False),
'workshop_name': data.get('workshop_name'),
'customer_address': data.get('customer_address'),
'customer_phone': data.get('customer_phone'),
'vehicle_description': data.get('vehicle_description'),
})
so_id = result['service_order_id']
for item in items:
add_item(conn, so_id, item)
order = get_service_order(conn, so_id)
return jsonify(order), 201
finally:
conn.close()
@service_order_bp.route('', methods=['POST']) @service_order_bp.route('', methods=['POST'])
@require_workshop_action('create_order') @require_auth()
def create_order(): def create_order():
data = request.get_json() or {} data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
@@ -247,14 +73,6 @@ def create_order():
'mileage_in': data.get('mileage_in'), 'mileage_in': data.get('mileage_in'),
'fuel_level': data.get('fuel_level'), 'fuel_level': data.get('fuel_level'),
'created_by': getattr(g, 'employee_id', None), 'created_by': getattr(g, 'employee_id', None),
'delivery_method': data.get('delivery_method'),
'courier_id': data.get('courier_id'),
'is_direct': data.get('is_direct', False),
'requires_invoice': data.get('requires_invoice', False),
'workshop_name': data.get('workshop_name'),
'customer_address': data.get('customer_address'),
'customer_phone': data.get('customer_phone'),
'vehicle_description': data.get('vehicle_description'),
}) })
return jsonify(result), 201 return jsonify(result), 201
finally: finally:
@@ -262,29 +80,22 @@ def create_order():
@service_order_bp.route('/<int:so_id>', methods=['GET']) @service_order_bp.route('/<int:so_id>', methods=['GET'])
@require_workshop_view @require_auth()
def get_order(so_id): def get_order(so_id):
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
try: try:
order = get_service_order(conn, so_id) order = get_service_order(conn, so_id)
if not order: if not order:
return jsonify({'error': 'Service order not found'}), 404 return jsonify({'error': 'Service order not found'}), 404
if order.get('status') not in _visible_statuses(): return jsonify(order)
return jsonify({'error': 'No tienes acceso a esta orden'}), 403
return jsonify(_redact_order_for_restricted(order))
finally: finally:
conn.close() conn.close()
@service_order_bp.route('/<int:so_id>', methods=['PUT']) @service_order_bp.route('/<int:so_id>', methods=['PUT'])
@require_workshop_action('edit_order') @require_auth()
def update_order(so_id): def update_order(so_id):
data = request.get_json() or {} data = request.get_json() or {}
# Mechanics can only update diagnosis_notes, repair_notes and the free-text mechanic_name.
if g.employee_role == 'mechanic':
allowed = {'diagnosis_notes', 'repair_notes', 'mechanic_name'}
if not data or any(k not in allowed for k in data.keys()):
return jsonify({'error': 'Solo puedes editar notas de diagnostico, reparacion y asignar mecanico'}), 403
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
try: try:
ok = update_service_order(conn, so_id, data) ok = update_service_order(conn, so_id, data)
@@ -295,32 +106,13 @@ def update_order(so_id):
conn.close() conn.close()
@service_order_bp.route('/<int:so_id>', methods=['DELETE'])
@require_workshop_action('delete_order')
def delete_order(so_id):
"""Soft-delete a service order. Restricted to owner/admin."""
if g.employee_role not in ('owner', 'admin'):
return jsonify({'error': 'Solo administradores pueden eliminar órdenes'}), 403
conn = get_tenant_conn(g.tenant_id)
try:
delete_service_order(conn, so_id)
return jsonify({'message': 'Orden eliminada'})
except ValueError as e:
return jsonify({'error': str(e)}), 404
finally:
conn.close()
@service_order_bp.route('/<int:so_id>/status', methods=['PUT']) @service_order_bp.route('/<int:so_id>/status', methods=['PUT'])
@require_workshop_action('change_status') @require_auth()
def change_status(so_id): def change_status(so_id):
data = request.get_json() or {} data = request.get_json() or {}
new_status = data.get('status') new_status = data.get('status')
if not new_status: if not new_status:
return jsonify({'error': 'status is required'}), 400 return jsonify({'error': 'status is required'}), 400
if new_status not in _visible_statuses():
return jsonify({'error': 'No puedes cambiar a este estatus'}), 403
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
try: try:
result = update_status( result = update_status(
@@ -338,7 +130,7 @@ def change_status(so_id):
# ─── Items (Parts) ───────────────────────────── # ─── Items (Parts) ─────────────────────────────
@service_order_bp.route('/<int:so_id>/items', methods=['POST']) @service_order_bp.route('/<int:so_id>/items', methods=['POST'])
@require_workshop_action('add_items') @require_auth()
def add_order_item(so_id): def add_order_item(so_id):
data = request.get_json() or {} data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
@@ -350,7 +142,7 @@ def add_order_item(so_id):
@service_order_bp.route('/items/<int:item_id>', methods=['PUT']) @service_order_bp.route('/items/<int:item_id>', methods=['PUT'])
@require_workshop_action('add_items') @require_auth()
def update_order_item(item_id): def update_order_item(item_id):
data = request.get_json() or {} data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
@@ -364,7 +156,7 @@ def update_order_item(item_id):
@service_order_bp.route('/items/<int:item_id>', methods=['DELETE']) @service_order_bp.route('/items/<int:item_id>', methods=['DELETE'])
@require_workshop_action('add_items') @require_auth()
def delete_order_item(item_id): def delete_order_item(item_id):
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
try: try:
@@ -377,7 +169,7 @@ def delete_order_item(item_id):
# ─── Labor ───────────────────────────── # ─── Labor ─────────────────────────────
@service_order_bp.route('/<int:so_id>/labor', methods=['POST']) @service_order_bp.route('/<int:so_id>/labor', methods=['POST'])
@require_workshop_action('add_labor') @require_auth()
def add_order_labor(so_id): def add_order_labor(so_id):
data = request.get_json() or {} data = request.get_json() or {}
if not data.get('description'): if not data.get('description'):
@@ -391,7 +183,7 @@ def add_order_labor(so_id):
@service_order_bp.route('/labor/<int:labor_id>', methods=['PUT']) @service_order_bp.route('/labor/<int:labor_id>', methods=['PUT'])
@require_workshop_action('add_labor') @require_auth()
def update_order_labor(labor_id): def update_order_labor(labor_id):
data = request.get_json() or {} data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
@@ -405,7 +197,7 @@ def update_order_labor(labor_id):
@service_order_bp.route('/labor/<int:labor_id>', methods=['DELETE']) @service_order_bp.route('/labor/<int:labor_id>', methods=['DELETE'])
@require_workshop_action('add_labor') @require_auth()
def delete_order_labor(labor_id): def delete_order_labor(labor_id):
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
try: try:
@@ -418,7 +210,7 @@ def delete_order_labor(labor_id):
# ─── Kanban Summary ───────────────────────────── # ─── Kanban Summary ─────────────────────────────
@service_order_bp.route('/kanban/summary', methods=['GET']) @service_order_bp.route('/kanban/summary', methods=['GET'])
@require_workshop_view @require_auth()
def kanban_summary(): def kanban_summary():
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
try: try:
@@ -432,7 +224,7 @@ def kanban_summary():
@service_order_bp.route('/<int:so_id>/items/<int:item_id>/reserve', methods=['POST']) @service_order_bp.route('/<int:so_id>/items/<int:item_id>/reserve', methods=['POST'])
@require_workshop_action('add_items') @require_auth()
def reserve_order_item(so_id, item_id): def reserve_order_item(so_id, item_id):
"""Reserve inventory for a service order item.""" """Reserve inventory for a service order item."""
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
@@ -446,7 +238,7 @@ def reserve_order_item(so_id, item_id):
@service_order_bp.route('/<int:so_id>/items/<int:item_id>/release', methods=['POST']) @service_order_bp.route('/<int:so_id>/items/<int:item_id>/release', methods=['POST'])
@require_workshop_action('add_items') @require_auth()
def release_order_item(so_id, item_id): def release_order_item(so_id, item_id):
"""Release a previous inventory reservation.""" """Release a previous inventory reservation."""
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
@@ -464,7 +256,6 @@ def release_order_item(so_id, item_id):
@service_order_bp.route('/<int:so_id>/convert-to-sale', methods=['POST']) @service_order_bp.route('/<int:so_id>/convert-to-sale', methods=['POST'])
@require_auth('pos.sell') @require_auth('pos.sell')
@require_workshop_action('convert_to_sale')
def convert_order_to_sale(so_id): def convert_order_to_sale(so_id):
"""Convert a service order into a POS sale. """Convert a service order into a POS sale.
@@ -499,43 +290,11 @@ def convert_order_to_sale(so_id):
conn.close() conn.close()
# ─── Convert to remission note ────────────────────
@service_order_bp.route('/<int:so_id>/convert-to-remission', methods=['POST'])
@require_auth('pos.remission')
@require_workshop_action('convert_to_remission')
def convert_order_to_remission(so_id):
"""Convert a service order into a counter remission note (pending payment).
Body: {
register_id: int (optional),
notes: str (optional)
}
"""
data = request.get_json() or {}
sale_payload = {
'register_id': data.get('register_id'),
'notes': data.get('notes'),
}
conn = get_tenant_conn(g.tenant_id)
try:
result = convert_to_remission(
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 ────────────────────────── # ─── Mechanic assignment ──────────────────────────
@service_order_bp.route('/<int:so_id>/assign-mechanic', methods=['PUT']) @service_order_bp.route('/<int:so_id>/assign-mechanic', methods=['PUT'])
@require_workshop_action('assign_mechanic') @require_auth()
def assign_mechanic_endpoint(so_id): def assign_mechanic_endpoint(so_id):
"""Assign a mechanic/technician to a service order.""" """Assign a mechanic/technician to a service order."""
data = request.get_json() or {} data = request.get_json() or {}
@@ -553,144 +312,11 @@ def assign_mechanic_endpoint(so_id):
conn.close() conn.close()
@service_order_bp.route('/customers', methods=['POST'])
@require_workshop_action('edit_order')
def create_customer_for_workshop():
"""Create a customer directly from the workshop flow."""
data = request.get_json() or {}
if not data.get('name'):
return jsonify({'error': 'El nombre es obligatorio'}), 400
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
try:
cur.execute("""
INSERT INTO customers
(branch_id, name, rfc, razon_social, regimen_fiscal, uso_cfdi,
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
""", (
data.get('branch_id', g.branch_id), data['name'], data.get('rfc'),
data.get('razon_social'), data.get('regimen_fiscal'), data.get('uso_cfdi', 'G03'),
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]
conn.commit()
cur.close(); conn.close()
return jsonify({'id': customer_id, 'message': 'Cliente creado'}), 201
except Exception as e:
conn.rollback()
cur.close(); conn.close()
return jsonify({'error': str(e)}), 500
@service_order_bp.route('/vehicles', methods=['POST'])
@require_workshop_action('edit_order')
def create_vehicle_for_workshop():
"""Create a fleet vehicle directly from the workshop flow.
Vehicles must be assigned to a customer. Brand and model are required;
plate is optional and VIN is not used.
"""
data = request.get_json() or {}
if not data.get('make') or not data.get('model'):
return jsonify({'error': 'Marca y modelo son obligatorios'}), 400
if not data.get('customer_id'):
return jsonify({'error': 'El vehiculo debe estar asignado a un cliente'}), 400
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
try:
cur.execute("SELECT name FROM customers WHERE id = %s", (data['customer_id'],))
cust = cur.fetchone()
if not cust:
cur.close(); conn.close()
return jsonify({'error': 'Cliente no encontrado'}), 404
cur.execute("""
INSERT INTO fleet_vehicles
(branch_id, customer_id, plate, make, model, year,
current_mileage, fuel_type, color, owner_name, notes)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
RETURNING id
""", (
data.get('branch_id', g.branch_id), data['customer_id'], data.get('plate'),
data.get('make'), data.get('model'), data.get('year'),
data.get('current_mileage', 0), data.get('fuel_type', 'gasolina'),
data.get('color'), cust[0], data.get('notes'),
))
vehicle_id = cur.fetchone()[0]
conn.commit()
cur.close(); conn.close()
return jsonify({'id': vehicle_id, 'message': 'Vehiculo creado'}), 201
except Exception as e:
conn.rollback()
cur.close(); conn.close()
return jsonify({'error': str(e)}), 500
@service_order_bp.route('/inventory-search', methods=['GET'])
@require_workshop_view
def inventory_search():
"""Search active inventory for attaching parts to a service order.
Does not require inventory.view so workshop users can pick parts.
"""
q = request.args.get('q', '').strip()
if not q:
return jsonify({'data': []})
branch_id = request.args.get('branch_id', g.branch_id)
conn = get_tenant_conn(g.tenant_id)
try:
cur = conn.cursor()
like = f'%{q}%'
if branch_id:
cur.execute("""
SELECT i.id, i.part_number, i.name, i.brand, i.unit,
COALESCE((SELECT stock FROM inventory_stock
WHERE inventory_id = i.id AND branch_id = %s), 0) AS stock,
i.cost, i.price_1
FROM inventory i
WHERE i.is_active = true
AND (i.part_number ILIKE %s OR i.name ILIKE %s OR i.barcode ILIKE %s)
ORDER BY i.name
LIMIT 20
""", (branch_id, like, like, like))
else:
cur.execute("""
SELECT i.id, i.part_number, i.name, i.brand, i.unit,
COALESCE((SELECT stock FROM inventory_stock_summary
WHERE inventory_id = i.id), 0) AS stock,
i.cost, i.price_1
FROM inventory i
WHERE i.is_active = true
AND (i.part_number ILIKE %s OR i.name ILIKE %s OR i.barcode ILIKE %s)
ORDER BY i.name
LIMIT 20
""", (like, like, like))
items = []
for r in cur.fetchall():
items.append({
'id': r[0], 'part_number': r[1], 'name': r[2], 'brand': r[3], 'unit': r[4],
'stock': float(r[5]) if r[5] else 0,
'cost': float(r[6]) if r[6] else 0,
'price_1': float(r[7]) if r[7] else 0,
})
cur.close()
return jsonify({'data': items})
finally:
conn.close()
# ─── Service catalog (reusable labor) ───────────── # ─── Service catalog (reusable labor) ─────────────
@service_order_bp.route('/service-catalog', methods=['GET']) @service_order_bp.route('/service-catalog', methods=['GET'])
@require_workshop_view @require_auth()
def list_catalog(): def list_catalog():
"""List reusable labor/service concepts.""" """List reusable labor/service concepts."""
active_only = request.args.get('active_only', 'true').lower() != 'false' active_only = request.args.get('active_only', 'true').lower() != 'false'
@@ -703,7 +329,7 @@ def list_catalog():
@service_order_bp.route('/service-catalog', methods=['POST']) @service_order_bp.route('/service-catalog', methods=['POST'])
@require_workshop_action('edit_order') @require_auth()
def create_catalog_item(): def create_catalog_item():
"""Create a reusable labor concept.""" """Create a reusable labor concept."""
data = request.get_json() or {} data = request.get_json() or {}
@@ -719,7 +345,7 @@ def create_catalog_item():
@service_order_bp.route('/service-catalog/<int:item_id>', methods=['PUT']) @service_order_bp.route('/service-catalog/<int:item_id>', methods=['PUT'])
@require_workshop_action('edit_order') @require_auth()
def update_catalog_item(item_id): def update_catalog_item(item_id):
"""Update a reusable labor concept.""" """Update a reusable labor concept."""
data = request.get_json() or {} data = request.get_json() or {}
@@ -734,7 +360,7 @@ def update_catalog_item(item_id):
@service_order_bp.route('/service-catalog/<int:item_id>', methods=['DELETE']) @service_order_bp.route('/service-catalog/<int:item_id>', methods=['DELETE'])
@require_workshop_action('edit_order') @require_auth()
def delete_catalog_item(item_id): def delete_catalog_item(item_id):
"""Soft-delete a reusable labor concept.""" """Soft-delete a reusable labor concept."""
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
@@ -749,7 +375,7 @@ def delete_catalog_item(item_id):
@service_order_bp.route('/<int:so_id>/print', methods=['POST']) @service_order_bp.route('/<int:so_id>/print', methods=['POST'])
@require_workshop_action('edit_order') @require_auth()
def print_service_order_ticket(so_id): def print_service_order_ticket(so_id):
"""Generate a printable ticket for a service order. """Generate a printable ticket for a service order.
@@ -777,9 +403,7 @@ def print_service_order_ticket(so_id):
business_info = {'name': 'NEXUS AUTOPARTS', 'rfc': '', 'address': ''} business_info = {'name': 'NEXUS AUTOPARTS', 'rfc': '', 'address': ''}
try: try:
cur.execute( cur.execute(
"""SELECT 'business_name' AS k, value FROM tenant_config WHERE key IN ('tenant_nombre','receipt_store_name') "SELECT key, value FROM config WHERE key IN ('business_name','rfc','address')"
UNION ALL SELECT 'rfc', value FROM tenant_config WHERE key IN ('tenant_rfc','receipt_rfc')
UNION ALL SELECT 'address', value FROM tenant_config WHERE key IN ('tenant_direccion','receipt_address')"""
) )
for rw in cur.fetchall(): for rw in cur.fetchall():
if rw[0] == 'business_name': if rw[0] == 'business_name':

View File

@@ -1,240 +0,0 @@
# /home/Autopartes/pos/blueprints/storefront_bp.py
"""Storefront público por tenant: vitrina web con carrito → pedido al POS.
Endpoints públicos (sin auth) protegidos por rate-limit por IP:
GET /pos/api/store/info — nombre del negocio
GET /pos/api/store/search — búsqueda de inventario con stock y precio público
POST /pos/api/store/order — crea pedido (venta pendiente de pago) con reserva de stock
"""
import time
from collections import defaultdict, deque
from flask import Blueprint, request, jsonify, g
from middleware_tenant import resolve_tenant
from tenant_db import get_tenant_conn, get_master_conn
from services.pos_engine import process_sale
storefront_bp = Blueprint('storefront', __name__, url_prefix='/pos/api')
_RATE = defaultdict(deque)
RATE_LIMIT = 60 # requests por minuto por IP
RATE_WINDOW = 60
def _rate_ok():
ip = (request.headers.get('X-Real-IP') or request.remote_addr or 'x')
now = time.time()
dq = _RATE[ip]
while dq and dq[0] < now - RATE_WINDOW:
dq.popleft()
if len(dq) >= RATE_LIMIT:
return False
dq.append(now)
return True
def _require_tenant():
if not getattr(g, 'tenant_id', None):
return None
return g.tenant_id
def _business_name(tenant_id):
"""Nombre del negocio desde la DB maestra (cache 5 min por proceso)."""
cache = getattr(_business_name, '_cache', None)
if cache is None:
cache = _business_name._cache = {}
now = time.time()
hit = cache.get(tenant_id)
if hit and hit[1] > now:
return hit[0]
try:
conn = get_master_conn()
cur = conn.cursor()
cur.execute("SELECT name FROM tenants WHERE id = %s", (tenant_id,))
row = cur.fetchone()
cur.close()
conn.close()
name = row[0] if row else 'Refaccionaria'
except Exception:
name = 'Refaccionaria'
cache[tenant_id] = (name, now + 300)
return name
@storefront_bp.route('/store/info', methods=['GET'])
def store_info():
tid = _require_tenant()
if not tid:
return jsonify({'error': 'tenant'}), 404
return jsonify({'name': _business_name(tid)})
@storefront_bp.route('/store/search', methods=['GET'])
def store_search():
tid = _require_tenant()
if not tid:
return jsonify({'error': 'tenant'}), 404
if not _rate_ok():
return jsonify({'error': 'Demasiadas búsquedas. Intenta en un minuto.'}), 429
q = (request.args.get('q') or '').strip()
def _int_arg(name, default, lo, hi):
try:
return max(lo, min(hi, int(request.args.get(name, default) or default)))
except (ValueError, TypeError):
return default
page = _int_arg('page', 1, 1, 10000)
per_page = _int_arg('per_page', 20, 1, 30)
offset = (page - 1) * per_page
conn = get_tenant_conn(tid)
cur = conn.cursor()
params = []
where = "i.is_active = true"
if q:
where += """ AND (i.name ILIKE %s OR i.part_number ILIKE %s
OR i.id IN (SELECT a.inventory_id FROM inventory_sku_aliases a
WHERE a.sku ILIKE %s))"""
like = f"%{q}%"
params += [like, like, like]
cur.execute(f"SELECT count(*) FROM inventory i WHERE {where}", params)
total = cur.fetchone()[0]
cur.execute(f"""
SELECT i.id, i.part_number, i.name, i.brand, i.price_1, i.image_url, i.description,
COALESCE(s.stock, 0) AS stock
FROM inventory i
LEFT JOIN (
SELECT inventory_id, SUM(stock) AS stock FROM inventory_stock
GROUP BY inventory_id
) s ON s.inventory_id = i.id
WHERE {where}
ORDER BY (COALESCE(s.stock, 0) > 0) DESC, i.name
LIMIT %s OFFSET %s
""", params + [per_page, offset])
items = [{
'id': r[0], 'part_number': r[1], 'name': r[2], 'brand': r[3] or '',
'price': float(r[4] or 0), 'image_url': r[5] or '',
'description': (r[6] or '')[:200], 'stock': int(r[7] or 0),
} for r in cur.fetchall()]
cur.close()
conn.close()
return jsonify({'items': items, 'page': page, 'total': total,
'total_pages': max(1, (total + per_page - 1) // per_page)})
@storefront_bp.route('/store/order', methods=['POST'])
def store_order():
tid = _require_tenant()
if not tid:
return jsonify({'error': 'tenant'}), 404
if not _rate_ok():
return jsonify({'error': 'Demasiadas solicitudes. Intenta en un minuto.'}), 429
data = request.get_json() or {}
name = (data.get('customer_name') or '').strip()
phone = ''.join(ch for ch in (data.get('customer_phone') or '') if ch.isdigit())
items = data.get('items') or []
notes = (data.get('notes') or '').strip()
delivery = bool(data.get('delivery'))
if not name or not phone or len(phone) < 10:
return jsonify({'error': 'Nombre y teléfono (10 dígitos) son requeridos'}), 400
if not items:
return jsonify({'error': 'El carrito está vacío'}), 400
conn = get_tenant_conn(tid)
cur = conn.cursor()
# Sucursal principal
cur.execute("SELECT id FROM branches ORDER BY id LIMIT 1")
row = cur.fetchone()
if not row:
cur.close(); conn.close()
return jsonify({'error': 'Tienda no disponible'}), 503
branch_id = row[0]
# Cliente: buscar por teléfono, si no existe crearlo
cur.execute("SELECT id FROM customers WHERE phone = %s ORDER BY id LIMIT 1", (phone,))
row = cur.fetchone()
if row:
customer_id = row[0]
else:
cur.execute("""
INSERT INTO customers (name, phone, price_tier, is_active)
VALUES (%s, %s, 1, true) RETURNING id
""", (name, phone))
customer_id = cur.fetchone()[0]
# Validar stock y precios (precio SIEMPRE del inventario, no del cliente)
resolved = []
for it in items:
inv_id = it.get('inventory_id')
qty = int(it.get('quantity', 0) or 0)
if not inv_id or qty <= 0:
continue
cur.execute("""
SELECT i.id, i.name, i.part_number, i.price_1, i.tax_rate,
COALESCE(s.stock, 0) AS stock
FROM inventory i
LEFT JOIN (
SELECT inventory_id, SUM(stock) AS stock FROM inventory_stock
GROUP BY inventory_id
) s ON s.inventory_id = i.id
WHERE i.id = %s AND i.is_active
""", (inv_id,))
r = cur.fetchone()
if not r:
cur.close(); conn.close()
return jsonify({'error': f'Producto no disponible (#{inv_id})'}), 400
if (r[5] or 0) < qty:
cur.close(); conn.close()
return jsonify({'error': f'Sin stock suficiente: {r[2]}. Disponible: {int(r[5] or 0)}'}), 400
resolved.append({
'inventory_id': r[0], 'quantity': qty,
'unit_price': float(r[3] or 0), 'tax_rate': float(r[4] or 0),
'discount_pct': 0,
})
if not resolved:
cur.close(); conn.close()
return jsonify({'error': 'El carrito está vacío'}), 400
delivery_note = ' | Envío a domicilio solicitado' if delivery else ''
sale_data = {
'items': resolved,
'customer_id': customer_id,
'payment_method': 'pendiente',
'sale_type': 'cash',
'branch_id': branch_id,
'amount_paid': 0,
'notes': f'Pedido web de {name}{delivery_note}' + (f' | {notes}' if notes else ''),
}
try:
sale = process_sale(conn, sale_data)
conn.commit()
except ValueError as e:
conn.rollback()
cur.close(); conn.close()
return jsonify({'error': str(e)}), 400
except Exception:
conn.rollback()
cur.close(); conn.close()
return jsonify({'error': 'No se pudo registrar el pedido'}), 500
cur.close()
# Notificar al dueño (best effort)
try:
from services.notification_engine import notify_new_sale
notify_new_sale(conn, tid, sale['id'], sale['total'], 'web')
except Exception:
pass
conn.close()
return jsonify({
'ok': True,
'order_id': sale['id'],
'total': sale['total'],
'message': 'Pedido registrado. Te contactaremos para confirmar pago y entrega.',
})

View File

@@ -28,7 +28,7 @@ def enqueue_report():
@tasks_bp.route('/<task_id>/status', methods=['GET']) @tasks_bp.route('/<task_id>/status', methods=['GET'])
@require_auth() @require_auth
def task_status(task_id): def task_status(task_id):
"""Get status of a background task.""" """Get status of a background task."""
from celery_app import celery from celery_app import celery

View File

@@ -3,7 +3,6 @@
Endpoints (all under /pos/api): Endpoints (all under /pos/api):
GET/POST /warranties GET/POST /warranties
GET /warranties/<id> GET /warranties/<id>
GET /warranties/<id>/print (HTML)
GET /customers/<id>/warranties GET /customers/<id>/warranties
POST /warranty-claims POST /warranty-claims
GET /warranty-claims GET /warranty-claims
@@ -12,7 +11,7 @@ Endpoints (all under /pos/api):
PUT /warranty-claims/<id>/close PUT /warranty-claims/<id>/close
""" """
from flask import Blueprint, request, jsonify, g, render_template_string from flask import Blueprint, request, jsonify, g
from middleware import require_auth from middleware import require_auth
from tenant_db import get_tenant_conn from tenant_db import get_tenant_conn
from services.warranty_engine import ( from services.warranty_engine import (
@@ -44,10 +43,7 @@ def get_warranties():
def post_warranty(): def post_warranty():
"""Register a warranty (usually called at sale time).""" """Register a warranty (usually called at sale time)."""
data = request.get_json() or {} data = request.get_json() or {}
required = ['sale_id', 'sale_item_id', 'inventory_id', 'customer_id'] required = ['sale_id', 'sale_item_id', 'inventory_id', 'customer_id', 'warranty_months']
# warranty_months is required unless a day-based coverage is given (used parts)
if 'warranty_months' not in data and 'warranty_days' not in data:
required.append('warranty_months')
missing = [f for f in required if f not in data] missing = [f for f in required if f not in data]
if missing: if missing:
return jsonify({'error': f'Missing fields: {missing}'}), 400 return jsonify({'error': f'Missing fields: {missing}'}), 400
@@ -60,12 +56,11 @@ def post_warranty():
sale_item_id=data['sale_item_id'], sale_item_id=data['sale_item_id'],
inventory_id=data['inventory_id'], inventory_id=data['inventory_id'],
customer_id=data['customer_id'], customer_id=data['customer_id'],
warranty_months=int(data.get('warranty_months') or 0), warranty_months=int(data['warranty_months']),
supplier_id=data.get('supplier_id'), supplier_id=data.get('supplier_id'),
part_number=data.get('part_number'), part_number=data.get('part_number'),
name=data.get('name'), name=data.get('name'),
notes=data.get('notes'), notes=data.get('notes'),
warranty_days=int(data['warranty_days']) if data.get('warranty_days') else None,
) )
conn.commit() conn.commit()
conn.close() conn.close()
@@ -92,49 +87,6 @@ def get_warranty_detail(warranty_id):
return jsonify(w) return jsonify(w)
@warranty_bp.route('/warranties/<int:warranty_id>/print', methods=['GET'])
@require_auth('inventory.view')
def print_warranty(warranty_id):
"""Printable warranty receipt (HTML).
Serves both month-based and Yonke day-based coverage so a used-part buyer
gets a physical voucher with the coverage window.
"""
conn = get_tenant_conn(g.tenant_id)
try:
w = get_warranty(conn, warranty_id)
if not w:
return jsonify({'error': 'Warranty not found'}), 404
business = {'name': 'NEXUS AUTOPARTS', 'rfc': '', 'address': '', 'phone': ''}
try:
cur = conn.cursor()
cur.execute("""
SELECT 'name' AS k, value FROM tenant_config
WHERE key IN ('tenant_nombre', 'receipt_store_name')
UNION ALL SELECT 'rfc', value FROM tenant_config
WHERE key IN ('tenant_rfc', 'receipt_rfc')
UNION ALL SELECT 'address', value FROM tenant_config
WHERE key IN ('tenant_direccion', 'receipt_address')
UNION ALL SELECT 'phone', value FROM tenant_config
WHERE key IN ('tenant_telefono', 'receipt_phone')
""")
for k, v in cur.fetchall():
if v:
business[k] = v
cur.close()
except Exception:
pass
finally:
conn.close()
coverage = (f"{w['warranty_days']} días" if w.get('warranty_days')
else f"{w.get('warranty_months') or 0} meses")
html = render_template_string(
WARRANTY_PRINT_TEMPLATE, w=w, business=business, coverage=coverage
)
return html, 200, {'Content-Type': 'text/html; charset=utf-8'}
@warranty_bp.route('/customers/<int:customer_id>/warranties', methods=['GET']) @warranty_bp.route('/customers/<int:customer_id>/warranties', methods=['GET'])
@require_auth('inventory.view') @require_auth('inventory.view')
def get_customer_warranties(customer_id): def get_customer_warranties(customer_id):
@@ -254,68 +206,3 @@ def put_close_claim(claim_id):
conn.rollback() conn.rollback()
conn.close() conn.close()
return jsonify({'error': str(e)}), 500 return jsonify({'error': str(e)}), 500
# ── Printable warranty receipt ─────────────────────────────────────────────
WARRANTY_PRINT_TEMPLATE = """<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Garantia #{{ w.id }}</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f3f4f6;color:#111;padding:16px;line-height:1.5}
.card{max-width:640px;margin:0 auto;background:#fff;border-radius:12px;box-shadow:0 4px 20px rgba(0,0,0,.08);overflow:hidden}
.header{background:linear-gradient(135deg,#1f2937,#374151);color:#fff;padding:24px;text-align:center}
.header h1{font-size:20px;font-weight:700;margin-bottom:4px}
.header p{font-size:13px;opacity:.85}
.body{padding:24px}
.meta{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:20px;font-size:13px;color:#4b5563}
.meta div{background:#f9fafb;padding:10px 12px;border-radius:8px}
.meta strong{color:#111;display:block;font-size:11px;text-transform:uppercase;letter-spacing:.4px;margin-bottom:2px}
.badge{display:inline-block;padding:4px 10px;border-radius:999px;font-size:11px;font-weight:700;text-transform:uppercase}
.badge-active{background:#d1fae5;color:#065f46}
.badge-expired,.badge-claimed,.badge-void{background:#fee2e2;color:#991b1b}
.terms{font-size:12px;color:#6b7280;border-top:1px solid #e5e7eb;padding-top:14px}
.footer{text-align:center;padding:14px;font-size:12px;color:#9ca3af}
.actions{padding:0 24px 20px;text-align:center}
.btn{display:inline-block;padding:12px 22px;border-radius:10px;border:none;background:#1f2937;color:#fff;font-size:14px;font-weight:700;cursor:pointer}
@media print{body{background:#fff;padding:0}.card{box-shadow:none}.actions{display:none}}
</style>
</head>
<body>
<div class="card">
<div class="header">
<h1>Comprobante de Garantia</h1>
<p>{{ business.name }}{% if business.rfc %} &middot; RFC {{ business.rfc }}{% endif %}</p>
</div>
<div class="body">
<div class="meta">
<div><strong>Folio</strong>G-{{ w.id }}</div>
<div><strong>Estado</strong><span class="badge badge-{{ w.status }}">{{ w.status }}</span></div>
<div><strong>Cliente</strong>{{ w.customer_name or 'Publico general' }}</div>
<div><strong>Venta</strong>#{{ w.sale_id or '—' }}</div>
<div><strong>Pieza</strong>{{ w.name or '—' }}</div>
<div><strong>No. parte</strong>{{ w.part_number or '—' }}</div>
<div><strong>Inicio</strong>{{ w.start_date }}</div>
<div><strong>Vence</strong>{{ w.end_date }}</div>
<div><strong>Cobertura</strong>{{ coverage }}</div>
</div>
<div class="terms">
<p>Esta garantia ampara defectos de funcionamiento de la pieza por {{ coverage }} a partir de
la fecha de compra. Presente este comprobante junto con la pieza para cualquier reclamacion.</p>
{% if w.notes %}<p>Notas: {{ w.notes }}</p>{% endif %}
{% if business.address %}<p>{{ business.address }}</p>{% endif %}
{% if business.phone %}<p>Tel: {{ business.phone }}</p>{% endif %}
</div>
</div>
<div class="actions">
<button class="btn" onclick="window.print()">Imprimir</button>
</div>
<div class="footer">{{ business.name }} — Comprobante generado el {{ w.created_at[:10] }}</div>
</div>
</body>
</html>
"""

View File

@@ -34,7 +34,6 @@ def _get_whatsapp_config(conn):
bridge_url = config.get('whatsapp_bridge_url', '') or WHATSAPP_BRIDGE_URL or '' bridge_url = config.get('whatsapp_bridge_url', '') or WHATSAPP_BRIDGE_URL or ''
bridge_key = config.get('whatsapp_bridge_key', '') or WHATSAPP_BRIDGE_KEY or '' bridge_key = config.get('whatsapp_bridge_key', '') or WHATSAPP_BRIDGE_KEY or ''
provider = (config.get('whatsapp_provider') or 'baileys').lower()
enabled_raw = config.get('whatsapp_enabled', '').lower() enabled_raw = config.get('whatsapp_enabled', '').lower()
if enabled_raw == 'true': if enabled_raw == 'true':
enabled = True enabled = True
@@ -44,17 +43,7 @@ def _get_whatsapp_config(conn):
# No explicit tenant setting: auto-enable if a bridge URL is configured # No explicit tenant setting: auto-enable if a bridge URL is configured
enabled = bool(bridge_url) enabled = bool(bridge_url)
return { 'provider': provider, return {
'phone_number_id': config.get('wa_phone_number_id', ''),
'access_token': config.get('wa_access_token', ''),
'app_secret': config.get('wa_app_secret', ''),
'verify_token': config.get('wa_verify_token', ''),
'graph_url': config.get('wa_graph_url', ''),
'ai_persona': config.get('wa_ai_persona', ''),
'api_base': config.get('wa_api_base', ''),
'api_key': config.get('wa_api_key', ''),
'public_base': config.get('wa_public_base', ''),
'bridge_url': bridge_url, 'bridge_url': bridge_url,
'bridge_key': bridge_key, 'bridge_key': bridge_key,
'enabled': enabled, 'enabled': enabled,
@@ -370,11 +359,6 @@ def webhook():
return jsonify({'ok': True}) return jsonify({'ok': True})
msg = whatsapp_service.process_incoming(data) msg = whatsapp_service.process_incoming(data)
return _process_incoming_whatsapp_msg(msg)
def _process_incoming_whatsapp_msg(msg, tenant_id_override=None):
"""Procesa un mensaje entrante normalizado (Baileys o Meta Cloud API)."""
if not msg.get('phone') or msg.get('from_me'): if not msg.get('phone') or msg.get('from_me'):
return jsonify({'ok': True}) return jsonify({'ok': True})
@@ -409,7 +393,7 @@ def _process_incoming_whatsapp_msg(msg, tenant_id_override=None):
# Resolve tenant # Resolve tenant
tenant_id = tenant_id_override or request.args.get('tenant_id', type=int) tenant_id = request.args.get('tenant_id', type=int)
if not tenant_id: if not tenant_id:
try: try:
mconn = get_master_conn() mconn = get_master_conn()
@@ -510,7 +494,7 @@ def _process_incoming_whatsapp_msg(msg, tenant_id_override=None):
from services.part_kits import should_send_followup from services.part_kits import should_send_followup
followup = should_send_followup(phone, tenant_conn) followup = should_send_followup(phone, tenant_conn)
if followup: if followup:
whatsapp_service.wa_send_text(wa_config, reply_to, followup) whatsapp_service.send_message(reply_to, followup, bridge_url=wa_config.get('bridge_url'))
cur_fu = tenant_conn.cursor() cur_fu = tenant_conn.cursor()
cur_fu.execute( cur_fu.execute(
"INSERT INTO whatsapp_messages (phone, direction, message_text) VALUES (%s, 'outgoing', %s)", "INSERT INTO whatsapp_messages (phone, direction, message_text) VALUES (%s, 'outgoing', %s)",
@@ -560,7 +544,7 @@ def _process_incoming_whatsapp_msg(msg, tenant_id_override=None):
# 7. Send reply # 7. Send reply
if reply: if reply:
result = whatsapp_service.wa_send_text(wa_config, reply_to, reply) result = whatsapp_service.send_message(reply_to, reply, bridge_url=wa_config.get('bridge_url'))
print(f"[WA-SM] Replied to {phone}: {reply[:80]}... result={result}") print(f"[WA-SM] Replied to {phone}: {reply[:80]}... result={result}")
# Log outgoing # Log outgoing
@@ -584,7 +568,7 @@ def _process_incoming_whatsapp_msg(msg, tenant_id_override=None):
"Estoy teniendo problemas técnicos en este momento. 😕\n\n" "Estoy teniendo problemas técnicos en este momento. 😕\n\n"
f"Por favor llámanos directamente al {phone_branch}." f"Por favor llámanos directamente al {phone_branch}."
) )
whatsapp_service.wa_send_text(wa_config, reply_to, fallback) whatsapp_service.send_message(reply_to, fallback, bridge_url=wa_config.get('bridge_url'))
except Exception: except Exception:
pass pass
@@ -599,221 +583,10 @@ def _process_incoming_whatsapp_msg(msg, tenant_id_override=None):
master_conn.close() master_conn.close()
except Exception: except Exception:
pass pass
return jsonify({'ok': True}) return jsonify({'ok': True})
# ═══════════════════════════════════════════════════════════════════════════
# WA CRM / Meta Cloud API webhook (1 número por refaccionaria)
# ═══════════════════════════════════════════════════════════════════════════
import os as _os_wa
def _resolve_tenant_by_pnid(phone_number_id):
"""Resolve (tenant_id, wa_config) by Meta phone_number_id. Cached per process."""
if not phone_number_id:
return None, None
cache = getattr(_resolve_tenant_by_pnid, '_cache', None)
if cache is None:
cache = _resolve_tenant_by_pnid._cache = {}
if phone_number_id in cache:
return cache[phone_number_id]
try:
mconn = get_master_conn()
mcur = mconn.cursor()
mcur.execute("SELECT id, db_name FROM tenants WHERE is_active = true ORDER BY id")
tenants = mcur.fetchall()
mcur.close()
mconn.close()
except Exception:
return None, None
from tenant_db import get_tenant_conn_by_dbname
for tid, db_name in tenants:
try:
tconn = get_tenant_conn_by_dbname(db_name)
cfg = _get_whatsapp_config(tconn)
tconn.close()
if str(cfg.get('phone_number_id') or '') == str(phone_number_id):
cache[phone_number_id] = (tid, cfg)
return tid, cfg
except Exception:
continue
cache[phone_number_id] = (None, None)
return None, None
def _meta_download_media_b64(cfg, media_id):
"""Download Meta media (audio/image/document) → base64 + mimetype."""
import requests as _rq
import base64 as _b64
try:
base = (cfg.get('graph_url') or 'https://graph.facebook.com/v21.0').rstrip('/')
headers = {'Authorization': f"Bearer {cfg.get('access_token')}"}
info = _rq.get(f"{base}/{media_id}", headers=headers, timeout=20).json()
url = info.get('url')
if not url:
return None, None
data = _rq.get(url, headers=headers, timeout=60).content
return _b64.b64encode(data).decode(), info.get('mime_type', 'application/octet-stream')
except Exception as e:
print(f"[WA-CRM] media download failed: {e}")
return None, None
def _meta_parse_msg(payload, cfg):
"""Parse a Meta Cloud API webhook payload into the internal msg dict.
Returns (phone_number_id, msg) — msg may be None for non-message events."""
pnid = None
for entry in payload.get('entry', []):
for change in entry.get('changes', []):
value = change.get('value', {})
pnid = value.get('metadata', {}).get('phone_number_id', pnid)
if not value.get('messages'):
continue
m = value['messages'][0]
contact = (value.get('contacts') or [{}])[0]
push_name = (contact.get('profile') or {}).get('name', '')
msg = {
'phone': m.get('from', ''),
'from_me': False,
'message_id': m.get('id', ''),
'push_name': push_name,
'text': '',
'media_kind': 'text',
}
mtype = m.get('type', '')
if mtype == 'text':
msg['text'] = m.get('text', {}).get('body', '')
elif mtype == 'image':
msg['media_kind'] = 'image'
msg['text'] = m.get('image', {}).get('caption', '') or '(imagen)'
b64, mt = _meta_download_media_b64(cfg, m.get('image', {}).get('id'))
if b64:
msg['media_base64'] = b64
msg['media_mimetype'] = mt
elif mtype == 'audio' or mtype == 'voice':
msg['media_kind'] = 'audio'
b64, mt = _meta_download_media_b64(cfg, m.get('audio', {}).get('id'))
if b64:
msg['media_base64'] = b64
msg['media_mimetype'] = mt or 'audio/ogg'
elif mtype == 'document':
msg['media_kind'] = 'image'
msg['text'] = m.get('document', {}).get('caption', '') or '(documento)'
b64, mt = _meta_download_media_b64(cfg, m.get('document', {}).get('id'))
if b64:
msg['media_base64'] = b64
msg['media_mimetype'] = mt
elif mtype == 'location':
lat = m.get('location', {}).get('latitude')
lng = m.get('location', {}).get('longitude')
msg['media_kind'] = 'location'
msg['latitude'] = lat
msg['longitude'] = lng
return pnid, msg
return pnid, None
@whatsapp_bp.route('/wacrm/webhook', methods=['GET'])
def wacrm_webhook_verify():
"""Meta webhook verification handshake."""
verify_token = request.args.get('hub.verify_token', '')
challenge = request.args.get('hub.challenge', '')
if not challenge:
return 'Bad request', 400
expected_global = _os_wa.environ.get('WA_VERIFY_TOKEN', '')
if expected_global and verify_token == expected_global:
return challenge, 200
# per-tenant verify tokens
try:
mconn = get_master_conn()
mcur = mconn.cursor()
mcur.execute("SELECT id, db_name FROM tenants WHERE is_active = true ORDER BY id")
tenants = mcur.fetchall()
mcur.close()
mconn.close()
from tenant_db import get_tenant_conn_by_dbname
for tid, db_name in tenants:
try:
tconn = get_tenant_conn_by_dbname(db_name)
cur = tconn.cursor()
cur.execute("SELECT value FROM tenant_config WHERE key = 'wa_verify_token'")
row = cur.fetchone()
tconn.close()
if row and row[0] and verify_token == row[0]:
return challenge, 200
except Exception:
continue
except Exception:
pass
return 'Forbidden', 403
@whatsapp_bp.route('/wacrm/webhook', methods=['POST'])
def wacrm_webhook():
"""Meta Cloud API events (WA CRM self-hosted, 1 número por tenant)."""
raw = request.get_data()
payload = request.get_json(silent=True) or {}
signature = request.headers.get('X-Hub-Signature-256', '')
# First parse to get phone_number_id (to resolve tenant + its app secret)
pnid_probe = None
for entry in payload.get('entry', []):
for change in entry.get('changes', []):
pnid_probe = change.get('value', {}).get('metadata', {}).get('phone_number_id')
break
if pnid_probe:
break
tenant_id, wa_cfg = _resolve_tenant_by_pnid(pnid_probe) if pnid_probe else (None, None)
app_secret = (wa_cfg or {}).get('app_secret') or _os_wa.environ.get('WA_APP_SECRET', '')
from services.whatsapp_service import meta_verify_signature
if not meta_verify_signature(app_secret, raw, signature):
return 'Invalid signature', 403
pnid, msg = _meta_parse_msg(payload, wa_cfg or {})
# Formato de automatización de WA CRM: {"phone": "...", "message": "..."} (o from/text)
if not msg:
raw_phone = payload.get('phone') or payload.get('from') or payload.get('wa_id')
raw_text = payload.get('message') or payload.get('text') or payload.get('body')
if raw_phone and raw_text is not None:
msg = {
'phone': str(raw_phone),
'from_me': bool(payload.get('from_me')),
'message_id': payload.get('message_id') or payload.get('id') or '',
'push_name': payload.get('name') or payload.get('push_name') or '',
'text': str(raw_text),
'media_kind': 'text',
}
if not msg:
return jsonify({'ok': True}) # statuses / reads / non-message events
if not tenant_id:
tid_arg = request.args.get('tenant_id', type=int)
if tid_arg:
from tenant_db import get_tenant_conn as _gtc
try:
_c = _gtc(tid_arg)
wa_cfg = _get_whatsapp_config(_c)
_c.close()
tenant_id = tid_arg
except Exception:
tenant_id = None
if not tenant_id:
print(f"[WA-CRM] No tenant for phone_number_id={pnid_probe}")
return jsonify({'ok': True})
return _process_incoming_whatsapp_msg(msg, tenant_id_override=tenant_id)
@whatsapp_bp.route('/send', methods=['POST']) @whatsapp_bp.route('/send', methods=['POST'])
@require_auth() @require_auth()
def send(): def send():

View File

@@ -1,911 +0,0 @@
# /home/Autopartes/pos/blueprints/yonke_bp.py
"""Nexus Yonke blueprint: used/rebuild parts, donor vehicles and consignments.
All endpoints live under /pos/api/yonke.
Permissions: yonke.view for reads, yonke.manage for writes.
AI vision grading (A4):
POST /pos/api/yonke/pieces/<id>/grade-suggest (multipart `file` | JSON image_base64)
POST /pos/api/yonke/grade-suggest (no piece required)
Both return the suggestion contract documented in the "AI vision grading"
section below; the front (yonke.js) can POST the photo and pre-fill the grade.
A5 additions (donor intake, reports, statements, labels, ML, bulk, AI):
POST /pos/api/yonke/donors/full — donor + N pieces (atomic)
POST /pos/api/yonke/donors/<vin>/allocate-cost — spread acquisition cost
GET /pos/api/yonke/reports/aging?days=90 — pieces aging in the yard
GET /pos/api/yonke/reports/summary — donors/margin/capital/grades
GET /pos/api/yonke/consignments/<id>/statement — consignor statement (JSON)
GET /pos/api/yonke/consignments/<id>/statement/print — consignor statement (HTML)
POST /pos/api/yonke/pieces/<id>/publish-meli — publish one piece to ML
POST /pos/api/yonke/pieces/bulk — bulk CSV import
GET/POST /pos/api/yonke/pieces/<id>/compat — "sirve para" fitment
GET /pos/api/yonke/pieces/<id>/label — printable barcode label
POST /pos/api/yonke/pieces/<id>/identify — vision identify (503 on AI down)
POST /pos/api/yonke/pieces/<id>/price-suggest — rule-based price suggestion
GET /pos/api/yonke/duplicates?threshold=0.85 — semantic duplicate groups
POST /pos/api/yonke/pieces/<id>/validate-photo — vision photo check
"""
import os
from io import BytesIO
from flask import Blueprint, request, jsonify, g, Response
from middleware import require_auth
from tenant_db import get_tenant_conn
from services import yonke_service
from services.yonke_service import DuplicatePartNumber
from services.image_service import DATA_DIR, STATIC_DIR
from services.vin_decoder import decode_vin
yonke_bp = Blueprint('yonke', __name__, url_prefix='/pos/api/yonke')
ALLOWED_EXTENSIONS = {'jpg', 'jpeg', 'png', 'webp'}
MAX_IMAGE_BYTES = 5 * 1024 * 1024 # 5 MB
def _int_arg(name, default, minimum, maximum):
try:
val = int(request.args.get(name, default))
except (TypeError, ValueError):
return default
return min(max(val, minimum), maximum)
def _branch_id(data=None):
"""Resolve the branch: explicit body/query value first, token fallback."""
if isinstance(data, dict) and data.get('branch_id'):
try:
return int(data['branch_id'])
except (TypeError, ValueError):
pass
arg = request.args.get('branch_id')
if arg:
try:
return int(arg)
except (TypeError, ValueError):
pass
return g.branch_id
# ─── Pieces ────────────────────────────────────────────────────────────────
@yonke_bp.route('/pieces', methods=['GET'])
@require_auth('yonke.view')
def list_pieces():
"""List used/rebuild pieces with filters and pagination."""
conn = get_tenant_conn(g.tenant_id)
try:
items, pagination = yonke_service.list_pieces(
conn,
filters={
'q': request.args.get('q'),
'donor_make': request.args.get('donor_make'),
'donor_model': request.args.get('donor_model'),
'donor_year': request.args.get('donor_year'),
'vin': request.args.get('vin'),
'grade': request.args.get('grade'),
'condition': request.args.get('condition'),
'ownership': request.args.get('ownership'),
},
page=_int_arg('page', 1, 1, 10**9),
per_page=_int_arg('per_page', 25, 1, 200),
branch_id=_branch_id(),
)
return jsonify({'data': items, 'pagination': pagination})
except ValueError as e:
return jsonify({'error': str(e)}), 400
except Exception as e:
return jsonify({'error': str(e)}), 500
finally:
conn.close()
@yonke_bp.route('/pieces', methods=['POST'])
@require_auth('yonke.manage')
def create_piece():
"""Create a used/rebuild piece (auto-SKU when part_number is omitted)."""
data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id)
try:
item = yonke_service.create_piece(conn, data, branch_id=_branch_id(data))
conn.commit()
return jsonify(item), 201
except DuplicatePartNumber:
conn.rollback()
return jsonify({'error': 'part_number already exists'}), 409
except ValueError as e:
conn.rollback()
return jsonify({'error': str(e)}), 400
except Exception as e:
conn.rollback()
return jsonify({'error': str(e)}), 500
finally:
conn.close()
@yonke_bp.route('/pieces/<int:piece_id>', methods=['GET'])
@require_auth('yonke.view')
def get_piece(piece_id):
"""Get a single piece (extra helper for the detail/edit view)."""
conn = get_tenant_conn(g.tenant_id)
try:
item = yonke_service.get_piece(conn, piece_id, branch_id=_branch_id())
if not item:
return jsonify({'error': 'Piece not found'}), 404
return jsonify(item)
except Exception as e:
return jsonify({'error': str(e)}), 500
finally:
conn.close()
@yonke_bp.route('/pieces/<int:piece_id>', methods=['PUT'])
@require_auth('yonke.manage')
def update_piece(piece_id):
"""Edit the Yonke fields of a piece."""
data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id)
try:
item = yonke_service.update_piece(conn, piece_id, data, branch_id=_branch_id(data))
if not item:
conn.rollback()
return jsonify({'error': 'Piece not found'}), 404
conn.commit()
return jsonify(item)
except ValueError as e:
conn.rollback()
return jsonify({'error': str(e)}), 400
except Exception as e:
conn.rollback()
return jsonify({'error': str(e)}), 500
finally:
conn.close()
# ─── Images ────────────────────────────────────────────────────────────────
@yonke_bp.route('/pieces/<int:piece_id>/images', methods=['POST'])
@require_auth('yonke.manage')
def upload_piece_images(piece_id):
"""Upload one or more photos for a piece (multipart, repeated `file` field)."""
files = [f for f in request.files.getlist('file') if f and f.filename]
if not files:
return jsonify({'error': 'No file provided'}), 400
conn = get_tenant_conn(g.tenant_id)
try:
if not yonke_service.get_piece(conn, piece_id):
return jsonify({'error': 'Piece not found'}), 404
valid = []
for f in files:
ext = f.filename.rsplit('.', 1)[-1].lower() if '.' in f.filename else ''
if ext not in ALLOWED_EXTENSIONS:
return jsonify({
'error': f'File type not allowed. Use: {", ".join(sorted(ALLOWED_EXTENSIONS))}'
}), 400
raw = f.read()
if len(raw) > MAX_IMAGE_BYTES:
return jsonify({'error': 'File too large (max 5 MB)'}), 400
valid.append(BytesIO(raw))
images = yonke_service.add_piece_images(conn, g.tenant_id, piece_id, valid)
conn.commit()
return jsonify({'images': images})
except Exception as e:
conn.rollback()
return jsonify({'error': str(e)}), 500
finally:
conn.close()
@yonke_bp.route('/pieces/<int:piece_id>/images/<int:image_id>', methods=['DELETE'])
@require_auth('yonke.manage')
def delete_piece_image(piece_id, image_id):
"""Delete one photo (row + files); promotes another when it was primary."""
conn = get_tenant_conn(g.tenant_id)
try:
deleted = yonke_service.delete_piece_image(conn, g.tenant_id, piece_id, image_id)
if not deleted:
conn.rollback()
return jsonify({'error': 'Image not found'}), 404
conn.commit()
return jsonify({'ok': True})
except Exception as e:
conn.rollback()
return jsonify({'error': str(e)}), 500
finally:
conn.close()
# ─── AI vision grading (A4) ────────────────────────────────────────────────
#
# Front-end contract (yonke.js):
# POST /pos/api/yonke/pieces/<id>/grade-suggest
# body: multipart `file` (jpg/png/webp, <=5 MB) — or JSON {"image_base64": "..."}
# optional metadata overrides: name / category / condition
# 200 -> {condition_grade_suggested:"A|B|C|D", condition_suggested, defects[],
# confidence, notes, source, piece_id}
# 503 -> same shape but grade=null + `error` (AI backend unavailable)
# POST /pos/api/yonke/grade-suggest
# same body, no piece required (returns the suggestion only, never persists).
def _piece_primary_image_url(piece):
"""Primary photo URL for a piece, falling back to inventory.image_url."""
for img in (piece or {}).get('images') or []:
if img.get('is_primary') and img.get('url'):
return img['url']
for img in (piece or {}).get('images') or []:
if img.get('url'):
return img['url']
return (piece or {}).get('image_url') or None
def _read_local_image(url):
"""Read the bytes of an inventory_images/inventory URL stored on disk."""
if not url:
return None
idx = url.find('/images/parts/')
if idx == -1:
return None
rel = url[idx + len('/images/parts/'):]
for base in (DATA_DIR, STATIC_DIR):
try:
with open(os.path.join(base, rel), 'rb') as fh:
return fh.read()
except OSError:
continue
return None
def _read_image_request():
"""Extract the image from the request.
Returns (image_bytes, image_base64, error_response). Exactly one of
image_bytes / image_base64 is set when error_response is None.
"""
upload = request.files.get('file')
if upload and upload.filename:
raw = upload.read()
if not raw:
return None, None, (jsonify({'error': 'Empty file'}), 400)
if len(raw) > MAX_IMAGE_BYTES:
return None, None, (jsonify({'error': 'File too large (max 5 MB)'}), 400)
return raw, None, None
data = request.get_json(silent=True) or {}
b64 = (data.get('image_base64') or data.get('image') or '').strip()
if b64:
return None, b64, None
return None, None, None
def _meta_from_request(piece=None):
"""Metadata for the prompt: request overrides piece values."""
data = request.get_json(silent=True) or {}
form = request.form or {}
def pick(field):
return (form.get(field) or data.get(field) or '').strip() or None
piece = piece or {}
return {
'name': pick('name') or piece.get('name'),
'category': pick('category'),
'condition': pick('condition') or piece.get('condition') or 'usado',
}
def _piece_category_name(conn, piece_id):
cur = conn.cursor()
try:
cur.execute("""
SELECT c.name
FROM inventory i
LEFT JOIN part_categories c ON c.id = i.category_id
WHERE i.id = %s
""", (piece_id,))
row = cur.fetchone()
return (row[0] if row else None) or None
except Exception:
return None
finally:
cur.close()
def _grade_response(result, piece_id=None):
"""Normalize the service result into the HTTP response + status code."""
payload = dict(result)
if piece_id is not None:
payload['piece_id'] = piece_id
payload['available'] = result.get('condition_grade_suggested') is not None
status = 200 if payload['available'] else 503
return jsonify(payload), status
@yonke_bp.route('/pieces/<int:piece_id>/grade-suggest', methods=['POST'])
@require_auth('yonke.manage')
def grade_suggest_piece(piece_id):
"""Suggest a condition grade from a piece photo (upload/b64/primary photo)."""
image_bytes, image_b64, err = _read_image_request()
if err:
return err
conn = get_tenant_conn(g.tenant_id)
try:
piece = yonke_service.get_piece(conn, piece_id)
if not piece:
return jsonify({'error': 'Piece not found'}), 404
if not image_bytes and not image_b64:
image_bytes = _read_local_image(_piece_primary_image_url(piece))
if not image_bytes:
return jsonify({
'error': 'No image provided and the piece has no stored photo'
}), 400
meta = _meta_from_request(piece)
if not meta.get('category'):
meta['category'] = _piece_category_name(conn, piece_id)
from services import part_vision
result = part_vision.suggest_grade(
image_bytes=image_bytes, image_base64=image_b64,
name=meta['name'], category=meta['category'], condition=meta['condition'],
)
return _grade_response(result, piece_id=piece_id)
except Exception as e:
return jsonify({'error': str(e)}), 500
finally:
conn.close()
@yonke_bp.route('/grade-suggest', methods=['POST'])
@require_auth('yonke.manage')
def grade_suggest_adhoc():
"""Suggest a condition grade from a photo without requiring a piece."""
image_bytes, image_b64, err = _read_image_request()
if err:
return err
if not image_bytes and not image_b64:
return jsonify({'error': 'No image provided'}), 400
meta = _meta_from_request()
try:
from services import part_vision
result = part_vision.suggest_grade(
image_bytes=image_bytes, image_base64=image_b64,
name=meta['name'], category=meta['category'], condition=meta['condition'],
)
return _grade_response(result)
except Exception as e:
return jsonify({'error': str(e)}), 500
# ─── VIN decoder ───────────────────────────────────────────────────────────
@yonke_bp.route('/vin/<vin>', methods=['GET'])
@require_auth('yonke.view')
def decode_vin_endpoint(vin):
"""Decode a VIN with the shared NHTSA decoder."""
result = decode_vin(vin)
return jsonify({
'vin': result.get('vin', (vin or '').strip().upper()),
'make': result.get('make', '') or '',
'model': result.get('model', '') or '',
'year': result.get('year'),
'engine': result.get('engine', '') or '',
'body_type': result.get('body_type', '') or '',
'fuel_type': result.get('fuel_type', '') or '',
'drive_type': result.get('drive_type', '') or '',
'trim': result.get('trim', '') or '',
'error': result.get('error', '') or '',
})
# ─── Donors ────────────────────────────────────────────────────────────────
@yonke_bp.route('/donors', methods=['GET'])
@require_auth('yonke.view')
def list_donors():
"""List donor vehicles grouped by VIN."""
conn = get_tenant_conn(g.tenant_id)
try:
data, pagination = yonke_service.list_donors(
conn,
q=request.args.get('q'),
page=_int_arg('page', 1, 1, 10**9),
per_page=_int_arg('per_page', 25, 1, 200),
)
return jsonify({'data': data, 'pagination': pagination})
except Exception as e:
return jsonify({'error': str(e)}), 500
finally:
conn.close()
# ─── Consignments ──────────────────────────────────────────────────────────
@yonke_bp.route('/consignments', methods=['GET'])
@require_auth('yonke.view')
def list_consignments():
"""List consignments with piece counts and amounts."""
conn = get_tenant_conn(g.tenant_id)
try:
data, pagination = yonke_service.list_consignments(
conn,
status=request.args.get('status'),
page=_int_arg('page', 1, 1, 10**9),
per_page=_int_arg('per_page', 50, 1, 200),
)
return jsonify({'data': data, 'pagination': pagination})
except Exception as e:
return jsonify({'error': str(e)}), 500
finally:
conn.close()
@yonke_bp.route('/consignments', methods=['POST'])
@require_auth('yonke.manage')
def create_consignment():
"""Create a consignment."""
data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id)
try:
cons = yonke_service.create_consignment(conn, data)
conn.commit()
return jsonify(cons), 201
except ValueError as e:
conn.rollback()
return jsonify({'error': str(e)}), 400
except Exception as e:
conn.rollback()
return jsonify({'error': str(e)}), 500
finally:
conn.close()
@yonke_bp.route('/consignments/<int:consignment_id>', methods=['GET'])
@require_auth('yonke.view')
def get_consignment(consignment_id):
"""Consignment detail with its items."""
conn = get_tenant_conn(g.tenant_id)
try:
cons = yonke_service.get_consignment(conn, consignment_id)
if not cons:
return jsonify({'error': 'Consignment not found'}), 404
return jsonify(cons)
except Exception as e:
return jsonify({'error': str(e)}), 500
finally:
conn.close()
@yonke_bp.route('/consignments/<int:consignment_id>/items', methods=['POST'])
@require_auth('yonke.manage')
def add_consignment_item(consignment_id):
"""Attach a piece to a consignment (sets inventory.ownership='consigna')."""
data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id)
try:
item = yonke_service.add_consignment_item(conn, consignment_id, data)
conn.commit()
return jsonify(item), 201
except ValueError as e:
conn.rollback()
return jsonify({'error': str(e)}), 400
except Exception as e:
conn.rollback()
return jsonify({'error': str(e)}), 500
finally:
conn.close()
@yonke_bp.route('/consignments/<int:consignment_id>/settle', methods=['PUT'])
@require_auth('yonke.manage')
def settle_consignment(consignment_id):
"""Settle consignment items and return the payout summary."""
data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id)
try:
summary = yonke_service.settle_consignment(
conn, consignment_id, item_ids=data.get('item_ids')
)
if not summary:
conn.rollback()
return jsonify({'error': 'Consignment not found'}), 404
conn.commit()
return jsonify(summary)
except Exception as e:
conn.rollback()
return jsonify({'error': str(e)}), 500
finally:
conn.close()
# ═══════════════════════════════════════════════════════════════════════════
# A5 — Donor intake, reports, statements, labels, ML publish, bulk, AI
# ═══════════════════════════════════════════════════════════════════════════
def _public_base_url():
"""Tenant public base URL from the request (reverse-proxy aware)."""
proto = request.headers.get('X-Forwarded-Proto', request.scheme)
host = request.headers.get('X-Forwarded-Host', request.host)
cf = request.headers.get('CF-Visitor')
if cf and '"scheme":"https"' in cf:
proto = 'https'
if proto == 'http' and ('nexusautoparts.com.mx' in host
or request.headers.get('X-Forwarded-Ssl') == 'on'):
proto = 'https'
return f"{proto}://{host}/"
def _tenant_display_name():
"""Best-effort business name (from the master DB). Never raises."""
try:
from tenant_db import get_master_conn
conn = get_master_conn()
try:
cur = conn.cursor()
cur.execute("SELECT name FROM tenants WHERE id = %s", (g.tenant_id,))
row = cur.fetchone()
cur.close()
return (row[0] if row else None) or None
finally:
conn.close()
except Exception:
return None
def _piece_or_404(conn, piece_id):
piece = yonke_service.get_piece(conn, piece_id, branch_id=_branch_id())
if not piece:
return None, (jsonify({'error': 'Piece not found'}), 404)
return piece, None
def _read_any_image(piece):
"""Image bytes for AI endpoints: upload first, then the piece's stored photo."""
image_bytes, image_b64, err = _read_image_request()
if err:
return None, None, err
if not image_bytes and not image_b64:
image_bytes = _read_local_image(_piece_primary_image_url(piece))
if not image_bytes:
return None, None, (jsonify({
'error': 'No image provided and the piece has no stored photo'
}), 400)
return image_bytes, image_b64, None
# ─── 1. Full donor intake ──────────────────────────────────────────────────
@yonke_bp.route('/donors/full', methods=['POST'])
@require_auth('yonke.manage')
def create_donor_full():
"""Register a donor vehicle and all its pieces in one atomic call."""
data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id)
try:
result = yonke_service.create_donor_full(
conn, data, branch_id=_branch_id(data))
conn.commit()
return jsonify(result), 201
except DuplicatePartNumber:
conn.rollback()
return jsonify({'error': 'part_number already exists'}), 409
except ValueError as e:
conn.rollback()
return jsonify({'error': str(e)}), 400
except Exception as e:
conn.rollback()
return jsonify({'error': str(e)}), 500
finally:
conn.close()
# ─── 2. Cost allocation across a donor's pieces ────────────────────────────
@yonke_bp.route('/donors/<vin>/allocate-cost', methods=['POST'])
@require_auth('yonke.manage')
def allocate_donor_cost(vin):
"""Spread a donor's acquisition cost across its pieces."""
data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id)
try:
result = yonke_service.allocate_donor_cost(
conn, vin, data.get('total_cost'), method=data.get('method'))
if result is None:
conn.rollback()
return jsonify({'error': 'Donor not found or has no pieces'}), 404
conn.commit()
return jsonify(result)
except ValueError as e:
conn.rollback()
return jsonify({'error': str(e)}), 400
except Exception as e:
conn.rollback()
return jsonify({'error': str(e)}), 500
finally:
conn.close()
# ─── 3. Aging report ───────────────────────────────────────────────────────
@yonke_bp.route('/reports/aging', methods=['GET'])
@require_auth('yonke.view')
def aging_report():
"""Pieces in the yard for at least `days` days (default 90)."""
conn = get_tenant_conn(g.tenant_id)
try:
result = yonke_service.aging_report(conn, days=_int_arg('days', 90, 0, 100000))
return jsonify(result)
except Exception as e:
return jsonify({'error': str(e)}), 500
finally:
conn.close()
# ─── 4. Summary report ─────────────────────────────────────────────────────
@yonke_bp.route('/reports/summary', methods=['GET'])
@require_auth('yonke.view')
def summary_report():
"""Donor breakdown, margins, invested capital and grade mix."""
conn = get_tenant_conn(g.tenant_id)
try:
return jsonify(yonke_service.summary_report(conn))
except Exception as e:
return jsonify({'error': str(e)}), 500
finally:
conn.close()
# ─── 5-6. Consignment statement (+ print) ──────────────────────────────────
@yonke_bp.route('/consignments/<int:consignment_id>/statement', methods=['GET'])
@require_auth('yonke.view')
def consignment_statement(consignment_id):
"""Consignor account statement for an optional [from, to] period."""
conn = get_tenant_conn(g.tenant_id)
try:
statement = yonke_service.consignment_statement(
conn, consignment_id,
date_from=request.args.get('from'), date_to=request.args.get('to'))
if not statement:
return jsonify({'error': 'Consignment not found'}), 404
return jsonify(statement)
except Exception as e:
return jsonify({'error': str(e)}), 500
finally:
conn.close()
@yonke_bp.route('/consignments/<int:consignment_id>/statement/print', methods=['GET'])
@require_auth('yonke.view')
def consignment_statement_print(consignment_id):
"""Printable HTML for the consignor statement."""
conn = get_tenant_conn(g.tenant_id)
try:
statement = yonke_service.consignment_statement(
conn, consignment_id,
date_from=request.args.get('from'), date_to=request.args.get('to'))
if not statement:
return Response('<h1>404</h1>', status=404, mimetype='text/html')
html = yonke_service.render_statement_html(statement, _tenant_display_name())
return Response(html, mimetype='text/html')
finally:
conn.close()
# ─── 7. Publish one piece to MercadoLibre ──────────────────────────────────
@yonke_bp.route('/pieces/<int:piece_id>/publish-meli', methods=['POST'])
@require_auth('yonke.manage')
def publish_piece_meli(piece_id):
"""Publish this piece to MercadoLibre (degrades if ML is not configured)."""
data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id)
try:
result = yonke_service.publish_piece_to_meli(
conn, piece_id,
category_id=data.get('category_id'),
price=data.get('price'),
stock=data.get('stock'),
branch_id=_branch_id(data),
listing_type=data.get('listing_type'),
shipping_mode=data.get('shipping_mode'),
base_url=_public_base_url(),
)
if result.get('not_found'):
conn.rollback()
return jsonify({'ok': False, 'error': result.get('error')}), 404
conn.commit()
return jsonify(result), (200 if result.get('ok') else 400)
except Exception as e:
conn.rollback()
return jsonify({'ok': False, 'error': str(e)}), 500
finally:
conn.close()
# ─── 8. Bulk CSV import ────────────────────────────────────────────────────
@yonke_bp.route('/pieces/bulk', methods=['POST'])
@require_auth('yonke.manage')
def bulk_create_pieces():
"""Create pieces in bulk from a CSV (multipart `file` or raw text body)."""
upload = request.files.get('file') or request.files.get('csv')
if upload and upload.filename:
raw = upload.read()
text = raw.decode('utf-8-sig', 'replace') if isinstance(raw, bytes) else str(raw)
elif request.is_json:
data = request.get_json(silent=True) or {}
text = (data.get('csv') or '').strip()
if not text:
return jsonify({'error': 'CSV file required'}), 400
else:
raw = request.get_data() or b''
if not raw:
return jsonify({'error': 'CSV file required'}), 400
text = raw.decode('utf-8-sig', 'replace')
conn = get_tenant_conn(g.tenant_id)
try:
result = yonke_service.bulk_create_pieces(
conn, text, branch_id=_branch_id())
conn.commit()
return jsonify(result)
except ValueError as e:
conn.rollback()
return jsonify({'error': str(e)}), 400
except Exception as e:
conn.rollback()
return jsonify({'error': str(e)}), 500
finally:
conn.close()
# ─── 9. Vehicle compatibility ("sirve para") ───────────────────────────────
@yonke_bp.route('/pieces/<int:piece_id>/compat', methods=['GET'])
@require_auth('yonke.view')
def list_piece_compat(piece_id):
"""List the vehicles this piece fits."""
conn = get_tenant_conn(g.tenant_id)
try:
_, err = _piece_or_404(conn, piece_id)
if err:
return err
return jsonify({'data': yonke_service.list_piece_compat(conn, piece_id)})
except Exception as e:
return jsonify({'error': str(e)}), 500
finally:
conn.close()
@yonke_bp.route('/pieces/<int:piece_id>/compat', methods=['POST'])
@require_auth('yonke.manage')
def add_piece_compat(piece_id):
"""Add manual vehicles ({make, model, year}) to a piece."""
data = request.get_json() or {}
items = data.get('items')
if not isinstance(items, list) or not items:
return jsonify({'error': 'items required (list of {make, model, year})'}), 400
conn = get_tenant_conn(g.tenant_id)
try:
_, err = _piece_or_404(conn, piece_id)
if err:
return err
added = yonke_service.add_piece_compat(conn, piece_id, items)
conn.commit()
return jsonify({'added': added, 'count': len(added)}), 201
except Exception as e:
conn.rollback()
return jsonify({'error': str(e)}), 500
finally:
conn.close()
# ─── 10. Printable piece label (barcode) ───────────────────────────────────
@yonke_bp.route('/pieces/<int:piece_id>/label', methods=['GET'])
@require_auth('yonke.view')
def piece_label(piece_id):
"""Printable HTML label with a Code39 barcode of the auto-SKU."""
conn = get_tenant_conn(g.tenant_id)
try:
piece, err = _piece_or_404(conn, piece_id)
if err:
return err
html = yonke_service.render_label_html(conn, piece, _tenant_display_name())
return Response(html, mimetype='text/html')
except Exception as e:
return Response(f'<h1>Error</h1><p>{e}</p>', status=500, mimetype='text/html')
finally:
conn.close()
# ─── 11. Non-conversational AI ─────────────────────────────────────────────
@yonke_bp.route('/pieces/<int:piece_id>/identify', methods=['POST'])
@require_auth('yonke.manage')
def identify_piece(piece_id):
"""Vision identify: {name, category, confidence} (503 when AI is down)."""
conn = get_tenant_conn(g.tenant_id)
try:
piece, err = _piece_or_404(conn, piece_id)
if err:
return err
image_bytes, image_b64, err = _read_any_image(piece)
if err:
return err
from services import yonke_ai
result = yonke_ai.identify_piece(
image_bytes=image_bytes, image_base64=image_b64,
hint_name=piece.get('name'))
status = 200 if result.get('available') else 503
return jsonify(result), status
except Exception as e:
return jsonify({'name': None, 'category': None, 'confidence': 0.0,
'available': False, 'error': str(e)}), 503
finally:
conn.close()
@yonke_bp.route('/pieces/<int:piece_id>/price-suggest', methods=['POST'])
@require_auth('yonke.manage')
def price_suggest(piece_id):
"""Rule-based price suggestion using cost/grade plus local comparables."""
conn = get_tenant_conn(g.tenant_id)
try:
piece, err = _piece_or_404(conn, piece_id)
if err:
return err
from services import yonke_ai
return jsonify(yonke_ai.suggest_price(conn, piece))
except Exception as e:
return jsonify({'error': str(e)}), 500
finally:
conn.close()
@yonke_bp.route('/duplicates', methods=['GET'])
@require_auth('yonke.view')
def duplicates():
"""Groups of near-duplicate used pieces (semantic similarity)."""
conn = get_tenant_conn(g.tenant_id)
try:
from services import yonke_ai
threshold = request.args.get('threshold', 0.85)
return jsonify(yonke_ai.find_duplicate_groups(conn, threshold=threshold))
except Exception as e:
return jsonify({'error': str(e)}), 500
finally:
conn.close()
@yonke_bp.route('/pieces/<int:piece_id>/validate-photo', methods=['POST'])
@require_auth('yonke.manage')
def validate_photo(piece_id):
"""Vision check: is it an auto part with usable photo quality?"""
conn = get_tenant_conn(g.tenant_id)
try:
piece, err = _piece_or_404(conn, piece_id)
if err:
return err
image_bytes, image_b64, err = _read_any_image(piece)
if err:
return err
from services import yonke_ai
return jsonify(yonke_ai.validate_photo(
image_bytes=image_bytes, image_base64=image_b64))
except Exception as e:
return jsonify({'ok': True, 'issues': [], 'available': False,
'error': str(e)})
finally:
conn.close()

View File

@@ -90,26 +90,6 @@ QWEN_API_URL = os.environ.get('QWEN_API_URL', '')
QWEN_API_KEY = os.environ.get('QWEN_API_KEY', '') QWEN_API_KEY = os.environ.get('QWEN_API_KEY', '')
QWEN_MODEL = os.environ.get('QWEN_MODEL', 'qwen3.6') QWEN_MODEL = os.environ.get('QWEN_MODEL', 'qwen3.6')
# ─── Búsqueda semántica (rerank / embeddings, mismo proveedor QWEN) ────────
# Consumidas por services/semantic_search.py. OJO: se leen al importar el
# módulo — cambiar el flag requiere recargar el worker, no es hot-reload.
QWEN_RERANK_MODEL = os.environ.get('QWEN_RERANK_MODEL', 'rerank')
QWEN_EMBED_MODEL = os.environ.get('QWEN_EMBED_MODEL', 'qwen3-embedding')
# Interruptor maestro (scope 'catalog': catálogo público / API / dashboard).
SEARCH_RERANK_ENABLED = os.environ.get('SEARCH_RERANK_ENABLED', 'true').lower() == 'true'
# Scope POS (caja + chat): apagado por defecto, el rerank agrega ~0.6-1.8 s a
# una búsqueda que responde en ~18 ms (ver services/semantic_search.py).
SEARCH_RERANK_POS_ENABLED = os.environ.get('SEARCH_RERANK_POS_ENABLED', 'false').lower() == 'true'
# Un valor no numérico en el .env no debe tumbar el arranque del POS.
try:
SEARCH_RERANK_TIMEOUT = float(os.environ.get('SEARCH_RERANK_TIMEOUT', '2'))
except ValueError:
SEARCH_RERANK_TIMEOUT = 2.0
try:
SEARCH_RERANK_MAX_CANDIDATES = int(os.environ.get('SEARCH_RERANK_MAX_CANDIDATES', '50'))
except ValueError:
SEARCH_RERANK_MAX_CANDIDATES = 50
# ─── Internal Cron / Job Security ────────────────────────────────────────── # ─── Internal Cron / Job Security ──────────────────────────────────────────
INTERNAL_API_KEY = os.environ.get('INTERNAL_API_KEY', '') INTERNAL_API_KEY = os.environ.get('INTERNAL_API_KEY', '')

View File

@@ -43,8 +43,8 @@ def require_auth(*required_permissions):
# Check permissions # Check permissions
if required_permissions: if required_permissions:
missing = set(required_permissions) - g.permissions missing = set(required_permissions) - g.permissions
# owner/admin roles bypass all permission checks # owner role bypasses all permission checks
if g.employee_role not in ('owner', 'admin') and missing: if g.employee_role != 'owner' and missing:
return jsonify({'error': f'Missing permissions: {", ".join(missing)}'}), 403 return jsonify({'error': f'Missing permissions: {", ".join(missing)}'}), 403
return f(*args, **kwargs) return f(*args, **kwargs)
@@ -54,4 +54,4 @@ def require_auth(*required_permissions):
def has_permission(permission): def has_permission(permission):
"""Check if current user has a specific permission. Use inside a route.""" """Check if current user has a specific permission. Use inside a route."""
return g.employee_role in ('owner', 'admin') or permission in g.permissions return g.employee_role == 'owner' or permission in g.permissions

View File

@@ -89,10 +89,8 @@ def resolve_tenant():
g.tenant_name = None g.tenant_name = None
g.tenant_subdomain = None g.tenant_subdomain = None
# Skip for static files, health check and Prometheus metrics scrape # Skip for static files and health check
if (request.path.startswith('/pos/static/') if request.path.startswith('/pos/static/') or request.path == '/pos/health':
or request.path == '/pos/health'
or request.path == '/metrics'):
return return
subdomain = _extract_subdomain() subdomain = _extract_subdomain()

View File

@@ -52,29 +52,6 @@ MIGRATIONS = {
"v4.3": "v4.3_facturapi.sql", "v4.3": "v4.3_facturapi.sql",
"v4.4": "v4.4_workshop.sql", "v4.4": "v4.4_workshop.sql",
"v4.5": "v4.5_customer_max_discount.sql", "v4.5": "v4.5_customer_max_discount.sql",
"v4.6": "v4.6_inventory_support.sql",
"v4.7": "v4.7_workshop_business.sql",
"v4.8": "v4.8_workshop_permissions.sql",
"v4.9": "v4.9_workshop_customers_view.sql",
"v4.10": "v4.10_fleet_permissions.sql",
"v4.11": "v4.11_clean_workshop_mechanic_permissions.sql",
"v4.12": "v4.12_service_order_soft_delete.sql",
"v4.13": "v4.13_fleet_vehicle_customer.sql",
"v4.14": "v4.14_service_order_delivery_cleanup.sql",
"v4.15": "v4.15_counter_remission.sql",
"v4.16": "v4.16_remission_courier.sql",
"v4.17": "v4.17_service_order_invoice.sql",
"v4.18": "v4.18_rached_workshop.sql",
"v4.19": "v4.19_counter_inventory_create.sql",
"v4.20": "v4.20_cashier_sell_permissions.sql",
"v4.21": "v4.21_cashier_workshop_permissions.sql",
"v4.22": "v4.22_cashier_invoicing_permissions.sql",
"v4.23": "v4.23_service_order_mechanic_name.sql",
"v4.24": "v4.24_trgm_search_indexes.sql",
"v4.25": "v4.25_transfer_documents.sql",
"v4.26": "v4.26_sales_client_uuid.sql",
"v4.27": "v4.27_yonke_m1.sql",
"v4.28": "v4.28_meli_commerce.sql",
} }
@@ -130,10 +107,7 @@ def apply_migration(db_name, version):
def run_migrations(): def run_migrations():
"""Apply pending migrations to all tenants.""" """Apply pending migrations to all tenants."""
tenants = get_all_tenants() tenants = get_all_tenants()
def _version_key(v): sorted_versions = sorted(MIGRATIONS.keys())
return tuple(int(x) for x in v.lstrip('v').split('.'))
sorted_versions = sorted(MIGRATIONS.keys(), key=_version_key)
print(f"Found {len(tenants)} active tenants") print(f"Found {len(tenants)} active tenants")
print(f"Available migrations: {sorted_versions}") print(f"Available migrations: {sorted_versions}")
@@ -141,9 +115,8 @@ def run_migrations():
for tenant_id, db_name, name, current_version in tenants: for tenant_id, db_name, name, current_version in tenants:
print(f"\n[{name}] (db={db_name}, current={current_version})") print(f"\n[{name}] (db={db_name}, current={current_version})")
current_key = _version_key(current_version)
for version in sorted_versions: for version in sorted_versions:
if _version_key(version) <= current_key: if version <= current_version:
continue continue
print(f" Applying {version}...", end=" ") print(f" Applying {version}...", end=" ")

View File

@@ -1,4 +1,3 @@
-- : SKIP
-- v1.2: Add subdomain column to tenants table (nexus_master) -- v1.2: Add subdomain column to tenants table (nexus_master)
-- This migration runs against the MASTER database, not tenant databases. -- This migration runs against the MASTER database, not tenant databases.

View File

@@ -1,4 +1,3 @@
-- : SKIP
-- Marketplace orders (cross-tenant) -- Marketplace orders (cross-tenant)
-- These tables go in nexus_autoparts (master DB), not tenant DBs -- These tables go in nexus_autoparts (master DB), not tenant DBs

View File

@@ -2,20 +2,15 @@
-- Applies to: master DB (nexus_autoparts) and all tenant DBs -- Applies to: master DB (nexus_autoparts) and all tenant DBs
-- ─── MASTER DB ───────────────────────────────── -- ─── MASTER DB ─────────────────────────────────
-- These indexes only exist in the master DB (nexus_autoparts). On tenant DBs
-- (the `parts`/`warehouse_inventory` OEM tables live only in master) they are
-- skipped via existence guards so this migration can run against tenants too.
DO $$ -- Index for parts name lookups (used by get_part_types, shop supplies)
BEGIN CREATE INDEX IF NOT EXISTS idx_parts_name_part ON parts(name_part);
IF to_regclass('public.parts') IS NOT NULL THEN CREATE INDEX IF NOT EXISTS idx_parts_name_part_pattern ON parts(name_part text_pattern_ops);
EXECUTE 'CREATE INDEX IF NOT EXISTS idx_parts_name_part ON parts(name_part)';
EXECUTE 'CREATE INDEX IF NOT EXISTS idx_parts_name_part_pattern ON parts(name_part text_pattern_ops)'; -- Partial index for warehouse_inventory stock lookups (already created in Fase 1,
END IF; -- kept here for completeness on fresh installs)
IF to_regclass('public.warehouse_inventory') IS NOT NULL THEN CREATE INDEX IF NOT EXISTS idx_wi_part_stock_positive
EXECUTE 'CREATE INDEX IF NOT EXISTS idx_wi_part_stock_positive ON warehouse_inventory(part_id) WHERE stock_quantity > 0'; ON warehouse_inventory(part_id) WHERE stock_quantity > 0;
END IF;
END $$;
-- ─── TENANT DB (run on each tenant) ──────────── -- ─── TENANT DB (run on each tenant) ────────────

View File

@@ -10,12 +10,9 @@
-- To apply manually on a tenant with WhatsApp tables: -- To apply manually on a tenant with WhatsApp tables:
-- psql <tenant_db> -f pos/migrations/v3.5_whatsapp_state_machine.sql -- psql <tenant_db> -f pos/migrations/v3.5_whatsapp_state_machine.sql
-- (Remove the ': SKIP' line above before manual execution.) -- (Remove the ': SKIP' line above before manual execution.)
--
-- 2026-10-11: fixed nested dollar-quoting (the outer DO used $$ and the inner
-- trigger function also used $$, which broke the parser). Now $do$ / $fn$.
-- ============================================================ -- ============================================================
DO $do$ DO $$
BEGIN BEGIN
-- 1. Extender whatsapp_sessions con estado y contexto -- 1. Extender whatsapp_sessions con estado y contexto
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'whatsapp_sessions') THEN IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'whatsapp_sessions') THEN
@@ -44,12 +41,12 @@ BEGIN
CREATE INDEX IF NOT EXISTS idx_wa_cust_link_customer ON wa_customer_links(customer_id); CREATE INDEX IF NOT EXISTS idx_wa_cust_link_customer ON wa_customer_links(customer_id);
CREATE OR REPLACE FUNCTION update_wa_link_timestamp() CREATE OR REPLACE FUNCTION update_wa_link_timestamp()
RETURNS TRIGGER AS $fn$ RETURNS TRIGGER AS $$
BEGIN BEGIN
NEW.updated_at = NOW(); NEW.updated_at = NOW();
RETURN NEW; RETURN NEW;
END; END;
$fn$ LANGUAGE plpgsql; $$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_wa_link_updated ON wa_customer_links; DROP TRIGGER IF EXISTS trg_wa_link_updated ON wa_customer_links;
CREATE TRIGGER trg_wa_link_updated CREATE TRIGGER trg_wa_link_updated
@@ -118,4 +115,4 @@ BEGIN
FROM wa_customer_links wcl FROM wa_customer_links wcl
WHERE ws.phone = wcl.phone AND ws.customer_id IS NULL; WHERE ws.phone = wcl.phone AND ws.customer_id IS NULL;
END IF; END IF;
END $do$; END $$;

View File

@@ -1,20 +0,0 @@
-- v4.10: add fleet RBAC permissions for existing employees.
-- Owner and admin get full fleet access; accountant and workshop get read access.
INSERT INTO employee_permissions (employee_id, permission)
SELECT e.id, p.perm
FROM employees e
CROSS JOIN (VALUES
('fleet.view'),
('fleet.create'),
('fleet.edit'),
('fleet.delete')
) AS p(perm)
WHERE e.role IN ('owner', 'admin')
ON CONFLICT (employee_id, permission) DO NOTHING;
INSERT INTO employee_permissions (employee_id, permission)
SELECT e.id, 'fleet.view'
FROM employees e
WHERE e.role IN ('accountant', 'workshop')
ON CONFLICT (employee_id, permission) DO NOTHING;

View File

@@ -1,24 +0,0 @@
-- v4.11_clean_workshop_mechanic_permissions.sql
-- Restringe los permisos de los empleados con rol 'workshop' y 'mechanic'
-- para que solo tengan acceso a la seccion de Taller y no vean precios.
-- Ejecutar en cada base de datos de tenant.
-- 1. Eliminar cualquier permiso que no sea de taller para estos roles
DELETE FROM employee_permissions
WHERE employee_id IN (
SELECT id FROM employees WHERE role IN ('workshop', 'mechanic')
)
AND permission NOT IN ('workshop.view', 'workshop.edit', 'workshop.add_items');
-- 2. Asegurar que tengan los permisos base de taller
INSERT INTO employee_permissions (employee_id, permission)
SELECT e.id, p.permission
FROM employees e
CROSS JOIN (
VALUES
('workshop.view'),
('workshop.edit'),
('workshop.add_items')
) AS p(permission)
WHERE e.role IN ('workshop', 'mechanic')
ON CONFLICT (employee_id, permission) DO NOTHING;

View File

@@ -1,11 +0,0 @@
-- v4.12_service_order_soft_delete.sql
-- Soft-delete support for service orders.
ALTER TABLE service_orders
ADD COLUMN IF NOT EXISTS is_deleted BOOLEAN NOT NULL DEFAULT FALSE;
CREATE INDEX IF NOT EXISTS idx_service_orders_is_deleted
ON service_orders (is_deleted);
-- Exclude deleted orders from common queries by default via partial index helpers
-- (application filters explicitly).

View File

@@ -1,20 +0,0 @@
-- v4.13_fleet_vehicle_customer.sql
-- Link fleet vehicles to a customer.
ALTER TABLE fleet_vehicles
ADD COLUMN IF NOT EXISTS customer_id INTEGER;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'fleet_vehicles_customer_id_fkey'
) THEN
ALTER TABLE fleet_vehicles
ADD CONSTRAINT fleet_vehicles_customer_id_fkey
FOREIGN KEY (customer_id) REFERENCES customers(id);
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_fleet_vehicles_customer_id
ON fleet_vehicles (customer_id);

View File

@@ -1,10 +0,0 @@
-- Normalize service order delivery options to only "pickup" (mostrador) and "delivery" (a domicilio).
-- Convert legacy "courier" records to pickup and clear courier_id when delivery is not "delivery".
UPDATE service_orders
SET delivery_method = 'pickup'
WHERE delivery_method = 'courier';
UPDATE service_orders
SET courier_id = NULL
WHERE delivery_method IS NULL OR delivery_method != 'delivery';

View File

@@ -1,18 +0,0 @@
-- v4.15: Counter remission note support
-- Ensures employees with role 'counter' have the base permissions needed to
-- generate remission notes from the POS.
INSERT INTO employee_permissions (employee_id, permission)
SELECT e.id, p.permission
FROM employees e
CROSS JOIN (
VALUES
('pos.remission'),
('pos.view'),
('catalog.view'),
('inventory.view'),
('customers.view'),
('customers.create')
) AS p(permission)
WHERE e.role = 'counter'
ON CONFLICT (employee_id, permission) DO NOTHING;

View File

@@ -1,18 +0,0 @@
-- v4.16: Add courier assignment to counter remission notes.
ALTER TABLE sales
ADD COLUMN IF NOT EXISTS courier_id INTEGER;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'sales_courier_id_fkey'
) THEN
ALTER TABLE sales
ADD CONSTRAINT sales_courier_id_fkey
FOREIGN KEY (courier_id) REFERENCES couriers(id);
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_sales_courier_id ON sales(courier_id);

View File

@@ -1,6 +0,0 @@
-- Service order invoice requirement flag
ALTER TABLE service_orders
ADD COLUMN IF NOT EXISTS requires_invoice BOOLEAN DEFAULT FALSE;
CREATE INDEX IF NOT EXISTS idx_service_orders_requires_invoice
ON service_orders(requires_invoice);

View File

@@ -1,32 +0,0 @@
-- v4.18 Rached workshop fields
-- Extends service orders and items to match the Rached legacy workshop flow.
-- Applied to all tenants.
-- ═════════════════════════════════════════════════════════════════════════════
-- 1. SERVICE_ORDERS: capture free-text customer/vehicle/workshop data
-- ═════════════════════════════════════════════════════════════════════════════
ALTER TABLE service_orders
ADD COLUMN IF NOT EXISTS workshop_name VARCHAR(200),
ADD COLUMN IF NOT EXISTS customer_address TEXT,
ADD COLUMN IF NOT EXISTS customer_phone VARCHAR(50),
ADD COLUMN IF NOT EXISTS vehicle_description VARCHAR(300);
COMMENT ON COLUMN service_orders.workshop_name IS 'Free-text workshop/customer alias (Rached "Taller")';
COMMENT ON COLUMN service_orders.customer_address IS 'Address captured or imported for the service order';
COMMENT ON COLUMN service_orders.customer_phone IS 'Phone captured or imported for the service order';
COMMENT ON COLUMN service_orders.vehicle_description IS 'Free-text vehicle description (alternative to fleet_vehicles)';
CREATE INDEX IF NOT EXISTS idx_service_orders_workshop_name ON service_orders(workshop_name);
CREATE INDEX IF NOT EXISTS idx_service_orders_vehicle_description ON service_orders(vehicle_description);
-- ═════════════════════════════════════════════════════════════════════════════
-- 2. SERVICE_ORDER_ITEMS: mechanic per item + explicit observations
-- ═════════════════════════════════════════════════════════════════════════════
ALTER TABLE service_order_items
ADD COLUMN IF NOT EXISTS mechanic_id INTEGER REFERENCES employees(id),
ADD COLUMN IF NOT EXISTS observations TEXT;
COMMENT ON COLUMN service_order_items.mechanic_id IS 'Mechanic assigned to this specific line item';
COMMENT ON COLUMN service_order_items.observations IS 'Line-item observations (Rached detail notes)';
CREATE INDEX IF NOT EXISTS idx_service_order_items_mechanic_id ON service_order_items(mechanic_id);

View File

@@ -1,11 +0,0 @@
-- v4.19 — Grant inventory.create permission to existing counter employees
-- so they can create items and record purchase entries.
INSERT INTO employee_permissions (employee_id, permission)
SELECT e.id, 'inventory.create'
FROM employees e
WHERE e.role = 'counter'
AND e.is_active = true
AND NOT EXISTS (
SELECT 1 FROM employee_permissions ep
WHERE ep.employee_id = e.id AND ep.permission = 'inventory.create'
);

View File

@@ -1,18 +0,0 @@
-- v4.20 — Ensure existing cashier employees can sell and view quotations.
-- The cashier default set already includes these permissions, but employees
-- created before the fix may be missing them.
INSERT INTO employee_permissions (employee_id, permission)
SELECT e.id, p.perm
FROM employees e
CROSS JOIN (VALUES
('pos.sell'),
('pos.discount'),
('pos.cancel'),
('pos.view')
) AS p(perm)
WHERE e.role = 'cashier'
AND e.is_active = true
AND NOT EXISTS (
SELECT 1 FROM employee_permissions ep
WHERE ep.employee_id = e.id AND ep.permission = p.perm
);

View File

@@ -1,15 +0,0 @@
-- v4.21 — Allow existing cashier employees to create and edit service orders.
INSERT INTO employee_permissions (employee_id, permission)
SELECT e.id, p.perm
FROM employees e
CROSS JOIN (VALUES
('workshop.view'),
('workshop.edit'),
('workshop.add_items')
) AS p(perm)
WHERE e.role = 'cashier'
AND e.is_active = true
AND NOT EXISTS (
SELECT 1 FROM employee_permissions ep
WHERE ep.employee_id = e.id AND ep.permission = p.perm
);

View File

@@ -1,15 +0,0 @@
-- v4.22 -- Enable invoicing for existing cashier employees.
INSERT INTO employee_permissions (employee_id, permission)
SELECT e.id, p.perm
FROM employees e
CROSS JOIN (VALUES
('invoicing.view'),
('invoicing.create'),
('invoicing.cancel')
) AS p(perm)
WHERE e.role = 'cashier'
AND e.is_active = true
AND NOT EXISTS (
SELECT 1 FROM employee_permissions ep
WHERE ep.employee_id = e.id AND ep.permission = p.perm
);

View File

@@ -1,2 +0,0 @@
-- v4.23 -- Add free-text mechanic name for service orders.
ALTER TABLE service_orders ADD COLUMN IF NOT EXISTS mechanic_name TEXT;

View File

@@ -1,18 +0,0 @@
-- v4.24: pg_trgm search indexes for fast fuzzy inventory search.
-- The POS list endpoint uses ILIKE '%q%' on part_number/name/barcode/aliases;
-- btree indexes cannot serve those. Trigram GIN indexes make them fast even
-- with 150k+ SKU catalogs.
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX IF NOT EXISTS idx_inventory_part_trgm
ON inventory USING gin (part_number gin_trgm_ops);
CREATE INDEX IF NOT EXISTS idx_inventory_name_trgm
ON inventory USING gin (name gin_trgm_ops);
CREATE INDEX IF NOT EXISTS idx_inventory_alias_trgm
ON inventory_sku_aliases USING gin (sku gin_trgm_ops);
ANALYZE inventory;
ANALYZE inventory_sku_aliases;

View File

@@ -1,28 +0,0 @@
-- v4.25: transfer documents with in-transit state.
-- Transfers become documents: draft -> sent -> received (or cancelled).
-- Actual stock movement happens on 'receive' (origin -, destination +),
-- so goods in transit are tracked and discrepancies are explicit.
CREATE TABLE IF NOT EXISTS inventory_transfers (
id SERIAL PRIMARY KEY,
from_branch_id INTEGER NOT NULL REFERENCES branches(id),
to_branch_id INTEGER NOT NULL REFERENCES branches(id),
status VARCHAR(20) NOT NULL DEFAULT 'draft', -- draft | sent | received | cancelled
notes TEXT,
employee_id INTEGER,
created_at TIMESTAMPTZ DEFAULT NOW(),
sent_at TIMESTAMPTZ,
received_at TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS inventory_transfer_lines (
id SERIAL PRIMARY KEY,
transfer_id INTEGER NOT NULL REFERENCES inventory_transfers(id) ON DELETE CASCADE,
inventory_id INTEGER NOT NULL REFERENCES inventory(id),
quantity INTEGER NOT NULL,
qty_received INTEGER,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_transfers_status ON inventory_transfers(status);
CREATE INDEX IF NOT EXISTS idx_transfer_lines_transfer ON inventory_transfer_lines(transfer_id);

View File

@@ -1,4 +0,0 @@
-- v4.26: idempotencia para venta offline (client_uuid generado por el POS).
ALTER TABLE sales ADD COLUMN IF NOT EXISTS client_uuid VARCHAR(64);
CREATE UNIQUE INDEX IF NOT EXISTS ux_sales_client_uuid
ON sales (client_uuid) WHERE client_uuid IS NOT NULL;

View File

@@ -1,113 +0,0 @@
-- ═══════════════════════════════════════════════════════════════════════════
-- v4.27 — Yonke M1: used-parts (parts-usadas) data model
-- Additive + idempotent. Runs against tenant DBs.
--
-- Enables businesses that sell ONLY used parts (yonkes/deshuesaderos):
-- * condition (nuevo/usado/reconstruido) + grade (A/B/C/D)
-- * unique 1-of-1 pieces (is_unique_piece) with a donor vehicle
-- * multiple photos per item (inventory_images)
-- * ownership (propio/consigna) + consignment tables
-- * warranty expressed in days (for used parts)
-- ═══════════════════════════════════════════════════════════════════════════
-- ─── 1. inventory: condition / donor / ownership / unique-piece columns ─────
ALTER TABLE inventory
ADD COLUMN IF NOT EXISTS condition VARCHAR(20) DEFAULT 'nuevo',
ADD COLUMN IF NOT EXISTS condition_grade VARCHAR(2),
ADD COLUMN IF NOT EXISTS is_unique_piece BOOLEAN DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS serial_number VARCHAR(100),
ADD COLUMN IF NOT EXISTS donor_vin VARCHAR(20),
ADD COLUMN IF NOT EXISTS donor_make VARCHAR(60),
ADD COLUMN IF NOT EXISTS donor_model VARCHAR(60),
ADD COLUMN IF NOT EXISTS donor_year INTEGER,
ADD COLUMN IF NOT EXISTS donor_mileage_km INTEGER,
ADD COLUMN IF NOT EXISTS ownership VARCHAR(20) DEFAULT 'propio',
ADD COLUMN IF NOT EXISTS acquisition_cost NUMERIC(12,2);
-- CHECK constraints (guarded so re-runs are safe)
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'inventory_condition_chk') THEN
ALTER TABLE inventory ADD CONSTRAINT inventory_condition_chk
CHECK (condition IS NULL OR condition IN ('nuevo','usado','reconstruido'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'inventory_ownership_chk') THEN
ALTER TABLE inventory ADD CONSTRAINT inventory_ownership_chk
CHECK (ownership IS NULL OR ownership IN ('propio','consigna'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'inventory_grade_chk') THEN
ALTER TABLE inventory ADD CONSTRAINT inventory_grade_chk
CHECK (condition_grade IS NULL OR condition_grade IN ('A','B','C','D'));
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_inventory_condition
ON inventory(condition);
CREATE INDEX IF NOT EXISTS idx_inventory_donor_vin
ON inventory(donor_vin) WHERE donor_vin IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_inventory_donor_vehicle
ON inventory(donor_make, donor_model, donor_year) WHERE is_unique_piece;
-- ─── 2. Relax part_number NOT NULL so codeless unique pieces can exist ──────
-- A standard UNIQUE index in PostgreSQL already treats NULLs as distinct, so
-- the existing full unique index (idx_inventory_part_unique) keeps enforcing
-- uniqueness on non-null codes while allowing many NULLs. We deliberately do
-- NOT turn it into a partial index: that would break `ON CONFLICT (part_number)`
-- used by the importers.
ALTER TABLE inventory ALTER COLUMN part_number DROP NOT NULL;
-- ─── 3. inventory_images: multiple photos per item ─────────────────────────
CREATE TABLE IF NOT EXISTS inventory_images (
id SERIAL PRIMARY KEY,
inventory_id INTEGER NOT NULL REFERENCES inventory(id) ON DELETE CASCADE,
url VARCHAR(500) NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
is_primary BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_inventory_images_inv
ON inventory_images(inventory_id, sort_order);
CREATE UNIQUE INDEX IF NOT EXISTS idx_inventory_images_primary
ON inventory_images(inventory_id) WHERE is_primary;
-- Migrate the existing single image_url into the new table (first photo).
INSERT INTO inventory_images (inventory_id, url, sort_order, is_primary)
SELECT i.id, i.image_url, 0, TRUE
FROM inventory i
WHERE i.image_url IS NOT NULL AND i.image_url <> ''
AND NOT EXISTS (SELECT 1 FROM inventory_images ii WHERE ii.inventory_id = i.id);
-- ─── 4. warranties: express warranty in days (used parts) ──────────────────
DO $$
BEGIN
IF to_regclass('public.warranties') IS NOT NULL THEN
EXECUTE 'ALTER TABLE warranties ADD COLUMN IF NOT EXISTS warranty_days INTEGER';
END IF;
END $$;
-- ─── 5. Consignments (third-party parts held for sale) ─────────────────────
CREATE TABLE IF NOT EXISTS consignments (
id SERIAL PRIMARY KEY,
consignor_name VARCHAR(200) NOT NULL,
consignor_rfc VARCHAR(13),
consignor_phone VARCHAR(30),
commission_pct NUMERIC(5,2) DEFAULT 0,
status VARCHAR(20) NOT NULL DEFAULT 'open', -- open | closed | settled
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS consignment_items (
id SERIAL PRIMARY KEY,
consignment_id INTEGER NOT NULL REFERENCES consignments(id) ON DELETE CASCADE,
inventory_id INTEGER REFERENCES inventory(id) ON DELETE SET NULL,
agreed_price NUMERIC(12,2),
commission_amount NUMERIC(12,2),
status VARCHAR(20) NOT NULL DEFAULT 'in_stock', -- in_stock | sold | returned | settled
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_consignment_items_cons
ON consignment_items(consignment_id);
CREATE INDEX IF NOT EXISTS idx_consignment_items_inv
ON consignment_items(inventory_id);

View File

@@ -1,56 +0,0 @@
-- ═══════════════════════════════════════════════════════════════════════════
-- v4.28 — MeLi commerce: listing economics, inventory-match metadata
-- Additive + idempotent. Runs against tenant DBs.
--
-- Goal: make MercadoLibre listings sellable end-to-end by persisting, per
-- listing, the data that today is only computed transiently:
-- * how the ML item was linked to local inventory (backfill audit)
-- * the ML commission and the resulting net price / margin
-- * the cost at the moment of publishing (so margin stays comparable)
-- * the listing type and the description actually published
--
-- Also normalizes marketplace_questions.status to lowercase: ML returns
-- "UNANSWERED"/"ANSWERED" and the auto-answer/SLA queries filter lowercase.
-- ═══════════════════════════════════════════════════════════════════════════
-- ─── 1. marketplace_listings: match metadata + economics ────────────────────
ALTER TABLE marketplace_listings
ADD COLUMN IF NOT EXISTS listing_type_id VARCHAR(30),
ADD COLUMN IF NOT EXISTS inventory_match_source VARCHAR(40),
ADD COLUMN IF NOT EXISTS inventory_matched_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS meli_fee NUMERIC(12,2),
ADD COLUMN IF NOT EXISTS meli_fee_pct NUMERIC(6,2),
ADD COLUMN IF NOT EXISTS meli_net_price NUMERIC(12,2),
ADD COLUMN IF NOT EXISTS meli_fee_source VARCHAR(20),
ADD COLUMN IF NOT EXISTS cost_at_publish NUMERIC(12,2),
ADD COLUMN IF NOT EXISTS margin_pct NUMERIC(8,2),
ADD COLUMN IF NOT EXISTS meli_description TEXT,
ADD COLUMN IF NOT EXISTS economics_updated_at TIMESTAMPTZ;
-- Fast lookup of the listings that still need an inventory match (backfill).
CREATE INDEX IF NOT EXISTS idx_marketplace_listings_unmapped
ON marketplace_listings(channel)
WHERE inventory_id IS NULL;
-- ─── 2. marketplace_questions: status normalization ─────────────────────────
-- ML's API returns "UNANSWERED" / "ANSWERED"; the service compares lowercase.
-- Normalize historical rows so auto-answer and the SLA job see them.
UPDATE marketplace_questions
SET status = LOWER(status), updated_at = NOW()
WHERE status IS NOT NULL AND status <> LOWER(status);
-- Index for the SLA sweep: oldest unanswered questions first.
CREATE INDEX IF NOT EXISTS idx_marketplace_questions_status_date
ON marketplace_questions(status, question_date);
-- ─── 3. Allow several active ML listings per local product ──────────────────
-- The original v3.4 unique index assumed one ML listing per inventory item.
-- Real MeLi catalogs routinely publish several listings (different fitments /
-- titles) that share the same internal part number; the inventory backfill
-- must be able to link them all, and order conversion maps each order item by
-- external_item_id. Replace the uniqueness with a plain lookup index — the
-- publish path now upserts by (inventory_id, channel) explicitly.
DROP INDEX IF EXISTS idx_marketplace_listings_unique;
CREATE INDEX IF NOT EXISTS idx_marketplace_listings_inv_channel
ON marketplace_listings(inventory_id, channel) WHERE is_active = true;

View File

@@ -1,20 +0,0 @@
-- Tenant DB schema v4.6 — support tables for inventory UI
-- Product categories (hierarchical, optional)
CREATE TABLE IF NOT EXISTS categories (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
parent_id INTEGER REFERENCES categories(id) ON DELETE SET NULL,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_categories_parent ON categories(parent_id);
CREATE INDEX IF NOT EXISTS idx_categories_active ON categories(is_active);
-- Tier-based discount configuration for price tiers 2 and 3
CREATE TABLE IF NOT EXISTS tier_discounts (
tier_id INTEGER PRIMARY KEY,
tier_name VARCHAR(50) NOT NULL,
discount_pct NUMERIC(5,2) DEFAULT 0
);

View File

@@ -1,11 +0,0 @@
-- v4.6 Workshop business fields
-- Adds delivery method, courier assignment and direct-order flag to service orders.
ALTER TABLE service_orders
ADD COLUMN IF NOT EXISTS delivery_method VARCHAR(30),
ADD COLUMN IF NOT EXISTS courier_id INTEGER REFERENCES couriers(id),
ADD COLUMN IF NOT EXISTS is_direct BOOLEAN DEFAULT FALSE;
CREATE INDEX IF NOT EXISTS idx_service_orders_delivery_method ON service_orders(delivery_method);
CREATE INDEX IF NOT EXISTS idx_service_orders_courier_id ON service_orders(courier_id);
CREATE INDEX IF NOT EXISTS idx_service_orders_is_direct ON service_orders(is_direct);

View File

@@ -1,15 +0,0 @@
-- v4.8 Workshop permissions seed
-- Grants workshop permissions to existing admin employees so they keep access
-- after the new role-based restrictions are enforced.
INSERT INTO employee_permissions (employee_id, permission)
SELECT id, 'workshop.view'
FROM employees
WHERE role = 'admin'
ON CONFLICT DO NOTHING;
INSERT INTO employee_permissions (employee_id, permission)
SELECT id, 'workshop.edit'
FROM employees
WHERE role = 'admin'
ON CONFLICT DO NOTHING;

View File

@@ -1,7 +0,0 @@
-- v4.9: Grant customers.view permission to existing workshop employees
-- so they can load the customer list when creating service orders.
INSERT INTO employee_permissions (employee_id, permission)
SELECT e.id, 'customers.view'
FROM employees e
WHERE e.role = 'workshop'
ON CONFLICT (employee_id, permission) DO NOTHING;

View File

@@ -29,8 +29,6 @@ INSERT INTO accounts (code, name, parent_id, type, sat_code, is_system, is_activ
VALUES ('220', 'IVA Trasladado', (SELECT id FROM accounts WHERE code = '200'), 'pasivo', '220', true, true); VALUES ('220', 'IVA Trasladado', (SELECT id FROM accounts WHERE code = '200'), 'pasivo', '220', true, true);
INSERT INTO accounts (code, name, parent_id, type, sat_code, is_system, is_active) INSERT INTO accounts (code, name, parent_id, type, sat_code, is_system, is_active)
VALUES ('230', 'ISR por Pagar', (SELECT id FROM accounts WHERE code = '200'), 'pasivo', '230', true, true); VALUES ('230', 'ISR por Pagar', (SELECT id FROM accounts WHERE code = '200'), 'pasivo', '230', true, true);
INSERT INTO accounts (code, name, parent_id, type, sat_code, is_system, is_active)
VALUES ('240', 'Anticipos de Clientes', (SELECT id FROM accounts WHERE code = '200'), 'pasivo', '240', true, true);
-- ============================================================ -- ============================================================
-- CAPITAL (300) -- CAPITAL (300)

View File

@@ -13,7 +13,6 @@ Account codes (from SAT seed):
140 = IVA Acreditable 140 = IVA Acreditable
210 = Proveedores 210 = Proveedores
220 = IVA Trasladado 220 = IVA Trasladado
240 = Anticipos de Clientes (created on demand for layaways)
410 = Ventas 410 = Ventas
420 = Devoluciones sobre Ventas 420 = Devoluciones sobre Ventas
510 = Costo de Mercancia Vendida 510 = Costo de Mercancia Vendida
@@ -21,13 +20,10 @@ Account codes (from SAT seed):
All functions receive a psycopg2 connection (caller controls commit). All functions receive a psycopg2 connection (caller controls commit).
""" """
import logging
from datetime import date from datetime import date
from decimal import Decimal, ROUND_HALF_UP from decimal import Decimal, ROUND_HALF_UP
from flask import g from flask import g
logger = logging.getLogger('accounting')
def _to_dec(val): def _to_dec(val):
"""Convert a value to Decimal for precise arithmetic.""" """Convert a value to Decimal for precise arithmetic."""
@@ -50,64 +46,18 @@ def _get_account_id(cur, code):
def _get_account_ids(cur, codes): def _get_account_ids(cur, codes):
"""Look up multiple account IDs by code. Returns dict {code: id}.""" """Look up multiple account IDs by code. Returns dict {code: id}."""
cur.execute( result = {}
"SELECT code, id FROM accounts WHERE code = ANY(%s) AND is_active = true", for code in codes:
(list(codes),) result[code] = _get_account_id(cur, code)
) return result
rows = {row[0]: row[1] for row in cur.fetchall()}
missing = set(codes) - set(rows)
if missing:
raise ValueError(f"Account(s) with code {sorted(missing)} not found")
return rows
def _ensure_account(cur, code, name, parent_code, acc_type):
"""Return the account ID for a code, creating it if it is missing.
Tenants seeded before an auxiliary account (e.g. customer advances) was
introduced do not have it in their chart of accounts, so it is created
on first use. Creation is idempotent (accounts.code is unique).
Args:
cur: psycopg2 cursor
code: account code to look up / create
name: account name used when creating
parent_code: code of the parent account (None for roots)
acc_type: 'activo' | 'pasivo' | 'capital' | 'ingreso' | 'costo' | 'gasto'
Returns:
int: account ID
"""
cur.execute("SELECT id FROM accounts WHERE code = %s", (code,))
row = cur.fetchone()
if row:
return row[0]
parent_id = None
if parent_code:
cur.execute("SELECT id FROM accounts WHERE code = %s", (parent_code,))
parent = cur.fetchone()
parent_id = parent[0] if parent else None
cur.execute("""
INSERT INTO accounts (code, name, parent_id, type, sat_code, is_system, is_active)
VALUES (%s, %s, %s, %s, %s, false, true)
ON CONFLICT (code) DO NOTHING
RETURNING id
""", (code, name, parent_id, acc_type, code))
row = cur.fetchone()
if row:
return row[0]
cur.execute("SELECT id FROM accounts WHERE code = %s", (code,))
return cur.fetchone()[0]
def get_next_entry_number(conn): def get_next_entry_number(conn):
"""Get the next sequential journal entry number. """Get the next sequential journal entry number.
Uses a transaction-level advisory lock to prevent duplicate numbers Uses a simple MAX+1 approach. For high-concurrency environments this
when multiple journal entries are created concurrently. could be replaced with a sequence, but for single-tenant refaccionarias
the transaction-level lock from the INSERT is sufficient.
Args: Args:
conn: psycopg2 connection to tenant DB conn: psycopg2 connection to tenant DB
@@ -116,7 +66,6 @@ def get_next_entry_number(conn):
int: next entry number (starts at 1) int: next entry number (starts at 1)
""" """
cur = conn.cursor() cur = conn.cursor()
cur.execute("SELECT pg_advisory_xact_lock(hashtext('journal_entry_number'))")
cur.execute("SELECT COALESCE(MAX(entry_number), 0) + 1 FROM journal_entries") cur.execute("SELECT COALESCE(MAX(entry_number), 0) + 1 FROM journal_entries")
number = cur.fetchone()[0] number = cur.fetchone()[0]
cur.close() cur.close()
@@ -622,434 +571,6 @@ def record_cancellation_entry(conn, sale):
return entry_id return entry_id
def record_return_entry(conn, ret):
"""Generate the journal entry for a customer return (devolucion).
Reverses the revenue side of the original sale and puts the returned
goods back into inventory:
Debit 420 Devoluciones sobre Ventas = net (after discount, before tax)
Debit 220 IVA Trasladado = tax
Credit 110/111/120 (refund account) = net + tax
Debit 130 Inventarios = cost
Credit 510 Costo de Mercancia Vendida = cost
The net/tax/cost split is read from return_items joined with sale_items,
so callers only need the return id and the original sale.
Args:
conn: psycopg2 connection to tenant DB
ret: dict with keys:
id: return id
sale_id: original sale id (optional)
sale_type: 'cash' | 'credit' (optional, looked up from sales)
payment_method: original payment method (optional, looked up)
total: refund total (used only when the item detail is missing)
Returns:
int: journal entry ID
"""
cur = conn.cursor()
entry_date = date.today()
_check_period_open(cur, entry_date)
entry_number = get_next_entry_number(conn)
return_id = ret['id']
sale_id = ret.get('sale_id')
sale_type = ret.get('sale_type')
payment_method = ret.get('payment_method')
cur.execute("""
SELECT si.unit_price, si.discount_pct, si.tax_rate, si.unit_cost, ri.quantity
FROM return_items ri
JOIN sale_items si ON si.id = ri.sale_item_id
WHERE ri.return_id = %s
""", (return_id,))
item_rows = cur.fetchall()
if sale_id and (sale_type is None or payment_method is None):
cur.execute("SELECT sale_type, payment_method FROM sales WHERE id = %s", (sale_id,))
sale_row = cur.fetchone()
if sale_row:
sale_type = sale_type or sale_row[0]
payment_method = payment_method or sale_row[1]
net = Decimal('0')
tax = Decimal('0')
cost_total = Decimal('0')
for unit_price, discount_pct, tax_rate, unit_cost, quantity in item_rows:
qty = int(quantity or 0)
line_gross = (_to_dec(unit_price) * qty).quantize(TWO, ROUND_HALF_UP)
line_net = (line_gross * (Decimal('1') - _to_dec(discount_pct) / Decimal('100'))
).quantize(TWO, ROUND_HALF_UP)
net += line_net
tax += (line_net * _to_dec(tax_rate)).quantize(TWO, ROUND_HALF_UP)
cost_total += (_to_dec(unit_cost) * qty).quantize(TWO, ROUND_HALF_UP)
if net == 0 and tax == 0:
# No item detail available: fall back to the refund total (no IVA split)
net = _to_dec(ret.get('total', 0))
total = (net + tax).quantize(TWO, ROUND_HALF_UP)
accounts = _get_account_ids(cur, ['110', '111', '120', '130', '220', '420', '510'])
if sale_type == 'credit':
refund_account_id = accounts['120'] # Clientes
refund_desc = f'Clientes - Devolucion #{return_id}'
elif payment_method in ('transferencia', 'tarjeta'):
refund_account_id = accounts['111'] # Bancos
refund_desc = f'Bancos - Devolucion #{return_id}'
else:
refund_account_id = accounts['110'] # Caja
refund_desc = f'Caja - Devolucion #{return_id}'
lines = [
{
'account_id': accounts['420'],
'debit': float(net.quantize(TWO, ROUND_HALF_UP)),
'credit': 0,
'description': f'Devoluciones sobre ventas - Devolucion #{return_id}',
},
{
'account_id': refund_account_id,
'debit': 0,
'credit': float(total.quantize(TWO, ROUND_HALF_UP)),
'description': refund_desc,
},
]
if tax > 0:
lines.append({
'account_id': accounts['220'],
'debit': float(tax.quantize(TWO, ROUND_HALF_UP)),
'credit': 0,
'description': f'IVA Trasladado - Devolucion #{return_id}',
})
if cost_total > 0:
lines.append({
'account_id': accounts['130'],
'debit': float(cost_total.quantize(TWO, ROUND_HALF_UP)),
'credit': 0,
'description': f'Inventarios - Devolucion #{return_id}',
})
lines.append({
'account_id': accounts['510'],
'debit': 0,
'credit': float(cost_total.quantize(TWO, ROUND_HALF_UP)),
'description': f'Costo mercancia - Devolucion #{return_id}',
})
entry_id = _create_entry(
cur, entry_number, entry_date, 'egreso',
f'Devolucion #{return_id}' + (f' - Venta #{sale_id}' if sale_id else ''),
'return', return_id, lines
)
cur.close()
return entry_id
def record_layaway_entry(conn, layaway):
"""Generate the journal entry for a layaway (apartado) payment.
A layaway deposit is money held on behalf of the customer, not revenue,
so it is recorded as a customer advance:
Debit 110 Caja (or 111 Bancos) = amount
Credit 240 Anticipos de Clientes = amount
Args:
conn: psycopg2 connection
layaway: dict with keys:
id: layaway id
amount: float
payment_method: 'efectivo' | 'transferencia' | 'tarjeta'
Returns:
int or None: journal entry ID (None when amount <= 0)
"""
amount = _to_dec(layaway.get('amount', 0))
if amount <= 0:
return None
cur = conn.cursor()
entry_date = date.today()
_check_period_open(cur, entry_date)
entry_number = get_next_entry_number(conn)
layaway_id = layaway['id']
method = layaway.get('payment_method', 'efectivo')
accounts = _get_account_ids(cur, ['110', '111'])
advance_account = _ensure_account(cur, '240', 'Anticipos de Clientes', '200', 'pasivo')
if method in ('transferencia', 'tarjeta'):
debit_account = accounts['111']
debit_desc = f'Bancos - Apartado #{layaway_id}'
else:
debit_account = accounts['110']
debit_desc = f'Caja - Apartado #{layaway_id}'
lines = [
{
'account_id': debit_account,
'debit': float(amount.quantize(TWO, ROUND_HALF_UP)),
'credit': 0,
'description': debit_desc,
},
{
'account_id': advance_account,
'debit': 0,
'credit': float(amount.quantize(TWO, ROUND_HALF_UP)),
'description': f'Anticipos de clientes - Apartado #{layaway_id}',
},
]
entry_id = _create_entry(
cur, entry_number, entry_date, 'ingreso',
f'Apartado #{layaway_id} - anticipo ({method})',
'layaway', layaway_id, lines
)
cur.close()
return entry_id
def record_layaway_conversion_entry(conn, sale):
"""Generate the journal entry for a fully paid layaway converted to a sale.
Applies the advances collected during the layaway and recognizes the
revenue, trasladado IVA and cost of goods:
Debit 240 Anticipos de Clientes = total
Credit 410 Ventas = subtotal
Credit 220 IVA Trasladado = tax_total
Debit 510 Costo de Mercancia Vendida = cost
Credit 130 Inventarios = cost
Args:
conn: psycopg2 connection
sale: dict with keys:
id: sale id created for the layaway
layaway_id: layaway id (optional)
subtotal: float (net, after discounts, before tax)
tax_total: float
total: float (subtotal + tax_total)
items: [{unit_cost, quantity}] (optional; read from DB when absent)
Returns:
int: journal entry ID
"""
cur = conn.cursor()
entry_date = date.today()
_check_period_open(cur, entry_date)
entry_number = get_next_entry_number(conn)
sale_id = sale['id']
layaway_id = sale.get('layaway_id')
subtotal = _to_dec(sale['subtotal'])
tax_total = _to_dec(sale['tax_total'])
total = _to_dec(sale['total'])
items = sale.get('items')
if not items:
cur.execute("SELECT unit_cost, quantity FROM sale_items WHERE sale_id = %s", (sale_id,))
items = [{'unit_cost': r[0], 'quantity': r[1]} for r in cur.fetchall()]
cost_total = Decimal('0')
for item in items:
cost_total += (_to_dec(item.get('unit_cost', 0)) * int(item.get('quantity', 1))
).quantize(TWO, ROUND_HALF_UP)
accounts = _get_account_ids(cur, ['130', '220', '410', '510'])
advance_account = _ensure_account(cur, '240', 'Anticipos de Clientes', '200', 'pasivo')
lines = [
{
'account_id': advance_account,
'debit': float(total.quantize(TWO, ROUND_HALF_UP)),
'credit': 0,
'description': f'Anticipos de clientes - Apartado #{layaway_id}',
},
{
'account_id': accounts['410'],
'debit': 0,
'credit': float(subtotal.quantize(TWO, ROUND_HALF_UP)),
'description': f'Ventas - Apartado #{layaway_id} (venta #{sale_id})',
},
{
'account_id': accounts['220'],
'debit': 0,
'credit': float(tax_total.quantize(TWO, ROUND_HALF_UP)),
'description': f'IVA Trasladado - Apartado #{layaway_id} (venta #{sale_id})',
},
]
if cost_total > 0:
lines.append({
'account_id': accounts['510'],
'debit': float(cost_total.quantize(TWO, ROUND_HALF_UP)),
'credit': 0,
'description': f'Costo mercancia - Apartado #{layaway_id}',
})
lines.append({
'account_id': accounts['130'],
'debit': 0,
'credit': float(cost_total.quantize(TWO, ROUND_HALF_UP)),
'description': f'Inventarios - Apartado #{layaway_id}',
})
entry_id = _create_entry(
cur, entry_number, entry_date, 'ingreso',
f'Apartado #{layaway_id} - venta #{sale_id}',
'layaway', layaway_id, lines
)
cur.close()
return entry_id
def ensure_supplier_payments_table(conn):
"""Create the supplier_payments table if it does not exist (idempotent).
The table links each payment to the purchase order (cuentas por pagar)
it settles. It is created on first use so tenants that predate it work
without a manual migration.
"""
cur = conn.cursor()
cur.execute("""
CREATE TABLE IF NOT EXISTS supplier_payments (
id SERIAL PRIMARY KEY,
po_id INTEGER REFERENCES purchase_orders(id) ON DELETE CASCADE,
supplier_id INTEGER REFERENCES suppliers(id) ON DELETE SET NULL,
amount NUMERIC(12,2) NOT NULL,
payment_method VARCHAR(20) NOT NULL DEFAULT 'efectivo',
reference VARCHAR(100),
employee_id INTEGER REFERENCES employees(id),
created_at TIMESTAMPTZ DEFAULT NOW()
)
""")
cur.execute(
"CREATE INDEX IF NOT EXISTS idx_supplier_payments_po ON supplier_payments(po_id)"
)
cur.close()
def record_supplier_payment_entry(conn, payment):
"""Register a payment to a supplier against a purchase order (CxP).
Debit 210 Proveedores = amount
Credit 110 Caja / 111 Bancos = amount
The payment is stored in supplier_payments (created on first use) and the
purchase order is marked 'paid' once the accumulated payments cover its
total. This is what feeds the 'paid' column of the accounts-payable aging.
Args:
conn: psycopg2 connection
payment: dict with keys:
po_id: purchase order id (required)
amount: float (required, in the PO currency)
payment_method: 'efectivo' | 'transferencia' | 'tarjeta'
reference: str (optional)
Returns:
dict: {entry_id, payment_id, po_id, status, paid, balance}
"""
cur = conn.cursor()
po_id = payment.get('po_id')
amount = _to_dec(payment.get('amount', 0))
if not po_id:
raise ValueError("po_id is required")
if amount <= 0:
raise ValueError("amount must be greater than 0")
cur.execute("""
SELECT supplier_id, total, status FROM purchase_orders WHERE id = %s FOR UPDATE
""", (po_id,))
row = cur.fetchone()
if not row:
raise ValueError(f"Purchase order {po_id} not found")
supplier_id, po_total, po_status = row
if po_status == 'cancelled':
raise ValueError("Cannot pay a cancelled purchase order")
ensure_supplier_payments_table(conn)
cur.execute("SELECT COALESCE(SUM(amount), 0) FROM supplier_payments WHERE po_id = %s", (po_id,))
already_paid = _to_dec(cur.fetchone()[0])
po_total = _to_dec(po_total)
paid_total = already_paid + amount
if paid_total > po_total:
raise ValueError(
f"Payment ${amount} exceeds balance ${(po_total - already_paid).quantize(TWO)} "
f"of purchase order {po_id}"
)
entry_date = date.today()
_check_period_open(cur, entry_date)
try:
created_by = getattr(g, 'employee_id', None)
except RuntimeError:
created_by = None
cur.execute("""
INSERT INTO supplier_payments
(po_id, supplier_id, amount, payment_method, reference, employee_id)
VALUES (%s, %s, %s, %s, %s, %s)
RETURNING id
""", (
po_id, supplier_id, float(amount),
payment.get('payment_method', 'efectivo'),
payment.get('reference'), created_by
))
payment_id = cur.fetchone()[0]
accounts = _get_account_ids(cur, ['110', '111', '210'])
method = payment.get('payment_method', 'efectivo')
if method in ('transferencia', 'tarjeta'):
credit_account = accounts['111']
credit_desc = f'Bancos - Pago a proveedor OC #{po_id}'
else:
credit_account = accounts['110']
credit_desc = f'Caja - Pago a proveedor OC #{po_id}'
entry_number = get_next_entry_number(conn)
entry_id = _create_entry(
cur, entry_number, entry_date, 'egreso',
f'Pago a proveedor - OC #{po_id}',
'purchase', po_id,
[
{
'account_id': accounts['210'],
'debit': float(amount.quantize(TWO, ROUND_HALF_UP)),
'credit': 0,
'description': f'Proveedores - Pago OC #{po_id}',
},
{
'account_id': credit_account,
'debit': 0,
'credit': float(amount.quantize(TWO, ROUND_HALF_UP)),
'description': credit_desc,
},
]
)
new_status = 'paid' if paid_total >= po_total else po_status
if new_status != po_status:
cur.execute("UPDATE purchase_orders SET status = %s WHERE id = %s", (new_status, po_id))
cur.close()
return {
'entry_id': entry_id,
'payment_id': payment_id,
'po_id': po_id,
'status': new_status,
'paid': float(paid_total.quantize(TWO, ROUND_HALF_UP)),
'balance': float((po_total - paid_total).quantize(TWO, ROUND_HALF_UP)),
}
def create_manual_entry(conn, entry_data): def create_manual_entry(conn, entry_data):
"""Create a manual journal entry (type='diario'). """Create a manual journal entry (type='diario').

View File

@@ -3,17 +3,8 @@
import requests import requests
import json import json
import os as _os from config import OPENROUTER_API_KEY, HERMES_API_URL, HERMES_API_KEY
try: # config del POS; con sys.path del dashboard puede resolver el config raíz from config import QWEN_API_URL, QWEN_API_KEY, QWEN_MODEL
from config import OPENROUTER_API_KEY, HERMES_API_URL, HERMES_API_KEY
from config import QWEN_API_URL, QWEN_API_KEY, QWEN_MODEL
except ImportError:
OPENROUTER_API_KEY = _os.environ.get('OPENROUTER_API_KEY', '')
HERMES_API_URL = _os.environ.get('HERMES_API_URL', '')
HERMES_API_KEY = _os.environ.get('HERMES_API_KEY', '')
QWEN_API_URL = _os.environ.get('QWEN_API_URL', '')
QWEN_API_KEY = _os.environ.get('QWEN_API_KEY', '')
QWEN_MODEL = _os.environ.get('QWEN_MODEL', 'qwen3.6')
OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions" OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
HERMES_ENABLED = bool(HERMES_API_KEY and HERMES_API_URL) HERMES_ENABLED = bool(HERMES_API_KEY and HERMES_API_URL)
@@ -403,7 +394,7 @@ def classify_part(part_number):
for url, key, model_id in backends: for url, key, model_id in backends:
_validate_model(model_id) _validate_model(model_id)
result = _post_chat_completion(url, key, model_id, messages, max_tokens=800, temperature=0.2, timeout=70) result = _post_chat_completion(url, key, model_id, messages, max_tokens=300, temperature=0.2, timeout=15)
if result is None: if result is None:
continue continue
content = result["content"] content = result["content"]
@@ -552,7 +543,7 @@ def chat_cache_clear():
_RESPONSE_CACHE.clear() _RESPONSE_CACHE.clear()
def chat(user_message, conversation_history=None, inventory_context=None, persona=None): def chat(user_message, conversation_history=None, inventory_context=None):
"""Send a message to the AI and get a response with search suggestions. """Send a message to the AI and get a response with search suggestions.
Caches responses for repeated identical questions (subject to bypass Caches responses for repeated identical questions (subject to bypass
@@ -562,7 +553,6 @@ def chat(user_message, conversation_history=None, inventory_context=None, person
user_message: The user's chat message. user_message: The user's chat message.
conversation_history: Previous messages in the conversation. conversation_history: Previous messages in the conversation.
inventory_context: Optional inventory summary string to inject into the system prompt. inventory_context: Optional inventory summary string to inject into the system prompt.
persona: Optional persona intro (first line of system prompt) for tenant branding.
""" """
# Cache lookup — only when there's no conversation history (stateless) # Cache lookup — only when there's no conversation history (stateless)
cache_key = None cache_key = None
@@ -574,9 +564,6 @@ def chat(user_message, conversation_history=None, inventory_context=None, person
return cached return cached
system_content = SYSTEM_PROMPT system_content = SYSTEM_PROMPT
if persona:
# Replace the hardcoded first line (persona de marca por tenant)
system_content = persona + "\n" + system_content.split("\n", 1)[1]
if inventory_context: if inventory_context:
system_content = SYSTEM_PROMPT + "\n\n" + inventory_context system_content = SYSTEM_PROMPT + "\n\n" + inventory_context

View File

@@ -1,357 +0,0 @@
"""Business anomaly detection (A5): discounts, below-cost sales, cancellations
and off-hours activity.
Works on the tenant DB tables ``sale_items`` (discount_pct, unit_price,
unit_cost), ``sales`` (hour, employee, total, status) and ``audit_log``
(SALE / CANCEL). Everything is computed with a robust z-score (median + MAD)
so a handful of extreme tickets cannot drag the baseline.
Cold start is explicit: statistical rules only fire once there are at least
``min_samples`` observations in the window. The hard rules (a sale below its
recorded cost) fire regardless, because they do not need a baseline — but
``unit_cost`` of 0/NULL is treated as "unknown" and never flagged.
Public API:
detect_anomalies(conn, days=90, min_samples=5, z_threshold=3.5) -> dict
"""
import os
from datetime import datetime, timezone
import numpy as np
DEFAULT_WINDOW_DAYS = 90
DEFAULT_MIN_SAMPLES = 5
DEFAULT_Z = 3.5
# Local business hours (in BUSINESS_TZ). Sales outside are "off-hours".
BUSINESS_TZ = os.environ.get('ANOMALY_TZ', 'America/Mexico_City')
OFF_HOURS_START = int(os.environ.get('ANOMALY_OFF_HOURS_START', '7'))
OFF_HOURS_END = int(os.environ.get('ANOMALY_OFF_HOURS_END', '21'))
# Minimum repeated cancellations before we raise a flag (cold-start guard).
MIN_CANCELLATIONS = int(os.environ.get('ANOMALY_MIN_CANCELLATIONS', '3'))
CANCEL_RATE_THRESHOLD = float(os.environ.get('ANOMALY_CANCEL_RATE', '0.25'))
# A discount must be at least this high (percent points) to be worth flagging;
# keeps the detector from calling a 2% discount "anomalous" in a cash-register
# that normally discounts 0%.
DISCOUNT_ALERT_MIN = float(os.environ.get('ANOMALY_DISCOUNT_MIN', '15'))
# Off-hours needs at least this many tickets before it is reported, so a
# single late-night sale does not raise a flag.
OFF_HOURS_MIN = int(os.environ.get('ANOMALY_OFF_HOURS_MIN', '2'))
SEVERITY_POINTS = {'critical': 40, 'high': 25, 'medium': 15, 'low': 8}
def _robust_stats(values):
"""Return (median, scale) robust stats, or (None, None) for empty input.
Scale is the MAD when it is non-zero. When most values are identical
(MAD == 0 — very common with discounts, where the baseline is 0%), it
falls back to a scaled mean absolute deviation so a clear outlier is
still measurable instead of yielding a division by zero.
"""
arr = np.asarray(values, dtype=float)
if arr.size == 0:
return None, None
med = float(np.median(arr))
mad = float(np.median(np.abs(arr - med)))
if mad > 1e-9:
return med, mad
meanad = float(np.mean(np.abs(arr - med)))
if meanad > 1e-9:
return med, meanad * 1.2533
return med, 0.0
def _robust_z(value, med, scale):
"""Robust z-score (0.6745 * (x - med) / scale), None if scale == 0."""
if med is None or scale is None or scale <= 1e-9:
return None
return 0.6745 * (float(value) - med) / scale
def _is_off_hours(local_hour):
if local_hour is None:
return False
return local_hour < OFF_HOURS_START or local_hour > OFF_HOURS_END
def _anomaly(kind, severity, detail, metric=None, sample=None):
return {
'type': kind,
'severity': severity,
'detail': detail,
'metric': metric,
'sample_size': sample,
}
def _load_items(cur, days):
"""Sale items in the window, joined with their sale/employee/hour."""
cur.execute("""
SELECT si.sale_id, si.inventory_id, si.part_number, si.name,
si.quantity, si.unit_price, si.unit_cost, si.discount_pct,
s.employee_id, COALESCE(e.name, 'Empleado ' || s.employee_id),
EXTRACT(HOUR FROM (s.created_at AT TIME ZONE %s))::int AS local_hour
FROM sale_items si
JOIN sales s ON s.id = si.sale_id
LEFT JOIN employees e ON e.id = s.employee_id
WHERE s.created_at >= NOW() - make_interval(days => %s)
""", (BUSINESS_TZ, days))
return cur.fetchall()
def _load_cancellations(cur, days):
"""Cancelled-sale counts per employee (sales.status + audit_log CANCEL)."""
cur.execute("""
SELECT s.employee_id, COALESCE(e.name, 'Empleado ' || s.employee_id), COUNT(*)
FROM sales s
LEFT JOIN employees e ON e.id = s.employee_id
WHERE s.status = 'cancelled'
AND s.created_at >= NOW() - make_interval(days => %s)
GROUP BY 1, 2
""", (days,))
by_sales = {r[0]: (r[1], r[2]) for r in cur.fetchall()}
try:
cur.execute("""
SELECT employee_id, COUNT(*)
FROM audit_log
WHERE action ILIKE '%%CANCEL%%'
AND created_at >= NOW() - make_interval(days => %s)
GROUP BY 1
""", (days,))
for emp_id, cnt in cur.fetchall():
name, prev = by_sales.get(emp_id, (None, 0))
by_sales[emp_id] = (name or f'Empleado {emp_id}', max(prev, cnt))
except Exception:
cur.connection.rollback()
return by_sales
def _load_sale_counts(cur, days):
"""Total sales count per employee in the window (cancel-rate denominator)."""
cur.execute("""
SELECT employee_id, COUNT(*)
FROM sales
WHERE created_at >= NOW() - make_interval(days => %s)
GROUP BY 1
""", (days,))
return {r[0]: r[1] for r in cur.fetchall()}
def detect_anomalies(conn, days=DEFAULT_WINDOW_DAYS, min_samples=DEFAULT_MIN_SAMPLES,
z_threshold=DEFAULT_Z, limit=50):
"""Scan a tenant for business anomalies.
Returns a dict with per-employee and per-SKU findings, plus a summary and
a ``cold_start`` flag (True when there is not enough data for statistical
rules — only hard rules are reported then).
"""
days = max(1, int(days))
min_samples = max(1, int(min_samples))
cur = conn.cursor()
try:
items = _load_items(cur, days)
cancels = _load_cancellations(cur, days)
sale_counts = _load_sale_counts(cur, days)
finally:
cur.close()
total_items = len(items)
total_sales = len({r[0] for r in items})
employees_seen = {(r[8], r[9]) for r in items if r[8] is not None}
cold_start = total_items < min_samples
emp = {} # employee_id -> {name, anomalies: [], sales_count}
def add_emp(emp_id, name, anomaly):
if emp_id is None:
emp_id = 0
name = name or 'Sin empleado'
entry = emp.setdefault(emp_id, {'employee_id': emp_id, 'employee_name': name,
'anomalies': []})
entry['anomalies'].append(anomaly)
sku = {} # inventory_id -> {part_number, name, anomalies: [], tickets: n}
def add_sku(inv_id, part_number, name, anomaly):
if inv_id is None:
return
entry = sku.setdefault(inv_id, {'inventory_id': inv_id, 'part_number': part_number,
'name': name, 'anomalies': []})
entry['anomalies'].append(anomaly)
# ── 1. Sales below recorded cost (hard rule, no baseline needed) ──────
below_cost_emp = {}
for (sale_id, inv_id, part_number, name, qty, price, cost, disc,
emp_id, emp_name, hour) in items:
if cost is None or float(cost) <= 0:
continue # unknown cost — cannot judge
effective = float(price or 0) * (1 - float(disc or 0) / 100.0)
if effective < float(cost):
margin = effective - float(cost)
below_cost_emp[emp_id] = below_cost_emp.get(emp_id, 0) + 1
add_emp(emp_id, emp_name, _anomaly(
'below_cost', 'high',
f"Vendió {name} a {effective:.2f} por debajo del costo {float(cost):.2f}",
metric={'sale_id': sale_id, 'inventory_id': inv_id,
'unit_price': effective, 'unit_cost': float(cost),
'margin': round(margin, 2)},
))
add_sku(inv_id, part_number, name, _anomaly(
'below_cost', 'high',
f"Ticket #{sale_id} vendido bajo costo ({margin:.2f})",
metric={'sale_id': sale_id, 'margin': round(margin, 2)},
))
# ── 2. Discounts: robust z of the tenant-wide distribution ────────────
discounts = [float(r[7]) for r in items if r[7] is not None]
disc_med, disc_scale = _robust_stats(discounts)
if not cold_start:
for (sale_id, inv_id, part_number, name, qty, price, cost, disc,
emp_id, emp_name, hour) in items:
if disc is None or float(disc) < DISCOUNT_ALERT_MIN:
continue
z = _robust_z(float(disc), disc_med, disc_scale)
if z is not None and z > z_threshold:
add_emp(emp_id, emp_name, _anomaly(
'discount_atypical', 'medium',
f"Descuento {float(disc):.1f}% en {name} (z={z:.1f})",
metric={'sale_id': sale_id, 'discount_pct': float(disc),
'z': round(z, 2), 'baseline_median': round(disc_med, 2)},
sample=len(discounts),
))
add_sku(inv_id, part_number, name, _anomaly(
'discount_atypical', 'medium',
f"Descuento {float(disc):.1f}% (z={z:.1f})",
metric={'z': round(z, 2), 'discount_pct': float(disc)},
sample=len(discounts),
))
# Employee-level: each cajero's median discount vs the tenant baseline.
per_emp_disc = {}
for r in items:
if r[7] is not None and r[8] is not None:
per_emp_disc.setdefault(r[8], []).append(float(r[7]))
for emp_id, vals in per_emp_disc.items():
if len(vals) < min_samples:
continue
med = float(np.median(vals))
z = _robust_z(med, disc_med, disc_scale)
if med >= DISCOUNT_ALERT_MIN and z is not None and z > z_threshold:
add_emp(emp_id, dict(employees_seen).get(emp_id, f'Empleado {emp_id}'),
_anomaly(
'discount_employee', 'high',
f"Descuento mediano {med:.1f}% sobre la media del negocio ({disc_med:.1f}%)",
metric={'median_discount': round(med, 2), 'z': round(z, 2)},
sample=len(vals),
))
# SKU-level: each SKU's median discount vs the tenant baseline.
per_sku_disc = {}
for r in items:
if r[7] is not None and r[1] is not None:
per_sku_disc.setdefault(r[1], []).append(float(r[7]))
for inv_id, vals in per_sku_disc.items():
if len(vals) < min_samples:
continue
med = float(np.median(vals))
z = _robust_z(med, disc_med, disc_scale)
if med >= DISCOUNT_ALERT_MIN and z is not None and z > z_threshold:
part_number = next((r[2] for r in items if r[1] == inv_id), None)
name = next((r[3] for r in items if r[1] == inv_id), None)
add_sku(inv_id, part_number, name, _anomaly(
'discount_sku', 'medium',
f"Descuento mediano {med:.1f}% sobre la media ({disc_med:.1f}%)",
metric={'median_discount': round(med, 2), 'z': round(z, 2)},
sample=len(vals),
))
# ── 3. Repeated cancellations (employee) ──────────────────────────────
for emp_id, (emp_name, cnt) in cancels.items():
total = sale_counts.get(emp_id, cnt) + cnt
rate = cnt / total if total else 0
repeats = cnt >= MIN_CANCELLATIONS
proportional = total >= min_samples and rate >= CANCEL_RATE_THRESHOLD
if repeats or proportional:
add_emp(emp_id, emp_name, _anomaly(
'cancellations', 'high' if repeats else 'medium',
f"{cnt} cancelaciones en {days} días ({rate * 100:.0f}% de sus ventas)",
metric={'cancellations': cnt, 'sale_rate': round(rate, 3)},
sample=total,
))
# ── 4. Off-hours sales (rule; needs a repeat to avoid one-off noise) ──
off_hours = {}
for (sale_id, inv_id, part_number, name, qty, price, cost, disc,
emp_id, emp_name, hour) in items:
if _is_off_hours(hour):
off_hours.setdefault(emp_id, {'name': emp_name, 'sales': set()})
off_hours[emp_id]['sales'].add(sale_id)
for emp_id, info in off_hours.items():
count = len(info['sales'])
if count < OFF_HOURS_MIN and total_items >= min_samples:
continue
add_emp(emp_id, info['name'], _anomaly(
'off_hours', 'low' if count < 3 else 'medium',
f"{count} ventas fuera de horario ({OFF_HOURS_START}:00-{OFF_HOURS_END}:59)",
metric={'off_hours_sales': count},
sample=total_items,
))
# ── Score + serialise ─────────────────────────────────────────────────
def _score(anomalies):
total = 0.0
for a in anomalies:
total += SEVERITY_POINTS.get(a['severity'], 5)
return min(total, 100.0)
employees = []
for entry in emp.values():
entry['score'] = round(_score(entry['anomalies']), 1)
entry['sales_count'] = sale_counts.get(entry['employee_id'], 0)
employees.append(entry)
employees.sort(key=lambda e: e['score'], reverse=True)
skus = []
for entry in sku.values():
entry['score'] = round(_score(entry['anomalies']), 1)
skus.append(entry)
skus.sort(key=lambda s: s['score'], reverse=True)
by_type = {}
for entry in employees:
for a in entry['anomalies']:
by_type[a['type']] = by_type.get(a['type'], 0) + 1
for entry in skus:
for a in entry['anomalies']:
by_type[a['type']] = by_type.get(a['type'], 0) + 1
return {
'generated_at': datetime.now(timezone.utc).isoformat(),
'window_days': days,
'cold_start': cold_start,
'sample': {
'sale_items': total_items,
'sales': total_sales,
'employees': len(employees_seen),
},
'thresholds': {
'min_samples': min_samples,
'z_threshold': z_threshold,
'business_hours': [OFF_HOURS_START, OFF_HOURS_END],
'timezone': BUSINESS_TZ,
'min_cancellations': MIN_CANCELLATIONS,
},
'employees': employees[:limit],
'skus': skus[:limit],
'summary': {
'flagged_employees': len(employees),
'flagged_skus': len(skus),
'by_type': by_type,
},
}

View File

@@ -1,985 +0,0 @@
# /home/Autopartes/pos/services/b2b_service.py
"""Nexus B2B yonke marketplace: wholesale used parts between deshuesaderos.
A yonke (seller) publishes its used/rebuild pieces to a shared catalog; another
yonke (buyer) places an order. Listings live in the MASTER DB (nexus_autoparts)
so they are visible cross-tenant; the pieces themselves stay in each seller's
tenant DB.
Tables (master DB):
b2b_listings — published pieces (UNIQUE seller_tenant_id+inventory_id)
marketplace_orders — order header (buyer/seller tenant ids, total, status)
marketplace_order_items — order lines (+ listing_id link)
Order lifecycle:
pending → confirmed → shipped → received → completed
pending/confirmed → cancelled
Actor permissions per transition:
buyer : cancelled (from pending), received (from confirmed/shipped)
seller : confirmed (from pending), shipped (from pending/confirmed),
cancelled (from pending/confirmed)
both : completed (from received)
The source states are deliberately permissive (the UI exposes "Marcar
enviado"/"Recibido" a step early) so a valid actor never hits a dead end.
Conventions:
* Master reads/writes go through the `master_conn` passed by the caller
(the blueprint owns commit/rollback), EXCEPT `update_order_status`, which
commits the master transaction itself: on `received` it must persist the
status BEFORE running the cross-tenant side effects, so the order state can
never be rolled back while pieces were already created in another tenant.
* Tenant connections are opened/committed/closed inside this module, because
one order touches two tenants (buyer + seller) at once.
* Everything degrades: side effects at `received` run inside SAVEPOINTs and
never abort the order on failure — failures are returned as `warnings`.
"""
from decimal import Decimal
from psycopg2 import errors as pg_errors
from psycopg2.extras import RealDictCursor
from tenant_db import get_tenant_conn
# ─── Domain constants ──────────────────────────────────────────────────────
LISTING_STATUSES = ('available', 'reserved', 'sold', 'removed')
ORDER_STATUSES = ('pending', 'confirmed', 'shipped', 'received', 'completed', 'cancelled')
# status -> {new_status: (allowed actor roles)}
ORDER_TRANSITIONS = {
'pending': {'confirmed': ('seller',),
'shipped': ('seller',),
'cancelled': ('buyer', 'seller')},
'confirmed': {'shipped': ('seller',),
'received': ('buyer',),
'cancelled': ('seller',)},
'shipped': {'received': ('buyer',)},
'received': {'completed': ('buyer', 'seller')},
'completed': {},
'cancelled': {},
}
USED_CONDITIONS = ('usado', 'reconstruido')
class B2BError(Exception):
"""Base class for all B2B errors (mapped to HTTP by the blueprint)."""
class B2BValidationError(B2BError):
"""Bad input / illegal state transition -> HTTP 400."""
class B2BForbidden(B2BError):
"""Caller is not allowed -> HTTP 403."""
class B2BNotFound(B2BError):
"""Entity missing -> HTTP 404."""
# ─── Small helpers ─────────────────────────────────────────────────────────
def _to_int(val, default=None):
if val is None or val == '':
return default
try:
return int(float(str(val).replace(',', '')))
except (TypeError, ValueError):
return default
def _to_float(val, default=0.0):
if val is None or val == '':
return default
try:
return float(str(val).replace(',', ''))
except (TypeError, ValueError):
return default
def _jval(v):
"""Make a psycopg2 value JSON-safe (Decimal -> float, date -> isoformat)."""
if isinstance(v, Decimal):
return float(v)
if hasattr(v, 'isoformat'):
return v.isoformat()
return v
def _clean(d):
return {k: _jval(v) for k, v in (d or {}).items()}
def _cur(conn):
return conn.cursor(cursor_factory=RealDictCursor)
def _tenant_name(master_conn, tenant_id):
"""Best-effort tenant display name from the master DB (never raises)."""
if not tenant_id:
return None
try:
cur = _cur(master_conn)
cur.execute("SELECT name FROM tenants WHERE id = %s", (tenant_id,))
row = cur.fetchone()
cur.close()
return row['name'] if row else None
except Exception:
try:
master_conn.rollback()
except Exception:
pass
return None
def is_seller(master_conn, tenant_id):
"""True when the tenant has the seller flag enabled in the master DB."""
try:
cur = _cur(master_conn)
cur.execute("SELECT COALESCE(is_seller, false) AS is_seller FROM tenants WHERE id = %s",
(tenant_id,))
row = cur.fetchone()
cur.close()
return bool(row['is_seller']) if row else False
except Exception:
try:
master_conn.rollback()
except Exception:
pass
return False
def _audit_best_effort(conn, action, entity_type=None, entity_id=None, new_value=None):
try:
from services.audit import log_action
log_action(conn, action, entity_type, entity_id, new_value=new_value)
except Exception:
pass
# ─── Listings: serialization ───────────────────────────────────────────────
_LISTING_FIELDS = (
'id', 'seller_tenant_id', 'seller_inventory_id', 'part_number', 'name',
'condition', 'condition_grade', 'price_wholesale', 'donor_make',
'donor_model', 'donor_year', 'image_url', 'status', 'notes',
'created_at', 'updated_at',
)
_LISTING_COLS = ', '.join(_LISTING_FIELDS)
_LISTING_COLS_L = ', '.join('l.' + f for f in _LISTING_FIELDS)
def _listing_dict(row, seller_name=None, include_private=False):
"""Map a b2b_listings row to the API dict.
`inventory_id` is the public alias of `seller_inventory_id` (the contract
field). Extra fields (condition, notes, seller_tenant_id, timestamps) are
additive and harmless for the UI.
"""
d = _clean(dict(row))
d['inventory_id'] = d.get('seller_inventory_id')
d['price_wholesale'] = float(d.get('price_wholesale') or 0)
if seller_name is not None:
d['seller_name'] = seller_name
if not include_private:
d.pop('seller_tenant_id', None)
return d
# ─── Seller: my listings ───────────────────────────────────────────────────
def list_my_listings(master_conn, tenant_id, status=None):
"""Listings published by `tenant_id` (removed ones hidden by default)."""
cur = _cur(master_conn)
where = ["seller_tenant_id = %s"]
params = [tenant_id]
if status:
status = (status or '').strip().lower()
if status not in LISTING_STATUSES:
raise B2BValidationError(f"status inválido: {status}")
where.append("status = %s")
params.append(status)
else:
where.append("status <> 'removed'")
cur.execute(
f"SELECT {_LISTING_COLS} FROM b2b_listings "
f"WHERE {' AND '.join(where)} ORDER BY created_at DESC, id DESC",
params,
)
rows = cur.fetchall()
cur.close()
return [_listing_dict(r, include_private=True) for r in rows]
def get_listing(master_conn, listing_id):
"""Single listing + seller_name, or None."""
cur = _cur(master_conn)
cur.execute(f"SELECT {_LISTING_COLS} FROM b2b_listings WHERE id = %s", (listing_id,))
row = cur.fetchone()
cur.close()
if not row:
return None
return _listing_dict(row, seller_name=_tenant_name(master_conn, row['seller_tenant_id']))
# ─── Seller: publish / edit / remove ───────────────────────────────────────
def _fetch_piece(tenant_id, inventory_id):
"""Read the source piece from the seller's tenant DB (+ primary image)."""
conn = get_tenant_conn(tenant_id)
try:
cur = conn.cursor(cursor_factory=RealDictCursor)
cur.execute("""
SELECT id, part_number, name, condition, condition_grade,
donor_make, donor_model, donor_year, image_url, is_active
FROM inventory WHERE id = %s
""", (inventory_id,))
row = cur.fetchone()
if row:
cur.execute("""
SELECT url FROM inventory_images
WHERE inventory_id = %s
ORDER BY is_primary DESC, sort_order ASC, id ASC
LIMIT 1
""", (inventory_id,))
img = cur.fetchone()
if img and img.get('url'):
row = dict(row)
row['image_url'] = img['url']
cur.close()
return dict(row) if row else None
finally:
conn.close()
def publish_listing(master_conn, tenant_id, data):
"""Create or refresh a listing for one of the tenant's used pieces.
Raises B2BForbidden when the tenant is not a seller, B2BValidationError on
bad input / ineligible piece, B2BNotFound when the piece does not exist.
"""
data = data or {}
if not is_seller(master_conn, tenant_id):
raise B2BForbidden(
'Tu cuenta no está habilitada como vendedor (is_seller). '
'Contacta al administrador para activar el modo mayoreo.'
)
inventory_id = _to_int(data.get('inventory_id'))
if not inventory_id:
raise B2BValidationError('inventory_id requerido')
if 'price_wholesale' not in data or data.get('price_wholesale') in (None, ''):
raise B2BValidationError('price_wholesale requerido')
price = _to_float(data.get('price_wholesale'), None)
if price is None or price < 0:
raise B2BValidationError('price_wholesale inválido')
piece = _fetch_piece(tenant_id, inventory_id)
if not piece:
raise B2BNotFound('Pieza no encontrada')
condition = (piece.get('condition') or '').strip().lower()
if condition not in USED_CONDITIONS:
raise B2BValidationError(
"Solo se pueden publicar piezas usadas o reconstruidas "
f"(condition actual: {condition or 'desconocida'})"
)
if piece.get('is_active') is False:
raise B2BValidationError('La pieza no está activa en inventario')
notes = (data.get('notes') or '').strip() or None
cur = _cur(master_conn)
cur.execute("""
INSERT INTO b2b_listings
(seller_tenant_id, seller_inventory_id, part_number, name, condition,
condition_grade, price_wholesale, donor_make, donor_model, donor_year,
image_url, status, notes)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'available', %s)
ON CONFLICT (seller_tenant_id, seller_inventory_id) DO UPDATE SET
part_number = EXCLUDED.part_number,
name = EXCLUDED.name,
condition = EXCLUDED.condition,
condition_grade = EXCLUDED.condition_grade,
price_wholesale = EXCLUDED.price_wholesale,
donor_make = EXCLUDED.donor_make,
donor_model = EXCLUDED.donor_model,
donor_year = EXCLUDED.donor_year,
image_url = EXCLUDED.image_url,
notes = COALESCE(EXCLUDED.notes, b2b_listings.notes),
status = 'available',
updated_at = NOW()
RETURNING id
""", (
tenant_id, inventory_id, piece.get('part_number'), piece.get('name'),
condition or 'usado', piece.get('condition_grade'), price,
piece.get('donor_make'), piece.get('donor_model'), piece.get('donor_year'),
piece.get('image_url'), notes,
))
listing_id = cur.fetchone()['id']
cur.close()
listing = get_listing(master_conn, listing_id)
_audit_publish_best_effort(tenant_id, listing)
return listing
def _audit_publish_best_effort(tenant_id, listing):
try:
conn = get_tenant_conn(tenant_id)
try:
_audit_best_effort(conn, 'B2B_LISTING_PUBLISH', 'b2b_listing',
(listing or {}).get('id'),
{'price_wholesale': (listing or {}).get('price_wholesale')})
conn.commit()
finally:
conn.close()
except Exception:
pass
def update_listing(master_conn, tenant_id, listing_id, data):
"""Update price/status of a listing (owner only). Returns the listing."""
data = data or {}
sets = []
params = []
if 'price_wholesale' in data:
price = _to_float(data.get('price_wholesale'), None)
if price is None or price < 0:
raise B2BValidationError('price_wholesale inválido')
sets.append('price_wholesale = %s')
params.append(price)
if 'status' in data:
status = (data.get('status') or '').strip().lower()
if status not in LISTING_STATUSES:
raise B2BValidationError(f"status inválido: {status}")
sets.append('status = %s')
params.append(status)
if 'notes' in data:
sets.append('notes = %s')
params.append((data.get('notes') or '').strip() or None)
if not sets:
raise B2BValidationError('Nada que actualizar (price_wholesale, status, notes)')
cur = _cur(master_conn)
cur.execute("SELECT seller_tenant_id FROM b2b_listings WHERE id = %s", (listing_id,))
row = cur.fetchone()
if not row:
cur.close()
raise B2BNotFound('Listing no encontrado')
if row['seller_tenant_id'] != tenant_id:
cur.close()
raise B2BForbidden('Este listing pertenece a otro vendedor')
sets.append('updated_at = NOW()')
params.append(listing_id)
cur.execute(f"UPDATE b2b_listings SET {', '.join(sets)} WHERE id = %s", params)
cur.close()
return get_listing(master_conn, listing_id)
def remove_listing(master_conn, tenant_id, listing_id):
"""Soft-delete a listing (status='removed'). Owner only. Returns bool."""
cur = _cur(master_conn)
cur.execute("SELECT seller_tenant_id FROM b2b_listings WHERE id = %s", (listing_id,))
row = cur.fetchone()
if not row:
cur.close()
raise B2BNotFound('Listing no encontrado')
if row['seller_tenant_id'] != tenant_id:
cur.close()
raise B2BForbidden('Este listing pertenece a otro vendedor')
cur.execute(
"UPDATE b2b_listings SET status = 'removed', updated_at = NOW() WHERE id = %s",
(listing_id,),
)
cur.close()
return True
# ─── Buyer: catalog ────────────────────────────────────────────────────────
def list_catalog(master_conn, buyer_tenant_id, filters=None, page=1, per_page=12):
"""Available listings from OTHER tenants, with seller_name and pagination."""
filters = filters or {}
page = max(_to_int(page, 1) or 1, 1)
per_page = min(max(_to_int(per_page, 12) or 12, 1), 100)
where = ["l.status = 'available'", 'l.seller_tenant_id <> %s']
params = [buyer_tenant_id]
q = (filters.get('q') or '').strip()
if q:
where.append("(l.name ILIKE %s OR l.part_number ILIKE %s "
"OR l.donor_make ILIKE %s OR l.donor_model ILIKE %s)")
params.extend([f'%{q}%'] * 4)
for field, col in (('make', 'l.donor_make'), ('model', 'l.donor_model')):
val = (filters.get(field) or '').strip()
if val:
where.append(f"{col} ILIKE %s")
params.append(f'%{val}%')
year = _to_int(filters.get('year'))
if year:
where.append("l.donor_year = %s")
params.append(year)
grade = (filters.get('grade') or '').strip().upper()
if grade:
where.append("UPPER(l.condition_grade) = %s")
params.append(grade)
where_sql = ' AND '.join(where)
cur = _cur(master_conn)
cur.execute(f"""
SELECT COUNT(*) AS total
FROM b2b_listings l
WHERE {where_sql}
""", params)
total = cur.fetchone()['total'] or 0
cur.execute(f"""
SELECT {_LISTING_COLS_L}, t.name AS seller_name
FROM b2b_listings l
LEFT JOIN tenants t ON t.id = l.seller_tenant_id
WHERE {where_sql}
ORDER BY l.created_at DESC, l.id DESC
LIMIT %s OFFSET %s
""", params + [per_page, (page - 1) * per_page])
rows = cur.fetchall()
cur.close()
items = [_listing_dict(r, seller_name=r.get('seller_name')) for r in rows]
pagination = {
'page': page, 'per_page': per_page, 'total': total,
'total_pages': (total + per_page - 1) // per_page,
}
return items, pagination
# ─── Orders ────────────────────────────────────────────────────────────────
_ORDER_COLS = (
'id, buyer_tenant_id, seller_tenant_id, buyer_name, seller_name, total, '
'status, notes, created_at, updated_at'
)
def _order_items(master_conn, order_ids):
"""Bulk-load order lines grouped by order_id."""
if not order_ids:
return {}
cur = _cur(master_conn)
cur.execute("""
SELECT id, order_id, listing_id, part_number, part_name, quantity,
unit_price, subtotal
FROM marketplace_order_items
WHERE order_id = ANY(%s)
ORDER BY order_id, id
""", (list(order_ids),))
rows = cur.fetchall()
cur.close()
out = {}
for r in rows:
item = _clean(dict(r))
item['name'] = item.get('part_name') # UI-friendly alias
out.setdefault(r['order_id'], []).append(item)
return out
def _order_dict(row, items, role):
d = _clean(dict(row))
d['total'] = float(d.get('total') or 0)
d['role'] = role
if role == 'buyer':
d['other_party'] = d.get('seller_name')
d['other_tenant_id'] = d.get('seller_tenant_id')
else:
d['other_party'] = d.get('buyer_name')
d['other_tenant_id'] = d.get('buyer_tenant_id')
d['items'] = items or []
return d
def create_order(master_conn, buyer_tenant_id, items, notes=None):
"""Create a pending order + items and reserve the listings.
All lines must reference available listings from ONE other tenant.
"""
if not isinstance(items, list) or not items:
raise B2BValidationError('items requerido (lista no vacía)')
wanted = []
for it in items:
if not isinstance(it, dict):
raise B2BValidationError('cada item debe ser un objeto {listing_id, quantity}')
listing_id = _to_int(it.get('listing_id'))
qty = _to_int(it.get('quantity'), 1)
if not listing_id:
raise B2BValidationError('listing_id requerido en cada item')
if qty is None or qty < 1:
raise B2BValidationError('quantity debe ser >= 1')
wanted.append({'listing_id': listing_id, 'quantity': qty})
listing_ids = [w['listing_id'] for w in wanted]
cur = _cur(master_conn)
cur.execute(
f"SELECT {_LISTING_COLS} FROM b2b_listings WHERE id = ANY(%s) FOR UPDATE",
(listing_ids,),
)
rows = {r['id']: dict(r) for r in cur.fetchall()}
cur.close()
sellers = set()
resolved = []
for w in wanted:
listing = rows.get(w['listing_id'])
if not listing:
raise B2BNotFound(f"Listing {w['listing_id']} no encontrado")
if listing['status'] != 'available':
raise B2BValidationError(
f"Listing {w['listing_id']} no está disponible (status: {listing['status']})")
if listing['seller_tenant_id'] == buyer_tenant_id:
raise B2BValidationError('No puedes comprar tus propias piezas')
sellers.add(listing['seller_tenant_id'])
resolved.append((listing, w['quantity']))
if len(sellers) > 1:
raise B2BValidationError(
'Una orden solo puede tener un vendedor; crea una orden por vendedor')
seller_tenant_id = sellers.pop()
total = round(sum(
_to_float(listing['price_wholesale'], 0) * qty
for listing, qty in resolved), 2)
buyer_name = _tenant_name(master_conn, buyer_tenant_id) or ''
seller_name = _tenant_name(master_conn, seller_tenant_id) or ''
cur = _cur(master_conn)
cur.execute("""
INSERT INTO marketplace_orders
(buyer_tenant_id, seller_tenant_id, buyer_name, seller_name,
total, status, notes)
VALUES (%s, %s, %s, %s, %s, 'pending', %s)
RETURNING id
""", (buyer_tenant_id, seller_tenant_id, buyer_name, seller_name,
total, (notes or '').strip() or None))
order_id = cur.fetchone()['id']
order_items = []
for listing, qty in resolved:
price = _to_float(listing['price_wholesale'], 0)
subtotal = round(price * qty, 2)
cur.execute("""
INSERT INTO marketplace_order_items
(order_id, listing_id, part_number, part_name, quantity, unit_price, subtotal)
VALUES (%s, %s, %s, %s, %s, %s, %s)
""", (order_id, listing['id'], listing.get('part_number'), listing.get('name'),
qty, price, subtotal))
order_items.append({
'listing_id': listing['id'],
'part_number': listing.get('part_number'),
'part_name': listing.get('name'),
'name': listing.get('name'),
'quantity': qty,
'unit_price': price,
'subtotal': subtotal,
})
cur.execute("""
UPDATE b2b_listings SET status = 'reserved', updated_at = NOW()
WHERE id = ANY(%s)
""", ([listing['id'] for listing, _ in resolved],))
cur.close()
return {
'id': order_id,
'total': total,
'status': 'pending',
'seller_tenant_id': seller_tenant_id,
'buyer_tenant_id': buyer_tenant_id,
'seller_name': seller_name,
'buyer_name': buyer_name,
'items': order_items,
}
def list_orders(master_conn, tenant_id, role):
"""Orders where `tenant_id` is buyer ('buyer') or seller ('seller')."""
col = 'buyer_tenant_id' if role == 'buyer' else 'seller_tenant_id'
cur = _cur(master_conn)
cur.execute(
f"SELECT {_ORDER_COLS} FROM marketplace_orders WHERE {col} = %s "
f"ORDER BY created_at DESC, id DESC",
(tenant_id,),
)
rows = cur.fetchall()
cur.close()
items = _order_items(master_conn, [r['id'] for r in rows])
return [_order_dict(r, items.get(r['id']), role) for r in rows]
def get_order(master_conn, tenant_id, order_id):
"""Order detail; only participants. Raises B2BNotFound / B2BForbidden."""
cur = _cur(master_conn)
cur.execute(f"SELECT {_ORDER_COLS} FROM marketplace_orders WHERE id = %s", (order_id,))
row = cur.fetchone()
cur.close()
if not row:
raise B2BNotFound('Orden no encontrada')
if row['buyer_tenant_id'] == tenant_id:
role = 'buyer'
elif row['seller_tenant_id'] == tenant_id:
role = 'seller'
else:
raise B2BForbidden('No participas en esta orden')
items = _order_items(master_conn, [order_id])
return _order_dict(row, items.get(order_id), role)
def update_order_status(master_conn, tenant_id, order_id, new_status, branch_id=None):
"""Apply an order status transition (role-checked).
Commits the master transaction itself: when moving to `received` the status
must be durable before the cross-tenant side effects (marking the seller's
piece sold and creating the piece in the buyer's inventory) run.
"""
new_status = (new_status or '').strip().lower()
if new_status not in ORDER_STATUSES:
raise B2BValidationError(
f"status inválido: {new_status} (válidos: {', '.join(ORDER_STATUSES)})")
cur = _cur(master_conn)
cur.execute(
f"SELECT {_ORDER_COLS} FROM marketplace_orders WHERE id = %s FOR UPDATE",
(order_id,),
)
row = cur.fetchone()
if not row:
cur.close()
raise B2BNotFound('Orden no encontrada')
if row['buyer_tenant_id'] == tenant_id:
role = 'buyer'
elif row['seller_tenant_id'] == tenant_id:
role = 'seller'
else:
cur.close()
raise B2BForbidden('No participas en esta orden')
current = row['status'] or 'pending'
allowed = ORDER_TRANSITIONS.get(current, {}).get(new_status)
if not allowed:
cur.close()
raise B2BValidationError(f"No se puede pasar de '{current}' a '{new_status}'")
if role not in allowed:
cur.close()
raise B2BForbidden(
f"Rol '{role}' no puede cambiar la orden de '{current}' a '{new_status}'")
cur.execute(
"UPDATE marketplace_orders SET status = %s, updated_at = NOW() WHERE id = %s",
(new_status, order_id),
)
cur.close()
master_conn.commit()
result = get_order(master_conn, tenant_id, order_id)
result['previous_status'] = current
if new_status == 'received':
extras = _finalize_received(master_conn, dict(row), branch_id=branch_id)
result.update(extras)
return result
# ─── Received: cross-tenant side effects (best-effort) ─────────────────────
def _resolve_branch(conn, branch_id=None):
if branch_id:
return branch_id
try:
cur = conn.cursor()
cur.execute("SELECT id FROM branches ORDER BY id LIMIT 1")
r = cur.fetchone()
cur.close()
return r[0] if r else None
except Exception:
return None
def _mark_seller_piece_sold(conn, inventory_id):
"""Mark the seller's inventory row as sold (SAVEPOINT, never raises).
`inventory` has no `status` column in the standard tenant schema, so we set
`is_active = false` (piece no longer available) when it is absent; if a
tenant ever adds `inventory.status`, we write 'sold' there instead.
"""
cur = conn.cursor()
cur.execute("SAVEPOINT b2b_sold")
try:
cur.execute("""
SELECT 1 FROM information_schema.columns
WHERE table_name = 'inventory' AND column_name = 'status' LIMIT 1
""")
has_status = cur.fetchone() is not None
if has_status:
cur.execute("UPDATE inventory SET status = 'sold' WHERE id = %s", (inventory_id,))
else:
cur.execute("UPDATE inventory SET is_active = false WHERE id = %s", (inventory_id,))
cur.execute("RELEASE SAVEPOINT b2b_sold")
return True
except Exception:
try:
cur.execute("ROLLBACK TO SAVEPOINT b2b_sold")
except Exception:
pass
return False
finally:
cur.close()
def _insert_buyer_inventory(conn, item, listing, unit_price, warnings):
"""Insert the purchased piece into the buyer's inventory (SAVEPOINT)."""
from services.yonke_service import generate_unique_sku
name = (listing.get('name') or item.get('part_name') or item.get('part_number')
or 'Pieza usada')
candidate = (listing.get('part_number') or item.get('part_number') or '').strip() or None
grade = listing.get('condition_grade')
cur = conn.cursor()
inv_id = None
for _attempt in range(5):
if candidate is None:
try:
candidate = generate_unique_sku(conn, None)
except Exception:
candidate = None
cur.execute("SAVEPOINT b2b_inv")
try:
cur.execute("""
INSERT INTO inventory
(part_number, name, unit, cost, price_1, price_2, price_3, tax_rate,
condition, condition_grade, is_unique_piece,
donor_make, donor_model, donor_year, ownership, image_url, is_active)
VALUES (%s, %s, 'PZA', %s, %s, %s, %s, %s,
'usado', %s, true,
%s, %s, %s, 'propio', %s, true)
RETURNING id
""", (
candidate, name, unit_price, unit_price, unit_price, unit_price, 0.16,
grade, listing.get('donor_make'), listing.get('donor_model'),
listing.get('donor_year'), listing.get('image_url'),
))
inv_id = cur.fetchone()[0]
cur.execute("RELEASE SAVEPOINT b2b_inv")
break
except pg_errors.UniqueViolation:
cur.execute("ROLLBACK TO SAVEPOINT b2b_inv")
candidate = None
except Exception as e:
try:
cur.execute("ROLLBACK TO SAVEPOINT b2b_inv")
except Exception:
pass
warnings.append(f"no se pudo crear la pieza en el inventario del comprador: {e}")
cur.close()
return None
cur.close()
return inv_id
def _seed_stock(conn, inv_id, branch_id, qty, cost, warnings):
"""Seed initial stock for a freshly created buyer piece (SAVEPOINT)."""
if not branch_id or qty <= 0:
return
from services.inventory_engine import record_initial
cur = conn.cursor()
cur.execute("SAVEPOINT b2b_stock")
try:
record_initial(conn, inv_id, branch_id, qty, cost=cost)
cur.execute("RELEASE SAVEPOINT b2b_stock")
except Exception as e:
try:
cur.execute("ROLLBACK TO SAVEPOINT b2b_stock")
except Exception:
pass
warnings.append(f"no se registró la operación de stock: {e}")
# Idempotent safety net: the trigger normally creates this row already.
try:
cur.execute("""
INSERT INTO inventory_stock (inventory_id, branch_id, stock)
VALUES (%s, %s, %s)
ON CONFLICT (inventory_id, branch_id) DO UPDATE SET stock = EXCLUDED.stock
""", (inv_id, branch_id, qty))
except Exception as e:
warnings.append(f"no se pudo fijar el stock inicial: {e}")
cur.close()
def _finalize_received(master_conn, order, branch_id=None):
"""On `received`: sell the listings/seller pieces and stock the buyer.
Best-effort by design: every step runs in its own SAVEPOINT / connection,
failures are collected in `warnings`, and the order is never aborted.
"""
warnings = []
extras = {'listings_sold': 0, 'inventory_created': 0, 'warnings': warnings}
order_id = order['id']
seller_id = order['seller_tenant_id']
buyer_id = order['buyer_tenant_id']
cur = _cur(master_conn)
cur.execute("""
SELECT id, listing_id, part_number, part_name, quantity, unit_price
FROM marketplace_order_items WHERE order_id = %s ORDER BY id
""", (order_id,))
items = [dict(r) for r in cur.fetchall()]
listing_ids = [it['listing_id'] for it in items if it['listing_id']]
listings = {}
if listing_ids:
cur.execute(f"SELECT {_LISTING_COLS} FROM b2b_listings WHERE id = ANY(%s)",
(listing_ids,))
for r in cur.fetchall():
listings[r['id']] = dict(r)
# Legacy rows may lack listing_id: fall back to (seller, part_number).
for it in items:
if it['listing_id'] in listings:
continue
cur.execute(f"""
SELECT {_LISTING_COLS} FROM b2b_listings
WHERE seller_tenant_id = %s AND part_number = %s
ORDER BY (status = 'reserved') DESC, id DESC LIMIT 1
""", (seller_id, it.get('part_number')))
r = cur.fetchone()
if r:
listings[it['listing_id']] = dict(r)
# 1) Mark listings sold (master DB) ------------------------------------
sold = 0
for it in items:
listing = listings.get(it['listing_id']) if it['listing_id'] else None
if not listing and it['listing_id']:
continue
try:
if listing:
cur.execute(
"UPDATE b2b_listings SET status = 'sold', updated_at = NOW() WHERE id = %s",
(listing['id'],))
else:
cur.execute("""
UPDATE b2b_listings SET status = 'sold', updated_at = NOW()
WHERE seller_tenant_id = %s AND part_number = %s AND status = 'reserved'
""", (seller_id, it.get('part_number')))
sold += cur.rowcount
except Exception as e:
master_conn.rollback()
warnings.append(f"no se pudo marcar el listing como vendido: {e}")
cur = _cur(master_conn)
try:
master_conn.commit()
except Exception as e:
warnings.append(f"no se pudo confirmar el estado de los listings: {e}")
extras['listings_sold'] = sold
cur.close()
# 2) Mark the seller's pieces sold (seller tenant DB) ------------------
seller_conn = None
try:
seller_conn = get_tenant_conn(seller_id)
for it in items:
listing = listings.get(it['listing_id'])
inv_id = _to_int((listing or {}).get('seller_inventory_id'))
if inv_id:
_mark_seller_piece_sold(seller_conn, inv_id)
seller_conn.commit()
except Exception as e:
warnings.append(f"no se pudieron marcar las piezas del vendedor: {e}")
if seller_conn is not None:
try:
seller_conn.rollback()
except Exception:
pass
finally:
if seller_conn is not None:
try:
seller_conn.close()
except Exception:
pass
# 3) Create the purchased pieces in the buyer's inventory --------------
buyer_conn = None
try:
buyer_conn = get_tenant_conn(buyer_id)
buyer_branch = _resolve_branch(buyer_conn, branch_id)
created = 0
for it in items:
listing = listings.get(it['listing_id']) or {}
qty = max(_to_int(it.get('quantity'), 1) or 1, 1)
price = _to_float(it.get('unit_price'), 0)
inv_id = _insert_buyer_inventory(buyer_conn, it, listing, price, warnings)
if inv_id:
_seed_stock(buyer_conn, inv_id, buyer_branch, qty, price, warnings)
created += 1
buyer_conn.commit()
extras['inventory_created'] = created
try:
_audit_best_effort(buyer_conn, 'B2B_ORDER_RECEIVED', 'marketplace_order',
order_id, {'inventory_created': created})
buyer_conn.commit()
except Exception:
pass
except Exception as e:
warnings.append(f"no se pudo crear el inventario del comprador: {e}")
if buyer_conn is not None:
try:
buyer_conn.rollback()
except Exception:
pass
finally:
if buyer_conn is not None:
try:
buyer_conn.close()
except Exception:
pass
return extras
# ─── Seller status (UI badge) ──────────────────────────────────────────────
def get_seller_status(master_conn, tenant_id):
"""{is_seller, listings_count, incoming_orders} for the current tenant."""
seller = is_seller(master_conn, tenant_id)
cur = _cur(master_conn)
cur.execute("""
SELECT COUNT(*) AS c FROM b2b_listings
WHERE seller_tenant_id = %s AND status <> 'removed'
""", (tenant_id,))
listings_count = cur.fetchone()['c'] or 0
cur.execute("""
SELECT COUNT(*) AS c FROM marketplace_orders
WHERE seller_tenant_id = %s AND status IN ('pending', 'confirmed', 'shipped')
""", (tenant_id,))
incoming = cur.fetchone()['c'] or 0
cur.execute("""
SELECT COUNT(*) AS c FROM marketplace_orders WHERE seller_tenant_id = %s
""", (tenant_id,))
total_incoming = cur.fetchone()['c'] or 0
cur.close()
return {
'is_seller': seller,
'listings_count': listings_count,
'incoming_orders': incoming,
'incoming_orders_total': total_incoming,
}

View File

@@ -3,21 +3,13 @@
from tenant_db import get_master_conn from tenant_db import get_master_conn
PLANS = { PLANS = {
# `demo` is a time-limited full trial (see scripts/expire_demos.py). It has
# no resource limits on purpose: a prospect must be able to import their
# real inventory without hitting a wall. Access is gated by the
# subscriptions.expires_at date, not by data volume.
'demo': {'name': 'Demo', 'price_mxn': 0, 'max_products': None, 'max_employees': None, 'max_branches': None},
# Yonke: plan comercial para negocios de SOLO partes usadas (sin catálogo TecDoc)
'yonke': {'name': 'Yonke', 'price_mxn': 299, 'max_products': None, 'max_employees': 10, 'max_branches': 1},
'free': {'name': 'Gratis', 'price_mxn': 0, 'max_products': 100, 'max_employees': 2, 'max_branches': 1}, 'free': {'name': 'Gratis', 'price_mxn': 0, 'max_products': 100, 'max_employees': 2, 'max_branches': 1},
'basic': {'name': 'Basico', 'price_mxn': 499, 'max_products': 5000, 'max_employees': 5, 'max_branches': 2}, 'basic': {'name': 'Basico', 'price_mxn': 499, 'max_products': 5000, 'max_employees': 5, 'max_branches': 2},
'standard': {'name': 'Estandar', 'price_mxn': 999, 'max_products': 20000, 'max_employees': 10, 'max_branches': 3},
'pro': {'name': 'Pro', 'price_mxn': 1499, 'max_products': 50000, 'max_employees': 15, 'max_branches': 5}, 'pro': {'name': 'Pro', 'price_mxn': 1499, 'max_products': 50000, 'max_employees': 15, 'max_branches': 5},
'enterprise': {'name': 'Enterprise', 'price_mxn': 3999, 'max_products': None, 'max_employees': None, 'max_branches': None}, 'enterprise': {'name': 'Enterprise', 'price_mxn': 3999, 'max_products': None, 'max_employees': None, 'max_branches': None},
} }
PLAN_ORDER = ['free', 'demo', 'basic', 'standard', 'pro', 'enterprise'] PLAN_ORDER = ['free', 'basic', 'pro', 'enterprise']
def get_plan(tenant_id): def get_plan(tenant_id):

View File

@@ -737,10 +737,9 @@ def get_nexpart_groups_for_vehicle(master_conn, mye_id, tenant_conn=None):
classified = _classify_vehicle_parts(master_conn, mye_id) classified = _classify_vehicle_parts(master_conn, mye_id)
# Merge local inventory and supplier catalog classifications # Merge local inventory and supplier catalog classifications
# supplier_catalog vive en la maestra: siempre se puede clasificar.
_merge_classified(classified, _classify_supplier_catalog_parts(master_conn, mye_id))
if tenant_conn: if tenant_conn:
_merge_classified(classified, _classify_inventory_parts(tenant_conn, mye_id)) _merge_classified(classified, _classify_inventory_parts(tenant_conn, mye_id))
_merge_classified(classified, _classify_supplier_catalog_parts(master_conn, mye_id))
result = [] result = []
# Iterate in canonical Nexpart order so the UI is stable # Iterate in canonical Nexpart order so the UI is stable
@@ -770,10 +769,9 @@ def get_nexpart_subgroups_for_vehicle(master_conn, mye_id, group_slug, tenant_co
) )
classified = _classify_vehicle_parts(master_conn, mye_id) classified = _classify_vehicle_parts(master_conn, mye_id)
# supplier_catalog vive en la maestra: siempre se puede clasificar.
_merge_classified(classified, _classify_supplier_catalog_parts(master_conn, mye_id))
if tenant_conn: if tenant_conn:
_merge_classified(classified, _classify_inventory_parts(tenant_conn, mye_id)) _merge_classified(classified, _classify_inventory_parts(tenant_conn, mye_id))
_merge_classified(classified, _classify_supplier_catalog_parts(master_conn, mye_id))
group_data = classified.get(group_slug, {}) group_data = classified.get(group_slug, {})
if not group_data: if not group_data:
return [] return []
@@ -1174,10 +1172,9 @@ def get_parts_for_nexpart_triple(master_conn, mye_id, group_slug, subgroup_slug,
""" """
classified = _classify_vehicle_parts(master_conn, mye_id) classified = _classify_vehicle_parts(master_conn, mye_id)
# Also merge inventory and supplier catalog so the leaf IDs may be a mix # Also merge inventory and supplier catalog so the leaf IDs may be a mix
# supplier_catalog vive en la maestra: siempre se puede clasificar.
_merge_classified(classified, _classify_supplier_catalog_parts(master_conn, mye_id))
if tenant_conn: if tenant_conn:
_merge_classified(classified, _classify_inventory_parts(tenant_conn, mye_id)) _merge_classified(classified, _classify_inventory_parts(tenant_conn, mye_id))
_merge_classified(classified, _classify_supplier_catalog_parts(master_conn, mye_id))
all_ids = ( all_ids = (
classified classified
@@ -1330,10 +1327,9 @@ def get_nexpart_part_types_for_vehicle(master_conn, mye_id, group_slug, subgroup
) )
classified = _classify_vehicle_parts(master_conn, mye_id) classified = _classify_vehicle_parts(master_conn, mye_id)
# supplier_catalog vive en la maestra: siempre se puede clasificar.
_merge_classified(classified, _classify_supplier_catalog_parts(master_conn, mye_id))
if tenant_conn: if tenant_conn:
_merge_classified(classified, _classify_inventory_parts(tenant_conn, mye_id)) _merge_classified(classified, _classify_inventory_parts(tenant_conn, mye_id))
_merge_classified(classified, _classify_supplier_catalog_parts(master_conn, mye_id))
subgroup_data = classified.get(group_slug, {}).get(subgroup_slug, {}) subgroup_data = classified.get(group_slug, {}).get(subgroup_slug, {})
if not subgroup_data: if not subgroup_data:
return [] return []
@@ -1856,169 +1852,49 @@ def get_part_detail(master_conn, part_id, tenant_conn, branch_id):
} }
def _alternatives_from_supplier_catalog(conn, catalog_id=None, part_number=None, def _get_alternatives(cur, part_id):
name=None, category=None, limit=20, """Get cross-references + aftermarket parts for a given OEM part."""
use_semantic=True,
semantic_threshold=0.72,
semantic_pool=80,
semantic_timeout=20.0):
"""Alternatives for a supplier-catalog item, on live (non-TecDoc) tables.
Two sources, in order:
1. exact — other ``supplier_catalog`` items that share an interchange
cross-reference or an SKU with the base item
(``supplier_catalog_interchange``).
2. semantic — ``semantic_search.find_similar`` over same-category catalog
names (substitute parts that are worded differently). Degrades to
nothing when the embedding provider is unavailable.
Works with either a connection or a cursor (``conn``/``cur``); the caller
owns it. Never raises on the semantic step.
Returns list[dict]: {part_number, manufacturer, name, type, similarity,
catalog_id} — the caller adds stock/bodega fields.
"""
cur = conn.cursor() if hasattr(conn, 'cursor') else conn
results = [] results = []
seen = set()
base = None # Cross-references (other OEM numbers that reference this part)
if catalog_id:
cur.execute(
"SELECT id, supplier_name, sku, name, category FROM supplier_catalog "
"WHERE id = %s AND is_active = true", (catalog_id,)
)
base = cur.fetchone()
elif part_number:
cur.execute(
"SELECT id, supplier_name, sku, name, category FROM supplier_catalog "
"WHERE UPPER(sku) = UPPER(%s) AND is_active = true LIMIT 1",
(part_number,)
)
base = cur.fetchone()
if base:
base_id, base_sup, base_sku, base_name, base_cat = base
else:
base_id, base_sup, base_sku = None, None, None
base_name = name
base_cat = category
if base_id:
seen.add(base_id)
# ── 1. Exact interchange / SKU cross-references ────────────────────────
if base_id:
cur.execute(
"SELECT DISTINCT part_number FROM supplier_catalog_interchange "
"WHERE catalog_id = %s AND part_number IS NOT NULL",
(base_id,)
)
xrefs = [r[0] for r in cur.fetchall()]
keys_upper = set()
if base_sku:
keys_upper.add(str(base_sku).upper())
for x in xrefs:
keys_upper.add(str(x).upper())
keys_upper.discard(None)
if keys_upper:
key_list = list(keys_upper)
# (a) catalog items that *are* one of our keys
cur.execute(""" cur.execute("""
SELECT id, supplier_name, sku, name FROM supplier_catalog SELECT pcr.cross_reference_number, pcr.source_ref
WHERE UPPER(sku) = ANY(%s) AND is_active = true FROM part_cross_references pcr
LIMIT %s WHERE pcr.part_id = %s
""", (key_list, limit * 2)) LIMIT 50
exact_rows = list(cur.fetchall()) """, (part_id,))
# (b) catalog items whose interchange list contains one of our keys for r in cur.fetchall():
cur.execute("""
SELECT DISTINCT sc.id, sc.supplier_name, sc.sku, sc.name
FROM supplier_catalog sc
JOIN supplier_catalog_interchange sci ON sci.catalog_id = sc.id
WHERE UPPER(sci.part_number) = ANY(%s) AND sc.is_active = true
LIMIT %s
""", (key_list, limit * 2))
exact_rows.extend(cur.fetchall())
for cid, sup, sku, cname in exact_rows:
if not cid or cid in seen:
continue
seen.add(cid)
results.append({ results.append({
'part_number': sku, 'part_number': r[0],
'manufacturer': sup, 'manufacturer': r[1] or 'OEM Cross-Ref',
'name': cname, 'name': None,
'type': 'cross_reference', 'type': 'cross_reference',
'similarity': None,
'catalog_id': cid,
'local_stock': 0, 'local_stock': 0,
'bodega_count': 0, 'bodega_count': 0,
}) })
if len(results) >= limit:
break
# ── 2. Semantic substitutes (same category, different wording) ───────── # Aftermarket alternatives
if use_semantic and base_name and len(results) < limit:
pool_cat = base_cat
try:
if pool_cat:
cur.execute(""" cur.execute("""
SELECT id, supplier_name, sku, name FROM supplier_catalog SELECT ap.part_number, m.name_manufacture,
WHERE is_active = true AND category = %s AND id <> COALESCE(%s, -1) COALESCE(ap.name_es, ap.name_aftermarket_parts) AS name
LIMIT %s FROM aftermarket_parts ap
""", (pool_cat, base_id, semantic_pool)) JOIN manufacturers m ON m.id_manufacture = ap.manufacturer_id
else: WHERE ap.oem_part_id = %s
cur.execute(""" LIMIT 50
SELECT id, supplier_name, sku, name FROM supplier_catalog """, (part_id,))
WHERE is_active = true AND id <> COALESCE(%s, -1) for r in cur.fetchall():
LIMIT %s
""", (base_id, semantic_pool))
pool = [r for r in cur.fetchall() if r[0] not in seen]
except Exception:
pool = []
if pool:
try:
from services import semantic_search as ss
ranked = ss.find_similar(base_name, [r[3] for r in pool],
timeout=semantic_timeout)
except Exception:
ranked = None
if ranked:
for item in ranked:
if item['similarity'] < semantic_threshold:
continue
cid, sup, sku, cname = pool[item['index']]
if cid in seen:
continue
seen.add(cid)
results.append({ results.append({
'part_number': sku, 'part_number': r[0],
'manufacturer': sup, 'manufacturer': r[1],
'name': cname, 'name': r[2],
'type': 'semantic', 'type': 'aftermarket',
'similarity': round(item['similarity'], 3),
'catalog_id': cid,
'local_stock': 0, 'local_stock': 0,
'bodega_count': 0, 'bodega_count': 0,
}) })
if len(results) >= limit:
break
return results return results
def _get_alternatives(cur, part_id):
"""Back-compat shim: alternatives for a supplier-catalog id.
The retired TecDoc tables (``part_cross_references`` / ``aftermarket_parts``)
are gone; this now delegates to ``_alternatives_from_supplier_catalog``.
"""
return _alternatives_from_supplier_catalog(cur, catalog_id=part_id)
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# SMART SEARCH # SMART SEARCH
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
@@ -2063,46 +1939,7 @@ def _search_meili_fallback(master_conn, q, limit):
return None return None
def _rerank_document(item): def smart_search(master_conn, q, tenant_conn, branch_id, limit=50, mye_id=None, tenant_id=None):
"""Texto con el que el reranker semántico evalúa un resultado."""
parts = [
item.get('name'),
item.get('oem_part_number'),
item.get('brand') or item.get('vehicle_info'),
]
return ' '.join(str(p) for p in parts if p)
def _apply_rerank(query, results, scope='pos'):
"""Reordena ``results`` por relevancia semántica (QWEN /rerank).
Solo cambia el ORDEN: la paginación, el tope de ``limit`` y el filtrado por
vehículo/sucursal ya ocurrieron antes y quedan intactos.
Degradación obligatoria (``semantic_search.rerank`` nunca lanza): si el
rerank está apagado, sin configurar, tarda más que el timeout, falla o
devuelve un orden inconsistente, se conserva el orden original.
"""
if not results or not scope:
return results
try:
from services import semantic_search as ss
if not ss.is_rerank_enabled(scope):
return results
head = results[:ss.max_candidates()]
order = ss.rerank_order(query, [_rerank_document(r) for r in head])
reordered = [head[i] for i in order if 0 <= i < len(head)]
if len(reordered) != len(head):
# Salvaguarda: un orden incompleto perdería items → orden original.
return results
return reordered + results[len(head):]
except Exception as e:
print(f"[catalog_service] rerank omitido: {e}")
return results
def smart_search(master_conn, q, tenant_conn, branch_id, limit=50, mye_id=None,
tenant_id=None, rerank_scope='pos'):
"""Search parts by part number or text. Enriches with local stock. """Search parts by part number or text. Enriches with local stock.
Strategy: Strategy:
@@ -2110,9 +1947,6 @@ def smart_search(master_conn, q, tenant_conn, branch_id, limit=50, mye_id=None,
2. Fallback to PostgreSQL tsvector / ILIKE if Meilisearch is down 2. Fallback to PostgreSQL tsvector / ILIKE if Meilisearch is down
3. Search local inventory items by part_number or name 3. Search local inventory items by part_number or name
4. Always enriches results with local stock from tenant DB 4. Always enriches results with local stock from tenant DB
5. Reordena los candidatos con rerank semántico según ``rerank_scope``
('pos' por defecto = flag SEARCH_RERANK_POS_ENABLED, apagado; el
catálogo público/dashboard pasa 'catalog' → SEARCH_RERANK_ENABLED)
""" """
q = q.strip() q = q.strip()
if not q or len(q) < 2: if not q or len(q) < 2:
@@ -2140,7 +1974,6 @@ def smart_search(master_conn, q, tenant_conn, branch_id, limit=50, mye_id=None,
'image_url': li['image_url'], 'image_url': li['image_url'],
'local_stock': li['stock'], 'local_stock': li['stock'],
'local_price': li['price_1'], 'local_price': li['price_1'],
'brand': li.get('brand'),
'vehicle_info': '', 'vehicle_info': '',
'source': 'local_inventory', 'source': 'local_inventory',
}) })
@@ -2150,9 +1983,6 @@ def smart_search(master_conn, q, tenant_conn, branch_id, limit=50, mye_id=None,
# ── Inject supplier catalog items ─────────────────────────────────────── # ── Inject supplier catalog items ───────────────────────────────────────
if master_conn: if master_conn:
# Meilisearch first (typo tolerance, sub-100ms); SQL fallback below.
supplier_items = _search_meili_supplier_catalog(q, limit)
if not supplier_items:
supplier_items = _search_supplier_catalog(master_conn, q, mye_id, limit, tenant_id=tenant_id) supplier_items = _search_supplier_catalog(master_conn, q, mye_id, limit, tenant_id=tenant_id)
for si in supplier_items: for si in supplier_items:
if f"sc:{si['id']}" in seen_local_ids: if f"sc:{si['id']}" in seen_local_ids:
@@ -2173,7 +2003,7 @@ def smart_search(master_conn, q, tenant_conn, branch_id, limit=50, mye_id=None,
if len(results) >= limit: if len(results) >= limit:
break break
return _apply_rerank(q, results, rerank_scope) return results
def _get_supplier_prices(master_conn, tenant_id, catalog_ids): def _get_supplier_prices(master_conn, tenant_id, catalog_ids):
@@ -2197,33 +2027,6 @@ def _get_supplier_prices(master_conn, tenant_id, catalog_ids):
return prices return prices
def _search_meili_supplier_catalog(q, limit):
"""Typo-tolerant supplier catalog search via Meilisearch.
Returns [] on any failure so the SQL fallback always applies.
Result shape matches _search_supplier_catalog.
"""
try:
import meilisearch
from services.meili_search import MEILI_URL, MEILI_API_KEY
client = meilisearch.Client(MEILI_URL, MEILI_API_KEY)
res = client.index('nexus_supplier_catalog').search(q, {'limit': limit})
out = []
for h in res.get('hits', []):
out.append({
'id': h['id'],
'sku': h.get('sku'),
'name': h.get('name'),
'image_url': h.get('image_url'),
'category': h.get('category'),
'supplier_price': h.get('supplier_price'),
'supplier_currency': None,
})
return out
except Exception:
return []
def _search_supplier_catalog(master_conn, q, mye_id, limit, tenant_id=None): def _search_supplier_catalog(master_conn, q, mye_id, limit, tenant_id=None):
"""Search supplier catalog items by SKU or name. """Search supplier catalog items by SKU or name.
@@ -2289,181 +2092,13 @@ def _search_supplier_catalog(master_conn, q, mye_id, limit, tenant_id=None):
return results return results
def _tenant_db_name(tenant_conn):
"""db_name de la BD del tenant a partir de su conexión (o None)."""
try:
return tenant_conn.info.dbname
except Exception:
return None
def _meili_inventory_ids(db_name, q, limit):
"""IDs de inventario que Meilisearch considera relevantes.
None ⇒ Meilisearch no disponible (usar SQL); [] ⇒ cero resultados.
"""
try:
from services.meili_search import search_inventory
except Exception:
return None
hits = search_inventory(db_name, q, limit=limit)
if hits is None:
return None
return [h['id'] for h in hits if h.get('id') is not None]
def _hydrate_inventory_rows(tenant_conn, ids, mye_id, branch_id):
"""Trae stock/precio frescos de Postgres para los IDs que dio Meilisearch.
Respeta el filtro de vehículo/sucursal y conserva el orden de relevancia
de Meilisearch.
"""
cur = tenant_conn.cursor()
try:
where_vehicle = ""
params = []
if mye_id:
where_vehicle = """ AND EXISTS (
SELECT 1 FROM inventory_vehicle_compat ivc
WHERE ivc.inventory_id = i.id AND ivc.model_year_engine_id = %s)"""
if branch_id:
if mye_id:
params = [branch_id, list(ids), mye_id]
else:
params = [branch_id, list(ids)]
cur.execute("""
SELECT i.id, i.part_number, i.name, i.image_url, i.price_1,
i.brand, COALESCE(s.stock, 0) AS stock
FROM inventory i
LEFT JOIN inventory_stock_summary s
ON s.inventory_id = i.id AND s.branch_id = %s
WHERE i.is_active = true AND i.id = ANY(%s)""" + where_vehicle, params)
else:
params = [list(ids), mye_id] if mye_id else [list(ids)]
cur.execute("""
SELECT i.id, i.part_number, i.name, i.image_url, i.price_1,
i.brand, COALESCE(SUM(s.stock), 0) AS stock
FROM inventory i
LEFT JOIN inventory_stock_summary s ON s.inventory_id = i.id
WHERE i.is_active = true AND i.id = ANY(%s)""" + where_vehicle + """
GROUP BY i.id, i.part_number, i.name, i.image_url, i.price_1, i.brand""", params)
rows = cur.fetchall()
finally:
cur.close()
by_id = {r[0]: r for r in rows}
ordered = [by_id[i] for i in ids if i in by_id]
return [{
'inventory_id': r[0],
'part_number': r[1],
'name': r[2],
'image_url': r[3],
'price_1': float(r[4]) if r[4] is not None else None,
'brand': r[5],
'stock': int(r[6]) if r[6] is not None else 0,
} for r in ordered]
def _search_inventory_by_alias(tenant_conn, q, mye_id, branch_id, limit):
"""Inventory items whose SKU alias matches the query (A6 cross-refs).
``inventory_sku_aliases`` holds 2-3 alternative supplier SKUs per item;
this lets a search by an alternate number find the product even when the
main part_number does not match.
"""
clean_q = q.replace(' ', '').upper()
params = []
if branch_id:
stock_join = ("LEFT JOIN inventory_stock_summary s "
"ON s.inventory_id = i.id AND s.branch_id = %s")
params.append(branch_id)
else:
stock_join = "LEFT JOIN inventory_stock_summary s ON s.inventory_id = i.id"
params.append(f'%{clean_q}%')
mye_clause = ""
if mye_id:
mye_clause = """ AND EXISTS (
SELECT 1 FROM inventory_vehicle_compat ivc
WHERE ivc.inventory_id = i.id AND ivc.model_year_engine_id = %s)"""
params.append(mye_id)
params.append(limit)
cur = tenant_conn.cursor()
try:
cur.execute(f"""
SELECT DISTINCT i.id, i.part_number, i.name, i.image_url,
i.price_1, i.brand, COALESCE(s.stock, 0)
FROM inventory i
JOIN inventory_sku_aliases a
ON a.inventory_id = i.id AND a.is_active = true
{stock_join}
WHERE i.is_active = true
AND REPLACE(UPPER(a.sku), ' ', '') LIKE %s
{mye_clause}
ORDER BY i.name
LIMIT %s
""", params)
rows = cur.fetchall()
finally:
cur.close()
return [{
'inventory_id': r[0],
'part_number': r[1],
'name': r[2],
'image_url': r[3],
'price_1': float(r[4]) if r[4] is not None else None,
'brand': r[5],
'stock': int(r[6]) if r[6] is not None else 0,
} for r in rows]
def _augment_with_aliases(tenant_conn, rows, q, mye_id, branch_id, limit):
"""Append alias-borne matches to a result list, de-duplicated by id."""
try:
alias_rows = _search_inventory_by_alias(tenant_conn, q, mye_id, branch_id, limit)
except Exception:
try:
tenant_conn.rollback()
except Exception:
pass
return rows
if not alias_rows:
return rows
seen = {r['inventory_id'] for r in rows}
merged = list(rows)
for r in alias_rows:
if r['inventory_id'] not in seen:
merged.append(r)
seen.add(r['inventory_id'])
return merged[:limit]
def _search_local_inventory(tenant_conn, q, mye_id, branch_id, limit): def _search_local_inventory(tenant_conn, q, mye_id, branch_id, limit):
"""Search tenant inventory items by part_number or name. """Search tenant inventory items by part_number or name.
If mye_id is provided, only returns items compatible with that vehicle. If mye_id is provided, only returns items compatible with that vehicle.
Primero consulta Meilisearch (índice `nexus_inventory_<db>` del tenant) e
hidrata desde Postgres para tener stock/precio frescos. Si Meilisearch
está apagado, falla o no devuelve nada, cae al SQL de abajo (ILIKE).
En ambos caminos se añaden los items cuyo SKU alias (cross-ref) coincide.
""" """
if tenant_conn is None: if tenant_conn is None:
return [] return []
db_name = _tenant_db_name(tenant_conn)
if db_name:
ids = _meili_inventory_ids(db_name, q, limit)
if ids:
try:
rows = _hydrate_inventory_rows(tenant_conn, ids, mye_id, branch_id)
except Exception:
try:
tenant_conn.rollback()
except Exception:
pass
rows = []
if rows:
return _augment_with_aliases(tenant_conn, rows, q, mye_id, branch_id, limit)
cur = tenant_conn.cursor() cur = tenant_conn.cursor()
clean_q = q.replace(' ', '').upper() clean_q = q.replace(' ', '').upper()
@@ -2487,7 +2122,7 @@ def _search_local_inventory(tenant_conn, q, mye_id, branch_id, limit):
if branch_id: if branch_id:
cur.execute(f""" cur.execute(f"""
SELECT i.id, i.part_number, i.name, i.image_url, SELECT i.id, i.part_number, i.name, i.image_url,
i.price_1, i.brand, COALESCE(s.stock, 0) as stock i.price_1, COALESCE(s.stock, 0) as stock
FROM inventory i FROM inventory i
JOIN inventory_vehicle_compat ivc ON ivc.inventory_id = i.id JOIN inventory_vehicle_compat ivc ON ivc.inventory_id = i.id
LEFT JOIN inventory_stock_summary s LEFT JOIN inventory_stock_summary s
@@ -2502,7 +2137,7 @@ def _search_local_inventory(tenant_conn, q, mye_id, branch_id, limit):
else: else:
cur.execute(f""" cur.execute(f"""
SELECT i.id, i.part_number, i.name, i.image_url, SELECT i.id, i.part_number, i.name, i.image_url,
i.price_1, i.brand, COALESCE(SUM(s.stock), 0) as stock i.price_1, COALESCE(SUM(s.stock), 0) as stock
FROM inventory i FROM inventory i
JOIN inventory_vehicle_compat ivc ON ivc.inventory_id = i.id JOIN inventory_vehicle_compat ivc ON ivc.inventory_id = i.id
LEFT JOIN inventory_stock_summary s ON s.inventory_id = i.id LEFT JOIN inventory_stock_summary s ON s.inventory_id = i.id
@@ -2510,7 +2145,7 @@ def _search_local_inventory(tenant_conn, q, mye_id, branch_id, limit):
AND i.is_active = true AND i.is_active = true
AND (REPLACE(UPPER(i.part_number), ' ', '') LIKE %s AND (REPLACE(UPPER(i.part_number), ' ', '') LIKE %s
OR {_SQL_UNACCENT} LIKE %s) OR {_SQL_UNACCENT} LIKE %s)
GROUP BY i.id, i.part_number, i.name, i.image_url, i.price_1, i.brand GROUP BY i.id, i.part_number, i.name, i.image_url, i.price_1
ORDER BY i.name ORDER BY i.name
LIMIT %s LIMIT %s
""", (mye_id, f'%{clean_q}%', f'%{_q_unaccent}%', limit)) """, (mye_id, f'%{clean_q}%', f'%{_q_unaccent}%', limit))
@@ -2519,7 +2154,7 @@ def _search_local_inventory(tenant_conn, q, mye_id, branch_id, limit):
if branch_id: if branch_id:
cur.execute(f""" cur.execute(f"""
SELECT i.id, i.part_number, i.name, i.image_url, SELECT i.id, i.part_number, i.name, i.image_url,
i.price_1, i.brand, COALESCE(s.stock, 0) as stock i.price_1, COALESCE(s.stock, 0) as stock
FROM inventory i FROM inventory i
LEFT JOIN inventory_stock_summary s LEFT JOIN inventory_stock_summary s
ON s.inventory_id = i.id AND s.branch_id = %s ON s.inventory_id = i.id AND s.branch_id = %s
@@ -2532,32 +2167,30 @@ def _search_local_inventory(tenant_conn, q, mye_id, branch_id, limit):
else: else:
cur.execute(f""" cur.execute(f"""
SELECT i.id, i.part_number, i.name, i.image_url, SELECT i.id, i.part_number, i.name, i.image_url,
i.price_1, i.brand, COALESCE(SUM(s.stock), 0) as stock i.price_1, COALESCE(SUM(s.stock), 0) as stock
FROM inventory i FROM inventory i
LEFT JOIN inventory_stock_summary s ON s.inventory_id = i.id LEFT JOIN inventory_stock_summary s ON s.inventory_id = i.id
WHERE i.is_active = true WHERE i.is_active = true
AND (REPLACE(UPPER(i.part_number), ' ', '') LIKE %s AND (REPLACE(UPPER(i.part_number), ' ', '') LIKE %s
OR {_SQL_UNACCENT} LIKE %s) OR {_SQL_UNACCENT} LIKE %s)
GROUP BY i.id, i.part_number, i.name, i.image_url, i.price_1, i.brand GROUP BY i.id, i.part_number, i.name, i.image_url, i.price_1
ORDER BY i.name ORDER BY i.name
LIMIT %s LIMIT %s
""", (f'%{clean_q}%', f'%{_q_unaccent}%', limit)) """, (f'%{clean_q}%', f'%{_q_unaccent}%', limit))
rows = cur.fetchall() rows = cur.fetchall()
cur.close() cur.close()
results = [ return [
{ {
'inventory_id': r[0], 'inventory_id': r[0],
'part_number': r[1], 'part_number': r[1],
'name': r[2], 'name': r[2],
'image_url': r[3], 'image_url': r[3],
'price_1': float(r[4]) if r[4] is not None else None, 'price_1': float(r[4]) if r[4] is not None else None,
'brand': r[5], 'stock': int(r[5]) if r[5] is not None else 0,
'stock': int(r[6]) if r[6] is not None else 0,
} }
for r in rows for r in rows
] ]
return _augment_with_aliases(tenant_conn, results, q, mye_id, branch_id, limit)
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
@@ -2699,50 +2332,11 @@ def get_bodega_availability(master_conn, part_id):
] ]
def get_alternatives(master_conn, catalog_id=None, part_number=None, name=None, def get_alternatives(master_conn, part_id):
category=None, tenant_conn=None, branch_id=None, limit=20, """Public wrapper: get cross-references + aftermarket parts for a given OEM part."""
use_semantic=True, semantic_threshold=0.72,
semantic_timeout=20.0):
"""Public wrapper: alternatives for a supplier-catalog item.
Rebuilt on the live tables (``supplier_catalog`` /
``supplier_catalog_interchange``) + semantic substitutes; the retired
TecDoc ``aftermarket_parts`` / ``part_cross_references`` tables are no
longer queried.
Pass ``catalog_id`` (``supplier_catalog.id``, e.g. ``inventory.catalog_part_id``)
and/or ``part_number``. When ``tenant_conn`` is given, each alternative is
enriched with local stock (``local_stock``) from that tenant.
NOTE: the dashboard endpoints that still serve the old TecDoc-based result
are ``dashboard/server.py`` ``/api/catalog/part/<id>`` (line ~1326),
``/api/parts/<id>/alternatives`` (line ~1938) and
``/api/parts/<id>/aftermarket`` (line ~4916); POS uses
``GET /catalog/part/<id>`` (``blueprints/catalog_bp.py``) via
``get_part_detail``. Those call sites are owned by other workstreams.
"""
cur = master_conn.cursor() cur = master_conn.cursor()
try: results = _get_alternatives(cur, part_id)
results = _alternatives_from_supplier_catalog(
cur, catalog_id=catalog_id, part_number=part_number, name=name,
category=category, limit=limit, use_semantic=use_semantic,
semantic_threshold=semantic_threshold,
semantic_timeout=semantic_timeout,
)
finally:
cur.close() cur.close()
if tenant_conn is not None and results:
alt_pns = [a['part_number'] for a in results if a.get('part_number')]
alt_cids = [a['catalog_id'] for a in results if a.get('catalog_id')]
try:
local = _get_local_stock_bulk(tenant_conn, branch_id, alt_pns, alt_cids)
except Exception:
local = {}
for a in results:
l = local.get(a.get('part_number')) or local.get(f"cat:{a.get('catalog_id')}")
a['local_stock'] = l['stock'] if l else 0
a.setdefault('bodega_count', 0)
return results return results

View File

@@ -63,28 +63,6 @@ def _format_rate(val):
return str(_to_dec(val).quantize(SIX, ROUND_HALF_UP)) return str(_to_dec(val).quantize(SIX, ROUND_HALF_UP))
def _item_sat_keys(item):
"""Return (clave_prod_serv, clave_unidad), falling back to SAT rules.
Precedence: the value carried by the sale item, then the rule engine over
the item name/brand/category (``services.sat_keys``), then the historic
hard-coded default. Never raises, so a CFDI can always be built.
"""
cps = item.get('clave_prod_serv')
cu = item.get('clave_unidad')
if cps and cu:
return cps, cu
try:
from services import sat_keys
k, u, _src = sat_keys.resolve_key(
name=item.get('name'), brand=item.get('brand'),
category=item.get('category'), part_number=item.get('part_number'),
)
except Exception:
k, u = None, None
return (cps or k or '25174800'), (cu or u or 'H87')
def _make_element(parent, tag, attribs=None, ns=CFDI_NS): def _make_element(parent, tag, attribs=None, ns=CFDI_NS):
"""Create a subelement with the given namespace.""" """Create a subelement with the given namespace."""
elem = etree.SubElement(parent, f'{{{ns}}}{tag}') elem = etree.SubElement(parent, f'{{{ns}}}{tag}')
@@ -105,9 +83,6 @@ def build_ingreso_xml(sale, tenant_config, customer=None):
items: [{part_number, name, quantity, unit_price, discount_amount, items: [{part_number, name, quantity, unit_price, discount_amount,
tax_rate, tax_amount, subtotal, tax_rate, tax_amount, subtotal,
clave_prod_serv, clave_unidad}] clave_prod_serv, clave_unidad}]
(clave_prod_serv / clave_unidad are optional: when absent they are
resolved by services.sat_keys rules over name/brand/category,
falling back to 25174800 / H87)
tenant_config: dict with keys: tenant_config: dict with keys:
rfc, razon_social, regimen_fiscal, cp (codigo postal), rfc, razon_social, regimen_fiscal, cp (codigo postal),
serie (optional), nombre_comercial (optional) serie (optional), nombre_comercial (optional)
@@ -200,10 +175,10 @@ def build_ingreso_xml(sale, tenant_config, customer=None):
base = (importe - discount_amount).quantize(TWO, ROUND_HALF_UP) base = (importe - discount_amount).quantize(TWO, ROUND_HALF_UP)
concepto = _make_element(conceptos, 'Concepto') concepto = _make_element(conceptos, 'Concepto')
concepto.set('ClaveProdServ', _item_sat_keys(item)[0]) concepto.set('ClaveProdServ', item.get('clave_prod_serv') or '25174800')
concepto.set('NoIdentificacion', item.get('part_number') or '') concepto.set('NoIdentificacion', item.get('part_number') or '')
concepto.set('Cantidad', str(qty)) concepto.set('Cantidad', str(qty))
concepto.set('ClaveUnidad', _item_sat_keys(item)[1]) concepto.set('ClaveUnidad', item.get('clave_unidad') or 'H87')
concepto.set('Unidad', 'PZA') concepto.set('Unidad', 'PZA')
concepto.set('Descripcion', item.get('name') or 'Autoparte') concepto.set('Descripcion', item.get('name') or 'Autoparte')
concepto.set('ValorUnitario', _format_amount(unit_price)) concepto.set('ValorUnitario', _format_amount(unit_price))
@@ -326,10 +301,10 @@ def build_egreso_xml(sale, tenant_config, customer, original_uuid):
base = (importe - discount_amount).quantize(TWO, ROUND_HALF_UP) base = (importe - discount_amount).quantize(TWO, ROUND_HALF_UP)
concepto = _make_element(conceptos, 'Concepto') concepto = _make_element(conceptos, 'Concepto')
concepto.set('ClaveProdServ', _item_sat_keys(item)[0]) concepto.set('ClaveProdServ', item.get('clave_prod_serv') or '25174800')
concepto.set('NoIdentificacion', item.get('part_number') or '') concepto.set('NoIdentificacion', item.get('part_number') or '')
concepto.set('Cantidad', str(qty)) concepto.set('Cantidad', str(qty))
concepto.set('ClaveUnidad', _item_sat_keys(item)[1]) concepto.set('ClaveUnidad', item.get('clave_unidad') or 'H87')
concepto.set('Unidad', 'PZA') concepto.set('Unidad', 'PZA')
concepto.set('Descripcion', item.get('name') or 'Autoparte') concepto.set('Descripcion', item.get('name') or 'Autoparte')
concepto.set('ValorUnitario', _format_amount(unit_price)) concepto.set('ValorUnitario', _format_amount(unit_price))
@@ -581,10 +556,10 @@ def build_global_invoice_xml(sales, tenant_config, year, month):
base = (importe - discount_amount).quantize(TWO, ROUND_HALF_UP) base = (importe - discount_amount).quantize(TWO, ROUND_HALF_UP)
concepto = _make_element(conceptos, 'Concepto') concepto = _make_element(conceptos, 'Concepto')
concepto.set('ClaveProdServ', _item_sat_keys(item)[0]) concepto.set('ClaveProdServ', item.get('clave_prod_serv') or '25174800')
concepto.set('NoIdentificacion', item.get('part_number') or str(sale['id'])) concepto.set('NoIdentificacion', item.get('part_number') or str(sale['id']))
concepto.set('Cantidad', str(qty)) concepto.set('Cantidad', str(qty))
concepto.set('ClaveUnidad', _item_sat_keys(item)[1]) concepto.set('ClaveUnidad', item.get('clave_unidad') or 'H87')
concepto.set('Unidad', 'PZA') concepto.set('Unidad', 'PZA')
concepto.set('Descripcion', item.get('name') or 'Autoparte') concepto.set('Descripcion', item.get('name') or 'Autoparte')
concepto.set('ValorUnitario', _format_amount(unit_price)) concepto.set('ValorUnitario', _format_amount(unit_price))

View File

@@ -69,27 +69,6 @@ def _resolve_metodo_pago(sale):
return "PUE" return "PUE"
def _item_sat_keys(item):
"""Return (clave_prod_serv, clave_unidad) with SAT-rule fallback.
Precedence: sale-item value, then ``services.sat_keys`` rules over
name/brand/category, then the historic hard-coded default. Never raises.
"""
cps = item.get("clave_prod_serv")
cu = item.get("clave_unidad")
if cps and cu:
return cps, cu
try:
from services import sat_keys
k, u, _src = sat_keys.resolve_key(
name=item.get("name"), brand=item.get("brand"),
category=item.get("category"), part_number=item.get("part_number"),
)
except Exception:
k, u = None, None
return (cps or k or "25174800"), (cu or u or "H87")
def _build_items(sale_items): def _build_items(sale_items):
items = [] items = []
for item in sale_items or []: for item in sale_items or []:
@@ -97,13 +76,12 @@ def _build_items(sale_items):
unit_price = _to_dec(item.get("unit_price", 0)) unit_price = _to_dec(item.get("unit_price", 0))
discount = _to_dec(item.get("discount_amount", 0)) discount = _to_dec(item.get("discount_amount", 0))
tax_rate = _to_dec(item.get("tax_rate", "0.16")) tax_rate = _to_dec(item.get("tax_rate", "0.16"))
product_key, unit_key = _item_sat_keys(item)
# Facturapi price is unit price before taxes and discounts # Facturapi price is unit price before taxes and discounts
product = { product = {
"description": item.get("name") or "Autoparte", "description": item.get("name") or "Autoparte",
"product_key": product_key, "product_key": item.get("clave_prod_serv") or "25174800",
"unit_key": unit_key, "unit_key": item.get("clave_unidad") or "H87",
"unit_name": "Pieza", "unit_name": "Pieza",
"price": _fmt2(unit_price), "price": _fmt2(unit_price),
"tax_included": False, "tax_included": False,
@@ -158,91 +136,65 @@ def build_ingreso_payload(sale, tenant_config, customer=None):
"folio_number": sale["id"], "folio_number": sale["id"],
} }
# Optional currency/exchange rate for non-MXN sales # Optional exchange rate for USD
currency = (sale.get("currency") or "MXN").upper() if sale.get("currency") and sale["currency"] != "MXN" and sale.get("exchange_rate"):
if currency != "MXN":
payload["currency"] = currency
if sale.get("exchange_rate"):
payload["exchange"] = _fmt6(sale["exchange_rate"]) payload["exchange"] = _fmt6(sale["exchange_rate"])
payload["currency"] = sale["currency"]
return payload return payload
def build_egreso_payload(sale, tenant_config, customer, original_uuid, folio_number=None, idempotency_key=None): def build_egreso_payload(sale, tenant_config, customer, original_uuid):
"""Build Facturapi payload for a credit note (Comprobante tipo Egreso).""" """Build Facturapi payload for a credit note (Comprobante tipo Egreso)."""
payload = build_ingreso_payload(sale, tenant_config, customer) payload = build_ingreso_payload(sale, tenant_config, customer)
payload["type"] = "E" payload["type"] = "E"
payload["use"] = "G02" # Devoluciones, descuentos o bonificaciones payload["related_documents"] = [{"relationship": "01", "documents": [original_uuid]}]
payload["payment_method"] = "PUE" payload["payment_method"] = "PUE"
payload["related_documents"] = [{"relationship": "01", "documents": original_uuid}]
if folio_number is not None:
payload["folio_number"] = folio_number
if idempotency_key:
payload["idempotency_key"] = idempotency_key
return payload return payload
def _normalize_payment_date(value): def build_pago_payload(payment, tenant_config, customer, original_uuid):
"""Return the ISO8601 datetime string Facturapi expects for a payment.""" """Build Facturapi payload for a payment complement (Comprobante tipo Pago)."""
if not value:
return datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
text = str(value).replace(" ", "T")
if len(text) == 10:
return f"{text}T12:00:00"
return text[:19]
def build_pago_payload(payment, tenant_config, customer, original_uuid, idempotency_key=None):
"""Build Facturapi payload for a payment complement (Comprobante tipo Pago).
`complements[0].data` is a list of payments (Facturapi JS SDK v5 types),
each payment carrying its own related_documents with the installment number
and the previous balance of the linked PPD invoice.
"""
tenant_cp = tenant_config.get("cp", "00000") tenant_cp = tenant_config.get("cp", "00000")
customer_payload = _build_customer_payload(customer, tenant_cp) customer_payload = _build_customer_payload(customer, tenant_cp)
amount = _to_dec(payment.get("amount", 0)) amount = _to_dec(payment.get("amount", 0))
rate = _to_dec(payment.get("tax_rate") or "0.16") or Decimal("0.16") base = (amount / Decimal("1.16")).quantize(TWO, ROUND_HALF_UP)
base = (amount / (Decimal("1") + rate)).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") forma_pago = FORMA_PAGO_MAP.get((payment.get("payment_method") or "").lower().strip(), "01")
payload = { payload = {
"type": "P", "type": "P",
"customer": customer_payload, "customer": customer_payload,
"payment_form": forma_pago,
"complements": [ "complements": [
{ {
"type": "pago", "type": "pago",
"data": [ "data": {
{
"payment_form": forma_pago, "payment_form": forma_pago,
"date": _normalize_payment_date(payment.get("date")), "payment_date": payment_date,
"amount": _fmt2(amount),
"related_documents": [ "related_documents": [
{ {
"uuid": original_uuid, "uuid": original_uuid,
"amount": _fmt2(amount), "amount": _fmt2(amount),
"installment": int(payment.get("installment") or 1),
"last_balance": _fmt2(payment.get("last_balance") or 0),
"taxes": [ "taxes": [
{ {
"base": _fmt2(base),
"type": "IVA", "type": "IVA",
"rate": _fmt6(rate), "rate": 0.16,
"factor": "Tasa", "factor": "Tasa",
"withholding": False, "base": _fmt2(base),
} }
], ],
} }
], ],
},
} }
], ],
} }
],
}
if idempotency_key:
payload["idempotency_key"] = idempotency_key
return payload return payload

View File

@@ -3,11 +3,9 @@
Flow: Flow:
1. enqueue_cfdi() — inserts Facturapi JSON payload into cfdi_queue with status='pending' 1. enqueue_cfdi() — inserts Facturapi JSON payload into cfdi_queue with status='pending'
2. enqueue_pending_complements() — enqueues the missing payment complements 2. process_queue() — sends pending items to Facturapi, updates status
(CFDI type "P") of PPD invoices and the credit notes (type "E") of returns 3. retry_failed() — retries failed items with exponential backoff
3. process_queue() — sends pending items to Facturapi, updates status 4. cancel_cfdi() — cancels a stamped CFDI via Facturapi
4. retry_failed() — retries failed items with exponential backoff
5. cancel_cfdi() — cancels a stamped CFDI via Facturapi
Facturapi endpoints used: Facturapi endpoints used:
POST /v2/invoices — create and stamp an invoice POST /v2/invoices — create and stamp an invoice
@@ -19,11 +17,9 @@ Retry backoff: 5s, 30s, 2m, 10m, 1h (max 5 retries)
import json import json
import logging import logging
from datetime import datetime, timedelta, timezone from datetime import datetime
from decimal import ROUND_HALF_UP, Decimal
from services import facturapi_service from services import facturapi_service
from services.cfdi_facturapi_builder import build_egreso_payload, build_pago_payload
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -31,73 +27,10 @@ logger = logging.getLogger(__name__)
BACKOFF_INTERVALS = [5, 30, 120, 600, 3600] BACKOFF_INTERVALS = [5, 30, 120, 600, 3600]
MAX_RETRIES = len(BACKOFF_INTERVALS) MAX_RETRIES = len(BACKOFF_INTERVALS)
# Movements older than this window are only reconciled on explicit backfill.
COMPLEMENT_LOOKBACK_DAYS = 7
TWO = Decimal("0.01")
def _send_cfdi_pdf_wa(conn, invoice_id_external, sale_id, tenant_config):
"""Descarga el PDF timbrado y lo envía por WhatsApp al cliente (si tiene teléfono)."""
try:
from services import facturapi_service
cur = conn.cursor()
cur.execute("""
SELECT c.phone FROM sales s
JOIN customers c ON c.id = s.customer_id
WHERE s.id = %s
""", (sale_id,))
row = cur.fetchone()
cur.close()
if not row or not row[0]:
return
phone = row[0]
cur = conn.cursor()
cur.execute("SELECT key, value FROM tenant_config WHERE key LIKE 'whatsapp_%' OR key LIKE 'wa_%'")
raw = {k: v for k, v in cur.fetchall()}
cur.close()
cfg = {
'provider': raw.get('whatsapp_provider', 'baileys'),
'phone_number_id': raw.get('wa_phone_number_id', ''),
'access_token': raw.get('wa_access_token', ''),
'bridge_url': raw.get('whatsapp_bridge_url', ''),
}
# Solo enviar si hay proveedor configurado (Meta o Baileys con bridge)
if cfg['provider'] != 'meta' and not cfg['bridge_url']:
return
pdf_bytes = facturapi_service.download_pdf(tenant_config, invoice_id_external)
import base64 as _b64
b64 = _b64.b64encode(pdf_bytes).decode()
filename = f"CFDI_{sale_id}.pdf"
from services.whatsapp_service import wa_send_document
result = wa_send_document(cfg, phone, b64, filename,
caption="Adjuntamos tu factura CFDI. Gracias por tu compra.")
try:
from services.audit import log_action
log_action(conn, 'CFDI_PDF_SENT', 'sale', sale_id,
new_value={'result': result, 'phone': phone})
except Exception:
pass
except Exception as e:
try:
logger.warning("PDF WA send failed for sale %s: %s", sale_id, e)
except Exception:
pass
def _generate_provisional_folio(conn): def _generate_provisional_folio(conn):
"""Generate a provisional folio like PRE-00001. """Generate a provisional folio like PRE-00001."""
Uses a transaction-level advisory lock to avoid duplicate provisional
folios when multiple CFDIs are enqueued concurrently.
"""
cur = conn.cursor() cur = conn.cursor()
cur.execute("SELECT pg_advisory_xact_lock(hashtext('cfdi_provisional_folio'))")
cur.execute("SELECT COALESCE(MAX(id), 0) + 1 FROM cfdi_queue") cur.execute("SELECT COALESCE(MAX(id), 0) + 1 FROM cfdi_queue")
seq = cur.fetchone()[0] seq = cur.fetchone()[0]
cur.close() cur.close()
@@ -143,340 +76,6 @@ def enqueue_cfdi(conn, sale_id, cfdi_type, payload):
} }
# ─── Payment complements (REP) and credit notes reconciliation ──────────────
def _to_dec(val):
if val is None:
return Decimal("0")
return Decimal(str(val))
def _payload_customer(payload):
"""Adapt the receptor block of a stored CFDI payload to builder input."""
customer = payload.get("customer") or {}
tax_id = customer.get("tax_id")
if not tax_id:
return None
address = customer.get("address") or {}
return {
"rfc": tax_id,
"razon_social": customer.get("legal_name"),
"regimen_fiscal": customer.get("tax_system"),
"cp": address.get("zip"),
}
def _pago_related_amounts(payload):
"""Amounts of the related documents of a stored pago payload."""
amounts = []
for complement in payload.get("complements") or []:
data = complement.get("data")
blocks = data if isinstance(data, list) else [data]
for block in blocks:
if not isinstance(block, dict):
continue
for doc in block.get("related_documents") or []:
try:
amounts.append(round(float(doc.get("amount") or 0), 2))
except (TypeError, ValueError):
continue
return amounts
def _items_signature(payload):
"""Comparable signature of a payload's concepts (product key, qty, price)."""
signature = []
for item in payload.get("items") or []:
product = item.get("product") or {}
try:
signature.append(
(
str(product.get("product_key") or ""),
int(item.get("quantity") or 0),
round(float(product.get("price") or 0), 2),
)
)
except (TypeError, ValueError):
continue
return tuple(sorted(signature))
def _stored_signatures(cur, sale_id, cfdi_type):
"""Signatures/amounts of the non-cancelled CFDIs of a sale, for dedupe."""
cur.execute(
"""
SELECT payload_unsigned FROM cfdi_queue
WHERE sale_id = %s AND type = %s AND status <> 'cancelled'
""",
(sale_id, cfdi_type),
)
values = []
for (payload_unsigned,) in cur.fetchall():
try:
payload = json.loads(payload_unsigned or "{}")
except ValueError:
continue
if cfdi_type == "pago":
values.extend(_pago_related_amounts(payload))
else:
signature = _items_signature(payload)
if signature:
values.append(signature)
return values
def _enqueue_pending_pagos(conn, cur, tenant_config, since_dt):
"""Enqueue payment complements for every payment of a stamped PPD invoice."""
cur.execute(
"""
SELECT q.sale_id, q.uuid_fiscal, q.payload_unsigned,
s.total, s.subtotal, s.discount_total, s.tax_total
FROM cfdi_queue q
JOIN sales s ON s.id = q.sale_id
WHERE q.type = 'ingreso' AND q.status = 'stamped'
AND q.uuid_fiscal IS NOT NULL AND q.sale_id IS NOT NULL
AND (%s::timestamptz IS NULL OR EXISTS (
SELECT 1 FROM sale_payments sp
WHERE sp.sale_id = q.sale_id
AND sp.amount > 0
AND sp.created_at >= %s))
ORDER BY q.sale_id
""",
(since_dt, since_dt),
)
created = []
for sale_id, original_uuid, payload_unsigned, total, subtotal, discount_total, tax_total in cur.fetchall():
try:
invoice_payload = json.loads(payload_unsigned or "{}")
except ValueError:
continue
if str(invoice_payload.get("payment_method") or "").upper() != "PPD":
continue
customer = _payload_customer(invoice_payload)
if not customer:
continue
cur.execute(
"""
SELECT id, amount, method, created_at
FROM sale_payments
WHERE sale_id = %s AND amount > 0
ORDER BY id
""",
(sale_id,),
)
payments = cur.fetchall()
if not payments:
continue
already = _stored_signatures(cur, sale_id, "pago")
base_total = _to_dec(subtotal) - _to_dec(discount_total)
tax_rate = (_to_dec(tax_total) / base_total) if base_total > 0 else Decimal("0.16")
balance = _to_dec(total)
for installment, (payment_id, amount, method, created_at) in enumerate(payments, start=1):
paid = _to_dec(amount)
previous_balance = balance if balance > Decimal("0") else Decimal("0")
balance -= paid
if paid <= 0:
continue
if round(float(paid), 2) in already:
already.remove(round(float(paid), 2))
continue
if since_dt and created_at and created_at < since_dt:
continue
try:
payload = build_pago_payload(
{
"id": payment_id,
"amount": paid,
"payment_method": method,
"date": created_at,
"installment": installment,
"last_balance": previous_balance,
"tax_rate": tax_rate,
},
tenant_config,
customer,
original_uuid,
idempotency_key=f"nexus-rep-{payment_id}",
)
queued = enqueue_cfdi(conn, sale_id, "pago", payload)
except Exception as e:
logger.warning("Could not enqueue payment complement for sale_payment %s: %s", payment_id, e)
continue
created.append(
{
"cfdi_id": queued["id"],
"sale_id": sale_id,
"sale_payment_id": payment_id,
"amount": float(paid),
"installment": installment,
"original_uuid": original_uuid,
}
)
return created
def _enqueue_pending_egresos(conn, cur, tenant_config, since_dt):
"""Enqueue credit notes (egreso) for returns of already stamped sales."""
cur.execute(
"""
SELECT r.id, r.sale_id, r.created_at, s.currency, s.exchange_rate,
ing.uuid_fiscal, ing.payload_unsigned
FROM returns r
JOIN sales s ON s.id = r.sale_id
JOIN LATERAL (
SELECT q.uuid_fiscal, q.payload_unsigned
FROM cfdi_queue q
WHERE q.sale_id = r.sale_id AND q.type = 'ingreso'
AND q.status = 'stamped' AND q.uuid_fiscal IS NOT NULL
ORDER BY q.stamped_at DESC NULLS LAST, q.id DESC
LIMIT 1
) ing ON TRUE
WHERE r.status = 'completed'
AND (%s::timestamptz IS NULL OR r.created_at >= %s)
ORDER BY r.id
""",
(since_dt, since_dt),
)
created = []
for return_id, sale_id, created_at, currency, exchange_rate, original_uuid, payload_unsigned in cur.fetchall():
if since_dt and created_at and created_at < since_dt:
continue
try:
invoice_payload = json.loads(payload_unsigned or "{}")
except ValueError:
invoice_payload = {}
customer = _payload_customer(invoice_payload)
if not customer:
continue
cur.execute(
"""
SELECT si.part_number, si.name, ri.quantity, si.unit_price, si.discount_pct,
si.tax_rate, si.clave_prod_serv, si.clave_unidad
FROM return_items ri
JOIN sale_items si ON si.id = ri.sale_item_id
WHERE ri.return_id = %s
ORDER BY ri.id
""",
(return_id,),
)
items = []
for part_number, name, quantity, unit_price, discount_pct, tax_rate, clave_prod, clave_unidad in cur.fetchall():
price = float(unit_price or 0)
items.append(
{
"part_number": part_number,
"name": name or part_number,
"quantity": int(quantity or 0),
"unit_price": price,
"discount_amount": round(price * float(discount_pct or 0) / 100 * int(quantity or 0), 2),
"tax_rate": float(tax_rate if tax_rate is not None else 0.16),
"clave_prod_serv": clave_prod,
"clave_unidad": clave_unidad,
}
)
if not items:
continue
try:
payload = build_egreso_payload(
{
"id": sale_id,
"items": items,
"currency": currency,
"exchange_rate": exchange_rate,
"forma_pago_sat": invoice_payload.get("payment_form"),
},
tenant_config,
customer,
original_uuid,
folio_number=return_id,
idempotency_key=f"nexus-egreso-{return_id}",
)
except Exception as e:
logger.warning("Could not build credit note for return %s: %s", return_id, e)
continue
signature = _items_signature(payload)
already = _stored_signatures(cur, sale_id, "egreso")
if signature and signature in already:
already.remove(signature)
continue
try:
queued = enqueue_cfdi(conn, sale_id, "egreso", payload)
except Exception as e:
logger.warning("Could not enqueue credit note for return %s: %s", return_id, e)
continue
created.append(
{
"cfdi_id": queued["id"],
"sale_id": sale_id,
"return_id": return_id,
"original_uuid": original_uuid,
}
)
return created
def enqueue_pending_complements(conn, tenant_config=None, since=None, backfill=False):
"""Enqueue the missing payment complements and credit notes.
Every payment of a stamped PPD invoice gets a CFDI type "P" linked to the
original UUID, and every completed return of a stamped sale gets a CFDI
type "E". Idempotent: a movement that already has a non-cancelled CFDI of
the matching type/amount is skipped.
Args:
conn: psycopg2 connection
tenant_config: issuer config (only the default zip is used)
since: datetime or ISO string; reconcile movements created after it.
Defaults to COMPLEMENT_LOOKBACK_DAYS ago.
backfill: reconcile the whole history, ignoring `since`
Returns:
dict: {pagos: [...], egresos: [...], since: iso string}
"""
if backfill:
since_dt = None
elif since:
if isinstance(since, datetime):
since_dt = since
else:
since_dt = datetime.fromisoformat(str(since))
if since_dt.tzinfo is None:
since_dt = since_dt.replace(tzinfo=timezone.utc)
else:
since_dt = datetime.now(timezone.utc) - timedelta(days=COMPLEMENT_LOOKBACK_DAYS)
tenant_config = tenant_config or {}
cur = conn.cursor()
result = {"pagos": [], "egresos": [], "since": str(since_dt) if since_dt else None}
for key, sweep in (("pagos", _enqueue_pending_pagos), ("egresos", _enqueue_pending_egresos)):
try:
result[key] = sweep(conn, cur, tenant_config, since_dt)
except Exception as e:
logger.warning("Could not reconcile %s complements: %s", key, e)
conn.rollback()
conn.commit()
cur.close()
return result
def process_queue(conn, tenant_config, dry_run=False): def process_queue(conn, tenant_config, dry_run=False):
"""Process all pending CFDI items in the queue. """Process all pending CFDI items in the queue.
@@ -492,15 +91,6 @@ def process_queue(conn, tenant_config, dry_run=False):
Returns: Returns:
dict: {processed: int, stamped: int, failed: int, details: [...]} dict: {processed: int, stamped: int, failed: int, details: [...]}
""" """
# First enqueue the complements (REP / credit notes) that are still missing,
# so they are stamped in this same run.
if not dry_run:
try:
enqueue_pending_complements(conn, tenant_config)
except Exception as e:
logger.warning("Complement reconciliation failed: %s", e)
conn.rollback()
cur = conn.cursor() cur = conn.cursor()
cur.execute( cur.execute(
@@ -511,7 +101,6 @@ def process_queue(conn, tenant_config, dry_run=False):
AND retry_count < %s AND retry_count < %s
ORDER BY created_at ASC ORDER BY created_at ASC
LIMIT 50 LIMIT 50
FOR UPDATE SKIP LOCKED
""", """,
(MAX_RETRIES,), (MAX_RETRIES,),
) )
@@ -573,13 +162,6 @@ def process_queue(conn, tenant_config, dry_run=False):
) )
conn.commit() conn.commit()
# Enviar PDF por WhatsApp al cliente (best effort)
try:
if _sale_id:
_send_cfdi_pdf_wa(conn, invoice_id, _sale_id, tenant_config)
except Exception:
pass
results["stamped"] += 1 results["stamped"] += 1
results["details"].append( results["details"].append(
{ {
@@ -782,7 +364,7 @@ def get_queue_status(conn, filters=None):
params.append(int(filters["sale_id"])) params.append(int(filters["sale_id"]))
if filters.get("type"): if filters.get("type"):
where_clauses.append("LOWER(q.type) = LOWER(%s)") where_clauses.append("q.type = %s")
params.append(filters["type"]) params.append(filters["type"])
where = " AND ".join(where_clauses) where = " AND ".join(where_clauses)
@@ -794,12 +376,8 @@ def get_queue_status(conn, filters=None):
f""" f"""
SELECT q.id, q.sale_id, q.type, q.uuid_fiscal, q.status, SELECT q.id, q.sale_id, q.type, q.uuid_fiscal, q.status,
q.retry_count, q.provisional_folio, q.error_message, q.retry_count, q.provisional_folio, q.error_message,
q.cancel_motive, q.created_at, q.stamped_at, q.external_id, q.cancel_motive, q.created_at, q.stamped_at, q.external_id
c.name as customer_name, c.rfc,
s.subtotal, s.tax_total, s.total, s.payment_method
FROM cfdi_queue q FROM cfdi_queue q
LEFT JOIN sales s ON q.sale_id = s.id
LEFT JOIN customers c ON s.customer_id = c.id
WHERE {where} WHERE {where}
ORDER BY q.created_at DESC ORDER BY q.created_at DESC
LIMIT %s OFFSET %s LIMIT %s OFFSET %s
@@ -823,12 +401,6 @@ def get_queue_status(conn, filters=None):
"created_at": str(r[9]) if r[9] else None, "created_at": str(r[9]) if r[9] else None,
"stamped_at": str(r[10]) if r[10] else None, "stamped_at": str(r[10]) if r[10] else None,
"external_id": r[11], "external_id": r[11],
"customer_name": r[12],
"rfc": r[13],
"subtotal": float(r[14]) if r[14] else 0,
"tax_total": float(r[15]) if r[15] else 0,
"total": float(r[16]) if r[16] else 0,
"payment_method": r[17],
} }
) )

View File

@@ -1,463 +0,0 @@
"""Credit risk scoring for store credit ("fiado") — A9.
Pure rule-based / heuristic scoring: no ML dependencies, works with very little
history and stays conservative when evidence is scarce. Every point of the score
is traceable to an explicit rule listed in ``reasons``.
Public API:
score_customer(tenant_conn, customer_id) -> dict
is_enforcement_enabled(tenant_conn) -> bool
The score is 0..100 where **higher is safer**. It is built from up to four
independent signals, each 0..100, then combined with fixed weights over the
signals that actually have data (missing signals are not invented):
tenure 0.15 months since customers.created_at
purchase 0.25 frequency + recency + value from sales
payment 0.35 punctuality on credit sales + open overdue aging
utilization 0.25 credit_balance / credit_limit
Credit sales are assumed to be due 30 days after the sale, matching the
receivables aging convention in ``blueprints/accounting_bp.py``.
"""
import logging
from datetime import datetime, timedelta, timezone
logger = logging.getLogger('pos')
# Credit sale terms used across the app for receivables aging.
CREDIT_TERMS_DAYS = 30
# Weight of each signal in the combined score.
_WEIGHTS = {
'tenure': 0.15,
'purchase': 0.25,
'payment': 0.35,
'utilization': 0.25,
}
_TIER_ORDER = {'risk': 0, 'medium': 1, 'good': 2}
# Enforcement flag key in tenant_config. Off by default.
ENFORCE_FLAG = 'credit_scoring_enforce'
def _now():
return datetime.now(timezone.utc)
def _aware(dt):
"""Normalize a possibly-naive datetime to UTC-aware."""
if dt is None:
return None
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt
def _clamp(value, low=0, high=100):
return max(low, min(high, value))
def is_enforcement_enabled(tenant_conn):
"""Return True when the tenant opted into blocking credit sales for 'risk'.
Defaults to False on any error or missing flag so the sale flow never
breaks because of scoring.
"""
try:
cur = tenant_conn.cursor()
cur.execute("SELECT value FROM tenant_config WHERE key = %s", (ENFORCE_FLAG,))
row = cur.fetchone()
cur.close()
if not row:
return False
return str(row[0]).strip().lower() in ('true', '1', 'yes', 'on')
except Exception as exc: # pragma: no cover - defensive
logger.warning("credit_scoring: could not read enforce flag: %s", exc)
return False
def _collect_evidence(cur, customer_id):
"""Read every raw signal in a few queries. Returns a dict or None if the
customer does not exist."""
cur.execute("""
SELECT id, name, created_at, credit_limit, credit_balance,
price_tier, is_active
FROM customers WHERE id = %s
""", (customer_id,))
row = cur.fetchone()
if not row:
return None
ev = {
'customer_id': row[0],
'name': row[1],
'created_at': _aware(row[2]),
'credit_limit': float(row[3] or 0),
'credit_balance': float(row[4] or 0),
'price_tier': row[5],
'is_active': row[6],
}
# Completed sales aggregate (frequency / value / recency).
cur.execute("""
SELECT COUNT(*), COALESCE(SUM(total), 0), MIN(created_at),
MAX(created_at), COALESCE(AVG(total), 0),
COUNT(*) FILTER (WHERE sale_type = 'credit')
FROM sales
WHERE customer_id = %s AND status = 'completed'
""", (customer_id,))
total_orders, ltv, first_purchase, last_purchase, aov, credit_sales = cur.fetchone()
ev.update({
'total_orders': int(total_orders or 0),
'ltv': float(ltv or 0),
'first_purchase': _aware(first_purchase),
'last_purchase': _aware(last_purchase),
'avg_order_value': float(aov or 0),
'credit_sales': int(credit_sales or 0),
})
# Open (unpaid) credit sales: balance and due date for aging.
cur.execute("""
SELECT s.id, s.total, s.created_at,
s.total - COALESCE(SUM(sp.amount), 0) AS balance
FROM sales s
LEFT JOIN sale_payments sp ON sp.sale_id = s.id
WHERE s.customer_id = %s AND s.sale_type = 'credit' AND s.status = 'completed'
GROUP BY s.id, s.total, s.created_at
HAVING s.total - COALESCE(SUM(sp.amount), 0) > 0.005
ORDER BY s.created_at
""", (customer_id,))
now = _now()
open_sales = []
for sale_id, total, created_at, balance in cur.fetchall():
created_at = _aware(created_at)
due = created_at + timedelta(days=CREDIT_TERMS_DAYS) if created_at else None
overdue = max(0, (now - due).days) if due else 0
open_sales.append({
'sale_id': sale_id,
'total': float(total or 0),
'created_at': created_at,
'balance': float(balance or 0),
'due_date': due,
'days_overdue': overdue,
})
ev['open_sales'] = open_sales
ev['open_balance'] = round(sum(s['balance'] for s in open_sales), 2)
ev['max_overdue_days'] = max((s['days_overdue'] for s in open_sales), default=0)
ev['overdue_balance'] = round(
sum(s['balance'] for s in open_sales if s['days_overdue'] > 0), 2
)
# Aging buckets (by days overdue).
buckets = {'current': 0.0, 'd1_30': 0.0, 'd31_60': 0.0, 'd61_90': 0.0, 'd90_plus': 0.0}
for s in open_sales:
d = s['days_overdue']
if d == 0:
buckets['current'] += s['balance']
elif d <= 30:
buckets['d1_30'] += s['balance']
elif d <= 60:
buckets['d31_60'] += s['balance']
elif d <= 90:
buckets['d61_90'] += s['balance']
else:
buckets['d90_plus'] += s['balance']
ev['aging'] = {k: round(v, 2) for k, v in buckets.items()}
# Settled credit sales: was the final payment within terms?
cur.execute("""
SELECT s.created_at, MAX(sp.created_at) AS last_payment
FROM sales s
JOIN sale_payments sp ON sp.sale_id = s.id
WHERE s.customer_id = %s AND s.sale_type = 'credit' AND s.status = 'completed'
GROUP BY s.id, s.total, s.created_at
HAVING SUM(sp.amount) >= s.total - 0.005
""", (customer_id,))
settled = []
for created_at, last_payment in cur.fetchall():
created_at = _aware(created_at)
last_payment = _aware(last_payment)
if not created_at or not last_payment:
continue
due = created_at + timedelta(days=CREDIT_TERMS_DAYS)
settled.append({'days_late': max(0, (last_payment - due).days)})
ev['settled_credit_sales'] = settled
# Direct customer payments (abonos) — extra evidence of engagement.
cur.execute("""
SELECT COUNT(*), COALESCE(SUM(amount), 0), MAX(created_at)
FROM customer_payments WHERE customer_id = %s
""", (customer_id,))
cp_count, cp_total, cp_last = cur.fetchone()
ev.update({
'customer_payments_count': int(cp_count or 0),
'customer_payments_total': float(cp_total or 0),
'customer_payments_last': _aware(cp_last),
})
return ev
# ─── Signal scoring ─────────────────────────────────────
def _tenure_signal(ev, reasons):
created_at = ev['created_at']
if not created_at:
return None
days = max(0, (_now() - created_at).days)
months = days / 30.44
if months < 3:
score = 45
elif months < 6:
score = 60
elif months < 12:
score = 75
elif months < 24:
score = 85
else:
score = 95
reasons.append(f"Cliente con {int(months)} meses de antigüedad ({days} días).")
if ev.get('is_active') is False:
score = min(score, 40)
reasons.append("Cliente marcado como inactivo.")
return score
def _purchase_signal(ev, reasons):
total_orders = ev['total_orders']
if total_orders <= 0:
reasons.append("Sin historial de compras registrado.")
return None
if total_orders == 1:
freq = 50
elif total_orders <= 3:
freq = 65
elif total_orders <= 9:
freq = 80
elif total_orders <= 24:
freq = 90
else:
freq = 95
last = ev['last_purchase']
days_since = (_now() - last).days if last else None
if days_since is None:
recency = 60
elif days_since <= 30:
recency = 100
elif days_since <= 90:
recency = 80
elif days_since <= 180:
recency = 60
elif days_since <= 365:
recency = 40
else:
recency = 25
score = round(0.6 * freq + 0.4 * recency)
reasons.append(
f"{total_orders} compras por ${ev['ltv']:.2f} "
f"(ticket promedio ${ev['avg_order_value']:.2f}), "
f"última hace {days_since} días."
if days_since is not None else
f"{total_orders} compras por ${ev['ltv']:.2f}."
)
return _clamp(score)
def _payment_signal(ev, reasons):
has_history = (
ev['credit_sales'] > 0
or ev['open_balance'] > 0
or ev['customer_payments_count'] > 0
)
if not has_history:
reasons.append("Sin historial de crédito ni abonos.")
return None
settled = ev['settled_credit_sales']
if settled:
on_time = sum(1 for s in settled if s['days_late'] <= 0)
late_ratio = (len(settled) - on_time) / len(settled)
base = 90 - 60 * late_ratio
reasons.append(
f"{on_time}/{len(settled)} créditos liquidados dentro del plazo de "
f"{CREDIT_TERMS_DAYS} días."
)
else:
base = 70 if ev['credit_sales'] > 0 or ev['open_balance'] > 0 else 75
reasons.append("Sin créditos liquidados aún; sin historial de puntualidad.")
max_overdue = ev['max_overdue_days']
if max_overdue > 0:
penalty = min(55.0, 15.0 + max_overdue * 0.5)
base -= penalty
reasons.append(
f"Mora abierta: ${ev['overdue_balance']:.2f} con hasta {max_overdue} "
f"días de atraso."
)
if ev['credit_limit'] > 0:
util = ev['credit_balance'] / ev['credit_limit']
if util > 1.0:
base -= 15
reasons.append(f"Crédito excedido ({util * 100:.0f}% del límite).")
elif util > 0.9:
base -= 8
reasons.append(f"Crédito casi al límite ({util * 100:.0f}%).")
return _clamp(round(base))
def _utilization_signal(ev, reasons):
limit = ev['credit_limit']
balance = ev['credit_balance']
if limit <= 0:
if balance > 0:
reasons.append(f"Saldo de ${balance:.2f} sin límite de crédito definido.")
return 45
return None
util = balance / limit
if util <= 0.001:
score = 100
elif util <= 0.3:
score = 95
elif util <= 0.6:
score = 80
elif util <= 0.9:
score = 60
elif util <= 1.0:
score = 40
else:
score = 15
reasons.append(f"Utilización de crédito {util * 100:.0f}% (${balance:.2f} / ${limit:.2f}).")
return score
def _tier_from_score(score, data_points, max_overdue_days, behavioral_evidence):
if max_overdue_days > 60:
return 'risk'
if score >= 75 and data_points >= 2 and behavioral_evidence:
tier = 'good'
elif score >= 50:
tier = 'medium'
else:
tier = 'risk'
# Conservative when mora is building or evidence is thin.
if max_overdue_days > 30 and _TIER_ORDER[tier] > _TIER_ORDER['medium']:
tier = 'medium'
if not behavioral_evidence and _TIER_ORDER[tier] > _TIER_ORDER['medium']:
tier = 'medium'
if data_points <= 1 and _TIER_ORDER[tier] > _TIER_ORDER['medium']:
tier = 'medium'
return tier
def _recommended_limit(tier, ev):
if ev['total_orders'] <= 0:
return 0.0
aov = ev['avg_order_value'] or (ev['ltv'] / ev['total_orders'])
if tier == 'good':
factor = 4.0
elif tier == 'medium':
factor = 2.0
else:
return 0.0 # risk: do not recommend extending new credit
suggested = aov * factor
suggested = round(suggested / 100.0) * 100.0
return float(min(suggested, 200000.0))
def score_customer(tenant_conn, customer_id):
"""Score a customer's credit risk.
Returns a dict::
{
'score': 0..100, # higher = safer
'tier': 'good'|'medium'|'risk',
'recommended_limit': float,
'reasons': [str, ...],
'data_points': int, # number of signals with real data (0..4)
'components': {...}, # per-signal sub-scores (None when absent)
'metrics': {...}, # raw evidence for auditing
'computed_at': iso8601 str,
}
Raises ValueError if the customer does not exist.
"""
cur = tenant_conn.cursor()
try:
ev = _collect_evidence(cur, customer_id)
finally:
cur.close()
if ev is None:
raise ValueError(f"Customer {customer_id} not found")
reasons = []
components = {
'tenure': _tenure_signal(ev, reasons),
'purchase': _purchase_signal(ev, reasons),
'payment': _payment_signal(ev, reasons),
'utilization': _utilization_signal(ev, reasons),
}
available = {k: v for k, v in components.items() if v is not None}
data_points = len(available)
if available:
weight_sum = sum(_WEIGHTS[k] for k in available)
score = sum(available[k] * _WEIGHTS[k] for k in available) / weight_sum
else:
score = 50.0
score = int(round(_clamp(score)))
behavioral = components['purchase'] is not None or components['payment'] is not None
# Hard cap: heavy mora can never yield a high score.
if ev['max_overdue_days'] > 60:
score = min(score, 39)
elif ev['max_overdue_days'] > 30:
score = min(score, 60)
tier = _tier_from_score(score, data_points, ev['max_overdue_days'], behavioral)
recommended = _recommended_limit(tier, ev)
if data_points <= 1:
reasons.append(
f"Pocos datos ({data_points} señal{'es' if data_points != 1 else ''}); "
"evaluación conservadora."
)
return {
'customer_id': customer_id,
'score': score,
'tier': tier,
'recommended_limit': recommended,
'reasons': reasons,
'data_points': data_points,
'components': components,
'metrics': {
'months_active': round((_now() - ev['created_at']).days / 30.44, 1) if ev['created_at'] else None,
'total_orders': ev['total_orders'],
'ltv': round(ev['ltv'], 2),
'avg_order_value': round(ev['avg_order_value'], 2),
'days_since_last_purchase': (_now() - ev['last_purchase']).days if ev['last_purchase'] else None,
'credit_limit': ev['credit_limit'],
'credit_balance': ev['credit_balance'],
'open_credit_balance': ev['open_balance'],
'max_overdue_days': ev['max_overdue_days'],
'overdue_balance': ev['overdue_balance'],
'aging': ev['aging'],
'credit_sales': ev['credit_sales'],
'settled_credit_sales': len(ev['settled_credit_sales']),
},
'computed_at': _now().isoformat(),
}

View File

@@ -7,16 +7,7 @@ Provides:
- Customer analytics (LTV, frequency, churn risk) - Customer analytics (LTV, frequency, churn risk)
""" """
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta
def _aware(dt):
"""Normalize a possibly-naive datetime (psycopg2 may return either) to UTC."""
if dt is None:
return None
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt
# ─── Customer Activities ───────────────────────────── # ─── Customer Activities ─────────────────────────────
@@ -311,15 +302,11 @@ def get_customer_analytics(conn, customer_id):
WHERE customer_id = %s AND status = 'completed' WHERE customer_id = %s AND status = 'completed'
""", (customer_id,)) """, (customer_id,))
ltv, total_orders, first_purchase, last_purchase, aov = cur.fetchone() ltv, total_orders, first_purchase, last_purchase, aov = cur.fetchone()
first_purchase = _aware(first_purchase)
last_purchase = _aware(last_purchase)
now = datetime.now(timezone.utc)
# Days since last purchase # Days since last purchase
days_since_last = None days_since_last = None
if last_purchase: if last_purchase:
days_since_last = (now - last_purchase).days days_since_last = (datetime.utcnow() - last_purchase).days
# Purchase frequency (orders per month) # Purchase frequency (orders per month)
frequency = 0.0 frequency = 0.0
@@ -327,69 +314,13 @@ def get_customer_analytics(conn, customer_id):
months = max(1, (last_purchase - first_purchase).days / 30.0) months = max(1, (last_purchase - first_purchase).days / 30.0)
frequency = round(total_orders / months, 2) frequency = round(total_orders / months, 2)
# Real inter-purchase interval per customer (A12). Uses the gaps between # Churn risk: no purchase in 90+ days = high, 60-90 = medium, <60 = low
# consecutive completed sales instead of fixed 60/90-day thresholds.
cur.execute("""
SELECT created_at FROM sales
WHERE customer_id = %s AND status = 'completed'
ORDER BY created_at
""", (customer_id,))
purchase_dates = [_aware(r[0]) for r in cur.fetchall() if r[0] is not None]
intervals = [
(purchase_dates[i] - purchase_dates[i - 1]).days
for i in range(1, len(purchase_dates))
]
intervals = [d for d in intervals if d >= 0]
avg_interval = round(sum(intervals) / len(intervals), 1) if intervals else None
# Next-purchase prediction + churn probability.
predicted_next_purchase_date = None
days_until_next_purchase = None
churn_probability = 0.0
churn_reason = ''
if avg_interval and last_purchase and avg_interval > 0:
predicted = last_purchase + timedelta(days=round(avg_interval))
predicted_next_purchase_date = predicted
days_until_next_purchase = (predicted - now).days
# How many of its own typical cycles the customer has been silent.
ratio = (days_since_last or 0) / max(float(avg_interval), 1.0)
if ratio > 2.0:
churn_risk = 'high'
churn_probability = 0.9
churn_reason = (f"Lleva {days_since_last} días sin comprar, más de 2× "
f"su intervalo habitual ({avg_interval:.0f} días).")
elif ratio > 1.5:
churn_risk = 'high'
churn_probability = 0.7
churn_reason = (f"Rebasó 1.5× su intervalo habitual de compra "
f"({avg_interval:.0f} días).")
elif ratio > 1.0:
churn_risk = 'medium'
churn_probability = 0.4
churn_reason = f"Ya superó su intervalo habitual de compra ({avg_interval:.0f} días)."
else:
churn_risk = 'low' churn_risk = 'low'
churn_probability = 0.15 if days_since_last is not None:
churn_reason = f"Dentro de su intervalo habitual de compra ({avg_interval:.0f} días)." if days_since_last > 90:
else:
# Fewer than two purchases: not enough history for a cadence. Fall back
# to the legacy fixed thresholds but report that we lacked data.
if days_since_last is None:
churn_risk = 'low'
churn_probability = 0.0
churn_reason = 'Sin compras registradas.'
elif days_since_last > 90:
churn_risk = 'high' churn_risk = 'high'
churn_probability = 0.75
churn_reason = f"Sin compras desde hace {days_since_last} días."
elif days_since_last > 60: elif days_since_last > 60:
churn_risk = 'medium' churn_risk = 'medium'
churn_probability = 0.45
churn_reason = f"Sin compras desde hace {days_since_last} días."
else:
churn_risk = 'low'
churn_probability = 0.15
churn_reason = f"Compra reciente (hace {days_since_last} días)."
# Favorite categories (from inventory via sale_items) # Favorite categories (from inventory via sale_items)
cur.execute(""" cur.execute("""
@@ -431,15 +362,6 @@ def get_customer_analytics(conn, customer_id):
'days_since_last_purchase': days_since_last, 'days_since_last_purchase': days_since_last,
'purchase_frequency_monthly': frequency, 'purchase_frequency_monthly': frequency,
'churn_risk': churn_risk, 'churn_risk': churn_risk,
# A12 additions: real inter-purchase cadence + prediction.
'avg_days_between_purchases': avg_interval,
'purchase_intervals_count': len(intervals),
'predicted_next_purchase_date': (
predicted_next_purchase_date.isoformat() if predicted_next_purchase_date else None
),
'days_until_next_purchase': days_until_next_purchase,
'churn_probability': churn_probability,
'churn_reason': churn_reason,
'favorite_categories': categories, 'favorite_categories': categories,
'loyalty': { 'loyalty': {
'points_balance': loyalty_balance, 'points_balance': loyalty_balance,

View File

@@ -1,233 +0,0 @@
"""Cross-sell recommendations for the POS cart ("esto + esto").
Given the inventory_ids already in the cart, suggest complementary parts
from two independent sources, merged and ranked:
1. Curated part kits — ``services.part_kits.KIT_SUGGESTIONS`` (Spanish
aftermarket knowledge; works from day one, no sales data required).
2. Real co-occurrence — inventory items that historically appear in the
same ``sale_id`` as one of the cart items (learns from actual sales).
Items that already have stock are preferred, and items already in the cart
are never suggested.
Public API:
get_cross_sell(conn, inventory_ids, limit=5) -> list[dict]
"""
from services.part_kits import get_kit_suggestions
# Same accent-stripping used by catalog_service so that "Batería" in the
# inventory matches the unaccented keywords in KIT_SUGGESTIONS.
_ACCENTS = [
('Á', 'A'), ('É', 'E'), ('Í', 'I'), ('Ó', 'O'), ('Ú', 'U'),
('À', 'A'), ('È', 'E'), ('Ì', 'I'), ('Ò', 'O'), ('Ù', 'U'),
('Ä', 'A'), ('Ë', 'E'), ('Ï', 'I'), ('Ö', 'O'), ('Ü', 'U'), ('Ñ', 'N'),
]
# Ranking weights: stock first (a suggestion you cannot sell is useless),
# then source (real co-occurrence beats the curated kit), then evidence.
_W_STOCK = 1000
_W_COOC = 100
_W_KIT = 50
_W_FREQ = 10
def _norm_text(value):
"""Upper-case and strip accents for keyword comparison."""
text = (value or '').upper()
for a, b in _ACCENTS:
text = text.replace(a, b)
return text
def _norm_expr(column):
"""SQL expression that upper-cases and strips accents from a column."""
expr = f"UPPER({column})"
for a, b in _ACCENTS:
expr = f"REPLACE({expr}, '{a}', '{b}')"
return expr
def _cart_items(conn, inventory_ids):
"""Load the cart items (id, name) so we can derive kit keywords."""
cur = conn.cursor()
try:
cur.execute(
"SELECT id, part_number, name FROM inventory WHERE id = ANY(%s)",
(list(inventory_ids),),
)
return cur.fetchall()
finally:
cur.close()
def _kit_candidates(conn, cart_items, exclude_ids, limit):
"""Inventory rows whose name matches a curated kit keyword.
Returns list of (inventory_id, part_number, name, price, stock, reason).
"""
keyword_sources = {}
patterns = []
for _inv_id, _pn, name in cart_items:
for keyword in get_kit_suggestions(name):
norm = _norm_text(keyword)
if not norm or norm in keyword_sources:
continue
keyword_sources[norm] = (name, keyword)
patterns.append(f'%{norm}%')
if not patterns:
return []
name_expr = _norm_expr('i.name')
sql = f"""
SELECT DISTINCT ON (i.id)
i.id, i.part_number, i.name, i.price_1,
COALESCE(s.stock, 0) AS stock, {name_expr} AS name_norm
FROM inventory i
LEFT JOIN inventory_stock_summary s ON s.inventory_id = i.id
WHERE i.is_active = true
AND i.id <> ALL(%s)
AND {name_expr} LIKE ANY(%s)
ORDER BY i.id, i.name
LIMIT %s
"""
cur = conn.cursor()
try:
cur.execute(sql, (list(exclude_ids), patterns, limit * 4))
rows = cur.fetchall()
finally:
cur.close()
out = []
for inv_id, part_number, name, price, stock, name_norm in rows:
source_name, keyword = None, None
for norm, (src, kw) in keyword_sources.items():
if norm in (name_norm or ''):
source_name, keyword = src, kw
break
if source_name is None:
continue
out.append((inv_id, part_number, name, price, stock,
f"Complemento habitual de {source_name} ({keyword})"))
return out
def _cooccurrence_candidates(conn, inventory_ids, exclude_ids, limit):
"""Inventory items historically sold in the same sale as a cart item.
Returns list of (inventory_id, part_number, name, price, stock, freq, paired_with).
"""
cur = conn.cursor()
try:
cur.execute("""
SELECT si2.inventory_id,
COUNT(DISTINCT si2.sale_id) AS freq,
MIN(si1.name) AS paired_with
FROM sale_items si1
JOIN sale_items si2
ON si2.sale_id = si1.sale_id
AND si2.inventory_id IS NOT NULL
AND si2.inventory_id <> ALL(%s)
WHERE si1.inventory_id = ANY(%s)
GROUP BY si2.inventory_id
ORDER BY freq DESC, si2.inventory_id
LIMIT %s
""", (list(exclude_ids), list(inventory_ids), limit * 4))
pairs = cur.fetchall()
if not pairs:
return []
ids = [r[0] for r in pairs]
cur.execute("""
SELECT i.id, i.part_number, i.name, i.price_1,
COALESCE(s.stock, 0) AS stock
FROM inventory i
LEFT JOIN inventory_stock_summary s ON s.inventory_id = i.id
WHERE i.is_active = true AND i.id = ANY(%s)
""", (ids,))
by_id = {r[0]: r for r in cur.fetchall()}
finally:
cur.close()
out = []
for inv_id, freq, paired_with in pairs:
row = by_id.get(inv_id)
if not row:
continue
_rid, part_number, name, price, stock = row
out.append((inv_id, part_number, name, price, stock, freq, paired_with))
return out
def get_cross_sell(conn, inventory_ids, limit=5):
"""Suggest complementary parts for the given cart contents.
Args:
conn: psycopg2 connection to the tenant DB.
inventory_ids: iterable of inventory ids currently in the cart.
limit: max number of suggestions.
Returns:
list of dicts: {inventory_id, name, part_number, price, reason, stock, source}
"""
try:
cart_ids = [int(i) for i in (inventory_ids or [])]
except (TypeError, ValueError):
cart_ids = []
cart_ids = list(dict.fromkeys(cart_ids))[:50]
if not cart_ids:
return []
scored = {} # inventory_id -> dict with score + payload
try:
cart_items = _cart_items(conn, cart_ids)
except Exception:
conn.rollback()
return []
# 1. Co-occurrence (real sales) — highest evidence weight.
try:
for inv_id, part_number, name, price, stock, freq, paired_with in \
_cooccurrence_candidates(conn, cart_ids, cart_ids, limit):
score = (stock > 0) * _W_STOCK + _W_COOC + min(freq, 5) * _W_FREQ
scored[inv_id] = {
'inventory_id': inv_id,
'name': name,
'part_number': part_number,
'price': float(price) if price is not None else None,
'stock': int(stock or 0),
'reason': f"Suele venderse con {paired_with or 'este artículo'}",
'source': 'cooccurrence',
'_score': score,
}
except Exception:
conn.rollback()
# 2. Curated kits — fill in whatever co-occurrence did not cover.
try:
for inv_id, part_number, name, price, stock, reason in \
_kit_candidates(conn, cart_items, cart_ids, limit):
score = (stock > 0) * _W_STOCK + _W_KIT
if inv_id in scored:
# Already suggested by real sales: bump evidence, keep reason.
scored[inv_id]['_score'] += _W_KIT
continue
scored[inv_id] = {
'inventory_id': inv_id,
'name': name,
'part_number': part_number,
'price': float(price) if price is not None else None,
'stock': int(stock or 0),
'reason': reason,
'source': 'kit',
'_score': score,
}
except Exception:
conn.rollback()
ordered = sorted(scored.values(), key=lambda d: d['_score'], reverse=True)
for item in ordered:
item.pop('_score', None)
return ordered[:max(1, limit)]

View File

@@ -108,18 +108,6 @@ def _get_user_key() -> str | None:
return USER_KEY.strip() or None return USER_KEY.strip() or None
def _find_user_key(tenant_config: dict) -> str | None:
"""Return the Facturapi user key (env or sk_user_* tenant key), if any."""
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
return None
def _is_user_key_mode(tenant_config: dict) -> bool: def _is_user_key_mode(tenant_config: dict) -> bool:
return bool(_get_user_key()) and not _get_secret_key(tenant_config) return bool(_get_user_key()) and not _get_secret_key(tenant_config)
@@ -150,9 +138,7 @@ def get_organization(org_id: str, api_key: str) -> dict:
def upload_csd(tenant_config: dict, cer_b64: str, key_b64: str, password: str) -> dict: def upload_csd(tenant_config: dict, cer_b64: str, key_b64: str, password: str) -> dict:
"""Upload CSD (Certificado de Sello Digital) to Facturapi. """Upload CSD (Certificado de Sello Digital) to Facturapi.
cer_b64 and key_b64 are base64-encoded strings. Uses the documented cer_b64 and key_b64 are base64-encoded strings.
PUT /organizations/{id}/certificate endpoint with the `cer`, `key` and
`password` multipart fields.
""" """
api_key = get_api_key(tenant_config) api_key = get_api_key(tenant_config)
org_id = _get_org_id(tenant_config) org_id = _get_org_id(tenant_config)
@@ -164,11 +150,11 @@ def upload_csd(tenant_config: dict, cer_b64: str, key_b64: str, password: str) -
url = f"{BASE_URL}/organizations/{org_id}/certificate" url = f"{BASE_URL}/organizations/{org_id}/certificate"
files = { files = {
"cer": ("certificate.cer", cer_bytes, "application/octet-stream"), "certificate": ("certificate.cer", cer_bytes, "application/octet-stream"),
"key": ("private_key.key", key_bytes, "application/octet-stream"), "private_key": ("private_key.key", key_bytes, "application/octet-stream"),
"password": (None, password), "secret": (None, password),
} }
resp = requests.put(url, auth=(api_key, ""), files=files, timeout=60) resp = requests.post(url, auth=(api_key, ""), files=files, timeout=60)
if not resp.ok: if not resp.ok:
raise FacturapiError( raise FacturapiError(
f"CSD upload failed: {resp.status_code} {resp.text[:500]}", f"CSD upload failed: {resp.status_code} {resp.text[:500]}",
@@ -184,9 +170,13 @@ def _get_user_key_for_tenant(tenant_config: dict) -> str:
1. FACTURAPI_USER_KEY environment variable 1. FACTURAPI_USER_KEY environment variable
2. tenant_config.facturapi_key if it starts with sk_user_ 2. tenant_config.facturapi_key if it starts with sk_user_
""" """
user_key = _find_user_key(tenant_config) user_key = _get_user_key()
if user_key: if user_key:
return 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") raise FacturapiError("FACTURAPI_USER_KEY env or a Facturapi user key (sk_user_*) is required")
@@ -216,7 +206,7 @@ def find_organization_by_rfc(tenant_config: dict) -> dict | None:
def create_organization(tenant_config: dict) -> dict: def create_organization(tenant_config: dict) -> dict:
"""Create or reuse a Facturapi organization, configure legal data and return a live key. """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. Requires FACTURAPI_USER_KEY env or a user key (sk_user_*) in tenant_config.
Uses tenant RFC/razon_social if available. Uses tenant RFC/razon_social if available.
@@ -226,18 +216,8 @@ def create_organization(tenant_config: dict) -> dict:
rfc = (tenant_config.get("rfc") or "").upper().strip() rfc = (tenant_config.get("rfc") or "").upper().strip()
name = tenant_config.get("razon_social") or tenant_config.get("name") or rfc or "Nexus" name = tenant_config.get("razon_social") or tenant_config.get("name") or rfc or "Nexus"
# 1) Reuse the organization already stored locally, if it still exists in Facturapi. # First try to find existing org by RFC
local_org_id = _get_org_id(tenant_config) existing = find_organization_by_rfc(tenant_config) if rfc else None
if local_org_id:
try:
get_organization(local_org_id, user_key)
org_id = local_org_id
except FacturapiError:
local_org_id = None
# 2) Try to find an existing organization by RFC (legacy / Horux-style lookup).
if not local_org_id and rfc:
existing = find_organization_by_rfc(tenant_config)
if existing: if existing:
org_id = existing["id"] org_id = existing["id"]
else: else:
@@ -246,20 +226,6 @@ def create_organization(tenant_config: dict) -> dict:
org_id = org.get("id") org_id = org.get("id")
if not org_id: if not org_id:
raise FacturapiError("Could not create organization: no id returned") raise FacturapiError("Could not create organization: no id returned")
elif not local_org_id:
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")
# Configure fiscal/legal data (required before Live mode can be used)
try:
update_organization_legal(tenant_config, org_id)
except FacturapiError:
# If legal update fails, still return org info so the caller can surface it,
# but do not generate a live key because it would be unusable.
return {"org_id": org_id, "api_key": None, "legal_updated": False}
# Generate live secret key # Generate live secret key
key_resp = _request("PUT", f"/organizations/{org_id}/apikeys/live", user_key, json_payload={}, timeout=60) key_resp = _request("PUT", f"/organizations/{org_id}/apikeys/live", user_key, json_payload={}, timeout=60)
@@ -267,80 +233,7 @@ def create_organization(tenant_config: dict) -> dict:
if not live_key: if not live_key:
raise FacturapiError(f"Could not generate live key for org {org_id}") raise FacturapiError(f"Could not generate live key for org {org_id}")
return {"org_id": org_id, "api_key": live_key, "legal_updated": True} return {"org_id": org_id, "api_key": live_key}
def _build_legal_payload(tenant_config: dict) -> dict:
"""Build Facturapi /organizations/{id}/legal payload from tenant config.
Facturapi expects `name`, `legal_name`, `tax_system` and an `address` object
with at least `street` and `exterior` non-empty. `tax_id` is not accepted
by this endpoint (it is set from the CSD or from the organization profile).
"""
rfc = (tenant_config.get("rfc") or "").upper().strip()
legal_name = (tenant_config.get("razon_social") or "").strip()
tax_system = (tenant_config.get("regimen_fiscal") or "").strip() or "601"
zip_code = (tenant_config.get("cp") or "").strip() or "00000"
if not rfc or not legal_name:
raise FacturapiError("RFC y Razón Social son obligatorios para configurar la organización en Facturapi")
name = legal_name or rfc or "Nexus"
street = (tenant_config.get("direccion") or "").strip() or "No especificada"
exterior = (
(tenant_config.get("exterior") or "").strip()
or (tenant_config.get("numero_exterior") or "").strip()
or "S/N"
)
address = {
"zip": zip_code,
"street": street,
"exterior": exterior,
}
optional_address_fields = {
"interior": (tenant_config.get("interior") or tenant_config.get("numero_interior") or "").strip(),
"neighborhood": (tenant_config.get("colonia") or "").strip(),
"city": (tenant_config.get("ciudad") or "").strip(),
"municipality": (tenant_config.get("municipio") or "").strip(),
"state": (tenant_config.get("estado") or "").strip(),
}
for key, value in optional_address_fields.items():
if value:
address[key] = value
return {
"name": name,
"legal_name": legal_name,
"tax_system": tax_system,
"address": address,
}
def update_organization_legal(tenant_config: dict, org_id: str) -> dict:
"""Update the legal/fiscal data of a Facturapi organization.
Uses the user key (admin) because the org may not have a live key yet.
"""
user_key = _get_user_key_for_tenant(tenant_config)
payload = _build_legal_payload(tenant_config)
return _request("PUT", f"/organizations/{org_id}/legal", user_key, json_payload=payload, timeout=60)
def _get_status_key(tenant_config: dict) -> str | None:
"""Return the best key for read-only organization status checks.
Prefer the Facturapi user key because the live secret key cannot read
organization metadata until the org is production-ready.
"""
user = _get_user_key()
if user:
return user
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
return _get_secret_key(tenant_config)
def get_org_status(tenant_config: dict) -> dict: def get_org_status(tenant_config: dict) -> dict:
@@ -356,12 +249,12 @@ def get_org_status(tenant_config: dict) -> dict:
"error": None, "error": None,
} }
has_secret = bool(_get_secret_key(tenant_config)) try:
has_user = bool(_find_user_key(tenant_config)) api_key = get_api_key(tenant_config)
if not has_secret and not has_user:
result["error"] = "Facturapi not configured. Set FACTURAPI_USER_KEY env or tenant_config.facturapi_secret_key"
return result
result["has_key"] = True result["has_key"] = True
except FacturapiError as e:
result["error"] = str(e)
return result
org_id = _get_org_id(tenant_config) org_id = _get_org_id(tenant_config)
if not org_id: if not org_id:
@@ -371,36 +264,20 @@ def get_org_status(tenant_config: dict) -> dict:
result["has_org_id"] = True result["has_org_id"] = True
result["org_id"] = org_id result["org_id"] = org_id
api_key = _get_status_key(tenant_config) try:
if not api_key: org = get_organization(org_id, api_key)
result["error"] = "No Facturapi key available" legal = org.get("legal", {})
return result cert = org.get("certificate", {})
result.update(
def _fetch(key=None): {
org = get_organization(org_id, key or api_key)
legal = org.get("legal") or {}
cert = org.get("certificate") or {}
return {
"configured": True, "configured": True,
"has_csd": bool(cert.get("has_certificate")), "has_csd": bool(cert.get("has_certificate")),
"legal_name": legal.get("name") or legal.get("legal_name"), "legal_name": legal.get("name") or legal.get("legal_name"),
"tax_id": legal.get("tax_id"), "tax_id": legal.get("tax_id"),
"pending_steps": org.get("pending_steps", []), "pending_steps": org.get("pending_steps", []),
} }
)
try:
result.update(_fetch())
except FacturapiError as e: except FacturapiError as e:
# If the org reports "not configured for Live" (401) and we have a user key,
# try to backfill legal data and retry.
user_key = _find_user_key(tenant_config)
if e.status_code == 401 and user_key:
try:
update_organization_legal(tenant_config, org_id)
result.update(_fetch(user_key))
except FacturapiError as e2:
result["error"] = str(e2)
else:
result["error"] = str(e) result["error"] = str(e)
return result return result

View File

@@ -58,18 +58,19 @@ def get_eligible_sales(conn, year, month, branch_id=None, max_total=2000):
cur.close() cur.close()
return [] return []
# Load sale details with items in two bulk queries (O(1) round-trips) # Load sale details with items
sales = []
for sale_id in sale_ids:
cur.execute(""" cur.execute("""
SELECT id, branch_id, customer_id, employee_id, sale_type, SELECT id, branch_id, customer_id, employee_id, sale_type,
payment_method, subtotal, discount_total, tax_total, total, payment_method, subtotal, discount_total, tax_total, total,
metodo_pago_sat, forma_pago_sat, status, created_at metodo_pago_sat, forma_pago_sat, status, created_at
FROM sales FROM sales WHERE id = %s
WHERE id = ANY(%s) """, (sale_id,))
ORDER BY created_at ASC row = cur.fetchone()
""", (sale_ids,)) if not row:
continue
sales = {}
for row in cur.fetchall():
sale = { sale = {
'id': row[0], 'branch_id': row[1], 'customer_id': row[2], 'id': row[0], 'branch_id': row[1], 'customer_id': row[2],
'employee_id': row[3], 'sale_type': row[4], 'employee_id': row[3], 'sale_type': row[4],
@@ -84,37 +85,33 @@ def get_eligible_sales(conn, year, month, branch_id=None, max_total=2000):
'created_at': str(row[13]), 'created_at': str(row[13]),
'items': [], 'items': [],
} }
sales[row[0]] = sale
cur.execute(""" cur.execute("""
SELECT id, sale_id, inventory_id, part_number, name, quantity, unit_price, SELECT id, inventory_id, part_number, name, quantity, unit_price,
unit_cost, discount_pct, discount_amount, tax_rate, tax_amount, unit_cost, discount_pct, discount_amount, tax_rate, tax_amount,
subtotal, clave_prod_serv, clave_unidad subtotal, clave_prod_serv, clave_unidad
FROM sale_items FROM sale_items WHERE sale_id = %s ORDER BY id
WHERE sale_id = ANY(%s) """, (sale_id,))
ORDER BY sale_id, id
""", (sale_ids,))
for r in cur.fetchall(): for r in cur.fetchall():
sale = sales.get(r[1])
if not sale:
continue
sale['items'].append({ sale['items'].append({
'id': r[0], 'inventory_id': r[2], 'part_number': r[3], 'id': r[0], 'inventory_id': r[1], 'part_number': r[2],
'name': r[4], 'quantity': r[5], 'name': r[3], 'quantity': r[4],
'unit_price': float(r[6]) if r[6] else 0, 'unit_price': float(r[5]) if r[5] else 0,
'unit_cost': float(r[7]) if r[7] else 0, 'unit_cost': float(r[6]) if r[6] else 0,
'discount_pct': float(r[8]) if r[8] else 0, 'discount_pct': float(r[7]) if r[7] else 0,
'discount_amount': float(r[9]) if r[9] else 0, 'discount_amount': float(r[8]) if r[8] else 0,
'tax_rate': float(r[10]) if r[10] else 0.16, 'tax_rate': float(r[9]) if r[9] else 0.16,
'tax_amount': float(r[11]) if r[11] else 0, 'tax_amount': float(r[10]) if r[10] else 0,
'subtotal': float(r[12]) if r[12] else 0, 'subtotal': float(r[11]) if r[11] else 0,
'clave_prod_serv': r[13] or '25174800', 'clave_prod_serv': r[12] or '25174800',
'clave_unidad': r[14] or 'H87', 'clave_unidad': r[13] or 'H87',
}) })
sales.append(sale)
cur.close() cur.close()
return list(sales.values()) return sales
def generate_global_invoice(conn, tenant_config, year, month, branch_id=None, def generate_global_invoice(conn, tenant_config, year, month, branch_id=None,

View File

@@ -122,14 +122,7 @@ def record_operation(conn, inventory_id, branch_id, operation_type, quantity,
)) ))
op_id = cur.fetchone()[0] op_id = cur.fetchone()[0]
# Queue ML stock sync if this product has an active ML listing. # Queue ML stock sync if this product has an active ML listing
# Skip gracefully if the marketplace tables do not exist in this tenant.
cur.execute("""
SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name IN ('marketplace_listings', 'meli_sync_queue')
""")
if cur.fetchone()[0] >= 2:
cur.execute(""" cur.execute("""
INSERT INTO meli_sync_queue (inventory_id, action, status) INSERT INTO meli_sync_queue (inventory_id, action, status)
SELECT %s, 'stock_update', 'pending' SELECT %s, 'stock_update', 'pending'
@@ -237,54 +230,6 @@ def record_sale(conn, inventory_id, branch_id, quantity, sale_id=None, cost_at_t
return op_id return op_id
def record_reservation(conn, inventory_id, branch_id, quantity, sale_id=None,
cost_at_time=None, remaining_stock=None):
"""Reserve stock for a counter remission note (negative quantity).
The reserved quantity is deducted from available stock until the note is
paid or cancelled.
"""
op_id = record_operation(
conn, inventory_id, branch_id, 'REMISSION_RESERVE', -abs(quantity),
reference_id=sale_id, reference_type='sale', cost_at_time=cost_at_time,
notes='Reserva por nota de remision'
)
invalidate_stock(inventory_id, branch_id)
invalidate_stock(inventory_id, None)
try:
remaining = remaining_stock if remaining_stock is not None else get_stock(conn, inventory_id, branch_id)
if remaining <= 0:
cur = conn.cursor()
cur.execute("SELECT part_number, name FROM inventory WHERE id = %s", (inventory_id,))
inv_row = cur.fetchone()
cur.close()
if inv_row:
from services.push_service import notify_owner
notify_owner(
conn,
'Stock en Cero',
f'{inv_row[1] or inv_row[0]} se quedo sin existencias',
'/pos'
)
except Exception:
pass
return op_id
def release_reservation(conn, inventory_id, branch_id, quantity, sale_id=None, notes=None):
"""Release a previous reservation (positive quantity)."""
result = record_operation(
conn, inventory_id, branch_id, 'REMISSION_RELEASE', abs(quantity),
reference_id=sale_id, reference_type='sale',
notes=notes or 'Liberacion de reserva de nota de remision'
)
invalidate_stock(inventory_id, branch_id)
invalidate_stock(inventory_id, None)
return result
def record_return(conn, inventory_id, branch_id, quantity, sale_id=None, notes=None): def record_return(conn, inventory_id, branch_id, quantity, sale_id=None, notes=None):
"""Record a customer return (positive quantity).""" """Record a customer return (positive quantity)."""
result = record_operation( result = record_operation(
@@ -429,7 +374,7 @@ def get_movement_history(conn, inventory_id, limit=50):
history.append({ history.append({
'id': r[0], 'type': r[1], 'quantity': r[2], 'id': r[0], 'type': r[1], 'quantity': r[2],
'cost': float(r[3]) if r[3] else None, 'cost': float(r[3]) if r[3] else None,
'notes': r[4], 'date': r[5].isoformat() if r[5] else None, 'notes': r[4], 'date': str(r[5]),
'employee': r[6], 'branch_id': r[7] 'employee': r[6], 'branch_id': r[7]
}) })
cur.close() cur.close()

View File

@@ -35,83 +35,184 @@ def get_compat_source(tenant_id):
return 'both' return 'both'
def _norm_pn(pn):
"""Normalize a part number: uppercase, strip spaces and separators."""
if not pn:
return ''
return (str(pn).replace(' ', '').replace('-', '').replace('/', '')
.replace('.', '').replace('_', '').upper())
def auto_match_vehicle_compatibility(master_conn, tenant_conn, inventory_id, part_number, def auto_match_vehicle_compatibility(master_conn, tenant_conn, inventory_id, part_number,
brand=None, name=None): brand=None, name=None):
"""Find vehicle compatibility for an inventory item via the supplier catalog. """Find vehicle compatibility for an inventory item by part_number.
Legacy note: the original implementation queried the removed TecDoc tables Searches (in order of precision):
(parts / aftermarket_parts / part_cross_references / vehicle_parts) and 1. parts.oem_part_number exact normalized
always failed. This version uses the LIVE master tables: 2. aftermarket_parts.part_number exact normalized
1. supplier_catalog.sku exact-normalized 3. part_cross_references.cross_reference_number exact normalized
2. supplier_catalog_interchange.part_number exact-normalized (cross-refs) 4. parts.oem_part_number partial (ILIKE) — strips separators too
3. prefix fallback on catalog SKU (only for >=5 char numbers, few matches) 5. aftermarket_parts.part_number partial (ILIKE)
then collects DISTINCT model_year_engine_id from supplier_catalog_compat 6. part_cross_references.cross_reference_number partial (ILIKE)
and inserts them into the tenant inventory_vehicle_compat table. 7. parts.name_part / parts.name_es by inventory item name (ILIKE)
8. parts.name_part / parts.name_es by inventory item name (tsvector)
Returns dict {'matched': bool, 'matches': [...], 'myes': [...], 'inserted': int} If a brand hint is provided, results are preferentially filtered to
(same shape as the legacy implementation). vehicles of that brand.
Returns:
dict: {'matched': bool, 'matches': [...], 'myes': [...]}
""" """
cur = master_conn.cursor() cur = master_conn.cursor()
clean_pn = _norm_pn(part_number) oem_ids = set()
catalog_ids = set()
# Normalization helpers
clean_pn = (part_number or '').replace(' ', '').upper()
no_seps = clean_pn.replace('-', '').replace('/', '').replace('.', '').replace('_', '')
name_q = (name or '').strip()
brand_hint = (brand or '').strip().upper()
# ── 1. Exact normalized ──────────────────────────────────────────────
if clean_pn: if clean_pn:
# 1. Exact match on catalog SKU (with and without inner spaces)
cur.execute(""" cur.execute("""
SELECT id FROM supplier_catalog SELECT id_part FROM parts
WHERE REPLACE(UPPER(sku), '-', '') = %s WHERE REPLACE(UPPER(oem_part_number), ' ', '') = %s
OR REPLACE(UPPER(REPLACE(sku, ' ', '')), '-', '') = %s
LIMIT 50
""", (clean_pn, clean_pn))
for r in cur.fetchall():
catalog_ids.add(r[0])
# 2. Exact match on interchange part numbers (cross references)
if not catalog_ids:
cur.execute("""
SELECT DISTINCT catalog_id FROM supplier_catalog_interchange
WHERE REPLACE(UPPER(REPLACE(part_number, '-', '')), ' ', '') = %s
LIMIT 50
""", (clean_pn,)) """, (clean_pn,))
for r in cur.fetchall(): for r in cur.fetchall():
catalog_ids.add(r[0]) oem_ids.add(r[0])
# 3. Prefix fallback for longer numbers when exact found little if clean_pn and not oem_ids:
if len(clean_pn) >= 5 and len(catalog_ids) < 3:
cur.execute(""" cur.execute("""
SELECT id FROM supplier_catalog SELECT DISTINCT oem_part_id FROM aftermarket_parts
WHERE REPLACE(UPPER(sku), '-', '') LIKE %s WHERE REPLACE(UPPER(part_number), ' ', '') = %s
LIMIT 10 """, (clean_pn,))
""", (clean_pn[:8] + '%',))
for r in cur.fetchall(): for r in cur.fetchall():
catalog_ids.add(r[0]) if r[0]:
oem_ids.add(r[0])
mye_ids = [] if clean_pn and not oem_ids:
if catalog_ids:
cur.execute(""" cur.execute("""
SELECT DISTINCT model_year_engine_id FROM supplier_catalog_compat SELECT DISTINCT part_id FROM part_cross_references
WHERE catalog_id = ANY(%s) AND model_year_engine_id IS NOT NULL WHERE REPLACE(UPPER(cross_reference_number), ' ', '') = %s
""", (clean_pn,))
for r in cur.fetchall():
if r[0]:
oem_ids.add(r[0])
# ── 2. Partial / separator-stripped ──────────────────────────────────
if clean_pn and len(clean_pn) >= 3 and len(oem_ids) < 10:
# OEM part number partial
cur.execute("""
SELECT id_part FROM parts
WHERE REPLACE(UPPER(oem_part_number), ' ', '') LIKE %s
AND id_part NOT IN (SELECT unnest(%s::int[]))
LIMIT 20
""", (f'%{clean_pn}%', list(oem_ids) or [0]))
for r in cur.fetchall():
oem_ids.add(r[0])
# Aftermarket partial
if len(oem_ids) < 10:
cur.execute("""
SELECT DISTINCT oem_part_id FROM aftermarket_parts
WHERE REPLACE(UPPER(part_number), ' ', '') LIKE %s
AND oem_part_id NOT IN (SELECT unnest(%s::int[]))
LIMIT 20
""", (f'%{clean_pn}%', list(oem_ids) or [0]))
for r in cur.fetchall():
if r[0]:
oem_ids.add(r[0])
# Cross-reference partial
if len(oem_ids) < 10:
cur.execute("""
SELECT DISTINCT part_id FROM part_cross_references
WHERE REPLACE(UPPER(cross_reference_number), ' ', '') LIKE %s
AND part_id NOT IN (SELECT unnest(%s::int[]))
LIMIT 20
""", (f'%{clean_pn}%', list(oem_ids) or [0]))
for r in cur.fetchall():
if r[0]:
oem_ids.add(r[0])
# Separator-stripped fallback (e.g. KYB-343412 → KYB343412)
if len(no_seps) >= 4 and len(oem_ids) < 10:
cur.execute("""
SELECT id_part FROM parts
WHERE REPLACE(REPLACE(REPLACE(REPLACE(UPPER(oem_part_number), ' ', ''), '-', ''), '/', ''), '.', '') = %s
AND id_part NOT IN (SELECT unnest(%s::int[]))
LIMIT 10
""", (no_seps, list(oem_ids) or [0]))
for r in cur.fetchall():
oem_ids.add(r[0])
# ── 3. Search by item name (when part_number yields nothing) ─────────
if not oem_ids and name_q and len(name_q) >= 3:
# Name-based ILIKE search on parts
cur.execute("""
SELECT id_part FROM parts
WHERE UPPER(name_part) LIKE %s OR UPPER(COALESCE(name_es, '')) LIKE %s
LIMIT 20
""", (f'%{name_q.upper()}%', f'%{name_q.upper()}%'))
for r in cur.fetchall():
oem_ids.add(r[0])
# Name-based ILIKE search on aftermarket_parts
if len(oem_ids) < 20:
cur.execute("""
SELECT DISTINCT oem_part_id FROM aftermarket_parts
WHERE UPPER(COALESCE(name_aftermarket_parts, '')) LIKE %s
OR UPPER(COALESCE(name_es, '')) LIKE %s
LIMIT 20
""", (f'%{name_q.upper()}%', f'%{name_q.upper()}%'))
for r in cur.fetchall():
if r[0]:
oem_ids.add(r[0])
if not oem_ids:
cur.close()
return {'matched': False, 'matches': [], 'myes': []}
# ── Resolve MYEs ─────────────────────────────────────────────────────
oem_ids = list(oem_ids)
if brand_hint and brand_hint != 'GENERAL':
# Brand-aware: only MYEs for vehicles of the hinted brand
cur.execute("""
SELECT DISTINCT vp.model_year_engine_id
FROM vehicle_parts vp
JOIN model_year_engine mye ON mye.id_mye = vp.model_year_engine_id
JOIN models m ON m.id_model = mye.model_id
JOIN brands b ON b.id_brand = m.brand_id
WHERE vp.part_id = ANY(%s)
AND b.name_brand = %s
LIMIT 500 LIMIT 500
""", (list(catalog_ids),)) """, (oem_ids, brand_hint))
mye_ids = [r[0] for r in cur.fetchall()] 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 500
""", (oem_ids,))
mye_ids = [r[0] for r in cur.fetchall()]
cur.close() cur.close()
# ── Insert into tenant table ─────────────────────────────────────────
inserted = 0 inserted = 0
if mye_ids:
cur2 = tenant_conn.cursor() cur2 = tenant_conn.cursor()
for mye_id in mye_ids: for mye_id in mye_ids:
cur2.execute(""" cur2.execute("""
INSERT INTO inventory_vehicle_compat INSERT INTO inventory_vehicle_compat
(inventory_id, model_year_engine_id, source, confidence) (inventory_id, model_year_engine_id, source, confidence)
VALUES (%s, %s, 'supplier_catalog', 0.95) VALUES (%s, %s, 'auto_match', 1.0)
ON CONFLICT (inventory_id, model_year_engine_id) DO NOTHING ON CONFLICT (inventory_id, model_year_engine_id) DO NOTHING
""", (inventory_id, mye_id)) """, (inventory_id, mye_id))
if cur2.rowcount > 0: if cur2.rowcount > 0:
@@ -121,7 +222,7 @@ def auto_match_vehicle_compatibility(master_conn, tenant_conn, inventory_id, par
return { return {
'matched': len(mye_ids) > 0, 'matched': len(mye_ids) > 0,
'matches': sorted(catalog_ids), 'matches': oem_ids,
'myes': mye_ids, 'myes': mye_ids,
'inserted': inserted, 'inserted': inserted,
} }

View File

@@ -1,474 +0,0 @@
# /home/Autopartes/pos/services/invoice_ocr.py
"""Supplier-invoice OCR / parsing.
Accepts an image or PDF of a supplier invoice and returns structured lines:
[{part_number, name, quantity, unit_cost}]
Pipeline (first stage that yields lines wins):
1. PDF with a text layer -> ``pdftotext -layout``
2. Scanned PDF -> ``pdftoppm`` (first page) + Tesseract
3. Image -> Tesseract
4. Vision fallback -> ``ai_chat`` (QWEN / OpenRouter vision model)
Stock is NEVER touched here: the caller returns the parsed lines so the
front-end can confirm before creating a purchase order.
Public API:
parse_invoice(data, filename=None, content_type=None, use_vision=True) -> dict
"""
import base64
import io
import json
import os
import re
import shutil
import subprocess
import tempfile
_MONEY_RE = re.compile(r"^\$?\s?\d{1,3}(?:[,\d]{0,15})(?:\.\d{1,2})?$")
_INT_RE = re.compile(r"^\d{1,4}$")
_PN_RE = re.compile(r"^[A-Z0-9][A-Z0-9\-\./_]{2,}$")
# Lines that are clearly headers/totals, not invoice items.
_SKIP_TOKENS = {
"factura", "remision", "total", "subtotal", "iva", "ieps", "rfc", "fecha",
"pedido", "cliente", "proveedor", "telefono", "direccion", "calle",
"colonia", "cantidad", "cant", "descripcion", "precio", "importe",
"unidad", "folio", "orden", "pagina", "gracias", "nota", "clave",
"codigo", "producto", "articulo", "descuento", "moneda", "condiciones",
"subtotal:", "total:", "iva:", "rfc:", "fecha:", "importe:", "precio:",
}
# Any of these as a whole word marks a header/footer line (RFC, date, totals).
_HEADER_WORDS = {
"rfc", "fecha", "folio", "cliente", "proveedor", "factura", "remision",
"remisión", "pedido", "telefono", "tel", "direccion", "dirección", "calle",
"colonia", "condiciones", "moneda", "subtotal", "total", "iva", "ieps",
"clave", "descripcion", "descripción", "cantidad", "unidad", "descuento",
"importe", "precio", "page",
}
_VISION_PROMPT = (
"Eres un experto en facturas de refaccionarias mexicanas. Extrae TODAS las "
"lineas de producto de la imagen. Devuelve SOLO JSON valido, sin texto extra, "
"con esta forma exacta: "
'{"lines":[{"part_number":"...","name":"...","quantity":1,"unit_cost":0.0}]}. '
"part_number = clave/SKU/numero de parte; name = descripcion; quantity = "
"cantidad (entero); unit_cost = precio unitario (numero, sin simbolos). "
"Si no hay lineas claras devuelve {\"lines\": []}."
)
# ── Binary helpers ─────────────────────────────────────────────────────────
def _is_pdf(data, filename):
if data[:5] == b"%PDF-":
return True
return bool(filename and filename.lower().endswith(".pdf"))
def _pdftotext(data):
"""Extract the text layer of a PDF. Returns '' on failure."""
exe = shutil.which("pdftotext")
if not exe:
return ""
try:
proc = subprocess.run(
[exe, "-layout", "-", "-"],
input=data, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=60,
)
if proc.returncode == 0:
return proc.stdout.decode("utf-8", "replace")
except Exception:
pass
return ""
def _pdf_first_page_png(data, dpi=200):
"""Render the first PDF page to PNG bytes (for OCR). None on failure."""
exe = shutil.which("pdftoppm")
if not exe:
return None
try:
with tempfile.TemporaryDirectory() as td:
pdf_path = os.path.join(td, "in.pdf")
with open(pdf_path, "wb") as f:
f.write(data)
prefix = os.path.join(td, "page")
subprocess.run(
[exe, "-png", "-r", str(dpi), "-f", "1", "-l", "1", pdf_path, prefix],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=120,
)
for fn in sorted(os.listdir(td)):
if fn.startswith("page") and fn.endswith(".png"):
with open(os.path.join(td, fn), "rb") as f:
return f.read()
except Exception:
pass
return None
def _ocr_langs():
try:
import pytesseract
langs = pytesseract.get_languages(config="")
for wanted in ("spa", "eng"):
if wanted in langs:
return wanted
except Exception:
pass
return "eng"
def _ocr_image(img_bytes):
"""Run Tesseract over image bytes. Returns '' on failure."""
try:
from PIL import Image
import pytesseract
except Exception:
return ""
try:
img = Image.open(io.BytesIO(img_bytes))
if img.mode not in ("RGB", "L"):
img = img.convert("RGB")
return pytesseract.image_to_string(img, lang=_ocr_langs())
except Exception:
return ""
def _ocr_rows(img_bytes, y_tolerance=12):
"""OCR an image and rebuild table rows from word positions.
Tesseract's default layout analysis splits a monospaced invoice table into
one block per column, so ``image_to_string`` returns each column stacked
instead of each row. We regroup words by their vertical center instead.
Returns a list of row strings (falls back to plain lines on failure).
"""
try:
from PIL import Image
import pytesseract
except Exception:
return []
try:
img = Image.open(io.BytesIO(img_bytes))
if img.mode not in ("RGB", "L"):
img = img.convert("RGB")
data = pytesseract.image_to_data(
img, lang=_ocr_langs(), output_type=pytesseract.Output.DICT
)
except Exception:
text = _ocr_image(img_bytes)
return text.splitlines() if text else []
groups = {} # representative y -> list of (left, text)
for i, txt in enumerate(data.get("text", [])):
txt = (txt or "").strip()
if not txt:
continue
try:
top = int(data["top"][i])
h = int(data["height"][i] or 0)
left = int(data["left"][i])
except (KeyError, ValueError, TypeError):
continue
cy = top + h // 2
key = None
for k in groups:
if abs(k - cy) <= max(y_tolerance, h // 2):
key = k
break
groups.setdefault(cy if key is None else key, []).append((left, txt))
rows = []
for cy in sorted(groups):
rows.append(" ".join(t for _, t in sorted(groups[cy])))
return rows
def _ocr_rows_text(img_bytes):
"""Row-reconstructed OCR text (falls back to raw Tesseract output)."""
rows = _ocr_rows(img_bytes)
if rows:
return "\n".join(rows)
return _ocr_image(img_bytes)
# ── Text line parser ───────────────────────────────────────────────────────
def _is_money(tok):
if tok.startswith("$"):
return True
if not _MONEY_RE.match(tok):
return False
return ("." in tok) or ("," in tok)
def _to_money(tok):
cleaned = re.sub(r"[^\d.]", "", tok.replace(",", ""))
try:
return float(cleaned)
except ValueError:
return None
def _is_part_number(tok):
if _is_money(tok):
return False
if not _PN_RE.match(tok):
return False
has_alpha = any(c.isalpha() for c in tok)
digits = sum(c.isdigit() for c in tok)
if has_alpha:
return digits >= 1 and len(tok) >= 4
# pure numeric part numbers are accepted only if long enough to not be a qty
return len(tok) >= 4
def _skip_line(line):
low = line.lower()
if low.strip(".:-") in _SKIP_TOKENS:
return True
words = set(re.findall(r"[a-záéíóúñ]+", low))
if words & _HEADER_WORDS:
return True
if words and all(t in _SKIP_TOKENS or t in ("de", "del", "la", "el") for t in words):
return True
return False
def _parse_line(line):
tokens = line.split()
if len(tokens) < 3:
return None
money_idx = [i for i, t in enumerate(tokens) if _is_money(t)]
int_idx = [i for i, t in enumerate(tokens) if _INT_RE.match(t)]
pn_idx = [i for i, t in enumerate(tokens) if _is_part_number(t)]
if not pn_idx or (not money_idx and not int_idx):
return None
qty, qty_i = None, None
for i in int_idx:
if i in money_idx:
continue
v = int(tokens[i])
if 1 <= v <= 9999:
qty, qty_i = v, i
break
unit, unit_i = None, None
if money_idx:
unit_i = money_idx[0]
unit = _to_money(tokens[unit_i])
pn_i = pn_idx[0]
used = {i for i in (pn_i, qty_i) if i is not None} | set(money_idx)
name = " ".join(t for i, t in enumerate(tokens) if i not in used).strip(" -|")
if not name:
name = None
if qty is None and unit is None:
return None
return {
"part_number": tokens[pn_i],
"name": name,
"quantity": qty,
"unit_cost": unit,
}
def parse_lines(text):
"""Parse raw invoice text into structured lines."""
out = []
seen = set()
for raw in (text or "").splitlines():
line = raw.strip()
if len(line) < 6 or _skip_line(line):
continue
row = _parse_line(line)
if not row:
continue
key = (row["part_number"].upper(), row["quantity"], row["unit_cost"])
if key in seen:
continue
seen.add(key)
out.append(row)
return out
# ── Vision fallback ────────────────────────────────────────────────────────
def _extract_json(text):
if not text:
return None
s = text.strip()
if s.startswith("```"):
s = re.sub(r"^```[a-zA-Z]*\n?", "", s)
s = re.sub(r"\n?```$", "", s)
try:
return json.loads(s)
except Exception:
pass
m = re.search(r"\{.*\}", s, re.DOTALL)
if m:
try:
return json.loads(m.group(0))
except Exception:
return None
return None
def _coerce_lines(obj):
if not obj:
return []
raw = obj.get("lines") if isinstance(obj, dict) else None
if raw is None and isinstance(obj, list):
raw = obj
if not isinstance(raw, list):
return []
lines = []
for item in raw:
if not isinstance(item, dict):
continue
pn = item.get("part_number") or item.get("clave") or item.get("sku")
name = item.get("name") or item.get("descripcion") or item.get("description")
qty = item.get("quantity", item.get("cantidad"))
cost = item.get("unit_cost", item.get("precio", item.get("unit_price")))
try:
qty = int(qty) if qty is not None else None
except (ValueError, TypeError):
qty = None
try:
cost = float(cost) if cost is not None else None
except (ValueError, TypeError):
cost = None
if pn or name:
lines.append({
"part_number": str(pn).strip() if pn else None,
"name": str(name).strip() if name else None,
"quantity": qty, "unit_cost": cost,
})
return lines
def _vision_extract(image_bytes, hint_text=None):
"""Extract lines with the vision model. Returns (lines, error)."""
try:
from services import ai_chat
except Exception as e:
return [], f"ai_chat import: {e}"
b64 = base64.b64encode(image_bytes).decode("ascii")
prompt = _VISION_PROMPT
if hint_text:
prompt += "\n\nTexto OCR parcial (puede estar incompleto):\n" + hint_text[:1500]
# 1) reuse chat_with_image (per task requirement)
try:
res = ai_chat.chat_with_image(prompt, b64)
lines = _coerce_lines(res)
if lines:
return lines, None
msg = res.get("message") if isinstance(res, dict) else None
lines = _coerce_lines(_extract_json(msg) if msg else None)
if lines:
return lines, None
except Exception:
pass
# 2) direct vision call with an invoice-specific system prompt
try:
data_url = "data:image/jpeg;base64," + b64
messages = [
{"role": "system", "content": "Responde SOLO JSON valido."},
{"role": "user", "content": [
{"type": "image_url", "image_url": {"url": data_url}},
{"type": "text", "text": prompt},
]},
]
backends = []
if ai_chat.QWEN_ENABLED:
backends.append((ai_chat.QWEN_CHAT_URL, ai_chat.QWEN_API_KEY, ai_chat.QWEN_MODEL))
if ai_chat.OPENROUTER_API_KEY:
backends.append((ai_chat.OPENROUTER_URL, ai_chat.OPENROUTER_API_KEY, ai_chat.VISION_MODEL))
for url, key, model in backends:
result = ai_chat._post_chat_completion(
url, key, model, messages, max_tokens=1200, temperature=0.1, timeout=60
)
if not result:
continue
lines = _coerce_lines(_extract_json(result["content"]))
if lines:
return lines, None
return [], "vision sin lineas"
except Exception as e:
return [], f"vision: {e}"
# ── Public API ─────────────────────────────────────────────────────────────
def parse_invoice(data, filename=None, content_type=None, use_vision=True):
"""Parse an invoice image/PDF into structured lines.
Returns:
{
"lines": [{part_number, name, quantity, unit_cost}],
"count": int,
"method": str, # pdftotext | pdftoppm+tesseract | tesseract | ...+vision
"warnings": [str],
"text_preview": str,
}
"""
warnings = []
text = ""
method = "unknown"
vision_image = None # bytes to hand to the vision model when OCR fails
if not data:
return {"lines": [], "count": 0, "method": "empty",
"warnings": ["archivo vacío"], "text_preview": ""}
if _is_pdf(data, filename):
text = _pdftotext(data)
if len(text.strip()) >= 30:
method = "pdftotext"
else:
png = _pdf_first_page_png(data)
if png:
vision_image = png
text = _ocr_rows_text(png)
method = "pdftoppm+tesseract" if text.strip() else "pdftoppm"
if not text.strip():
warnings.append("PDF sin capa de texto y OCR vacío")
else:
method = "pdf"
warnings.append("No se pudo renderizar el PDF")
if not vision_image:
# for vision fallback on a text PDF we still need a raster copy
vision_image = _pdf_first_page_png(data)
else:
vision_image = data
text = _ocr_rows_text(data)
method = "tesseract"
if not text.strip():
warnings.append("OCR sin texto reconocible")
lines = parse_lines(text)
if not lines and use_vision and vision_image:
vlines, verr = _vision_extract(vision_image, hint_text=text)
if vlines:
lines = vlines
method += "+vision"
elif verr:
warnings.append(f"fallback visión sin resultados: {verr}")
return {
"lines": lines,
"count": len(lines),
"method": method,
"warnings": warnings,
"text_preview": text[:800],
}

Some files were not shown because too many files have changed in this diff Show More