Mejoras de sesion: estabilizacion, IA, Nexus Yonke y marketplace B2B
- F0 blindaje: Redis/Celery, pg_hba sin trust LAN, secretos a env 600, backups, webhook auth, jobs muertos - F1 caja: bugs POS, reembolsos al corte, apartados, CFDI, topes de descuento en cotizacion/apartado - F2/F7 IA de catalogo: autocategorizacion, dedup de SKUs con embeddings, OCR de facturas, descripciones, claves SAT, limpieza de fitment - F3 IA en el flujo: vision Yonke, preguntas MeLi, venta cruzada, anomalias, sinonimos, busqueda semantica (rerank/embeddings) - F4/F8 escala y cartera: /metrics+Prometheus+Grafana, pool, score de credito, churn, metricas multi-tenant - Nexus Yonke (M1-M6): modelo de datos 1-de-1, alta por donador, vitrina publica, MeLi usado, consignacion, garantia por dias, reportes, etiquetas - Marketplace B2B entre yonkes (catalogo + pedidos cross-tenant) - Seguridad: auth en /api/admin/* (35 endpoints), alertas por tenant - Migraciones v4.27/v4.28; timers expire-demos y rollup-metrics
This commit is contained in:
23
audit_dash.js
Normal file
23
audit_dash.js
Normal file
@@ -0,0 +1,23 @@
|
||||
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();
|
||||
})();
|
||||
10
audit_dash2.js
Normal file
10
audit_dash2.js
Normal file
@@ -0,0 +1,10 @@
|
||||
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();
|
||||
})();
|
||||
32
audit_fleet.js
Normal file
32
audit_fleet.js
Normal file
@@ -0,0 +1,32 @@
|
||||
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();
|
||||
})();
|
||||
85
audit_js.js
Normal file
85
audit_js.js
Normal file
@@ -0,0 +1,85 @@
|
||||
#!/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();
|
||||
})();
|
||||
@@ -23,6 +23,7 @@
|
||||
<button data-mode="oem" onclick="setCatalogMode('oem')">OEM</button>
|
||||
<button data-mode="local" onclick="setCatalogMode('local')">Local</button>
|
||||
</div>
|
||||
<a href="/usados" class="header-back" id="usedPartsLink" title="Partes usadas y reconstruidas">Usados</a>
|
||||
<a href="/" class="header-back">← Inicio</a>
|
||||
<button class="theme-toggle" onclick="toggleTheme()" id="themeToggle">
|
||||
<span id="themeIcon">☾</span>
|
||||
@@ -86,7 +87,7 @@
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script src="/catalog-public.js"></script>
|
||||
<script src="/catalog-public.js?v=2"></script>
|
||||
|
||||
<!-- AI Chat Widget -->
|
||||
<link rel="stylesheet" href="/chat-public.css">
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
nxPartType: null, // {slug, name} ← Nexpart part type
|
||||
|
||||
region: 'north-america',
|
||||
mode: (localStorage.getItem('catalog_mode') === 'local' ? 'local' : 'oem'),
|
||||
mode: (localStorage.getItem('catalog_mode') === 'oem' ? 'oem' : 'local'),
|
||||
page: 1,
|
||||
totalPages: 1,
|
||||
};
|
||||
@@ -64,6 +64,7 @@
|
||||
state.brand = state.model = state.year = state.engine = null;
|
||||
state.level = 'brands';
|
||||
loadBrands();
|
||||
waConfig(function () {}); // preload WhatsApp contact config
|
||||
};
|
||||
|
||||
// ── Region selector (global) ──
|
||||
@@ -109,6 +110,31 @@
|
||||
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
|
||||
// ── Smart search detector ──
|
||||
function detectQueryType(raw) {
|
||||
@@ -729,6 +755,7 @@
|
||||
}
|
||||
|
||||
html += '<button class="part-detail-btn" onclick="openDetail(' + p.id_part + ')">Ver detalle y alternativas</button>';
|
||||
html += waQuoteButtonHtml(p);
|
||||
html += '</div>';
|
||||
if (p.image_url) {
|
||||
html += '<img class="part-img" src="' + esc(p.image_url) + '" alt="" loading="lazy" onerror="this.style.display=\'none\'">';
|
||||
@@ -782,6 +809,26 @@
|
||||
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 ──
|
||||
function renderDetail(d, body) {
|
||||
if (!d || !d.part) {
|
||||
@@ -831,7 +878,7 @@
|
||||
html += '</tbody></table></div>';
|
||||
}
|
||||
|
||||
body.innerHTML = html;
|
||||
waConfig(function () { html += waQuoteButtonHtml(p); body.innerHTML = html; });
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
@@ -31,14 +31,14 @@
|
||||
}
|
||||
|
||||
var navLinks = [
|
||||
{ label: 'Demo', href: '/demo' },
|
||||
{ label: 'Tienda', href: '/tienda' },
|
||||
{ label: 'Demo', href: '/demo.html' },
|
||||
{ label: 'Tienda', href: '/tienda.html' },
|
||||
{ label: 'Cat\u00e1logo', href: '/index.html' },
|
||||
{ label: 'Captura', href: '/captura' },
|
||||
{ label: 'POS', href: '/pos' },
|
||||
{ label: 'Cuentas', href: '/cuentas' },
|
||||
{ label: 'Bodega', href: '/bodega' },
|
||||
{ label: 'Admin', href: '/admin' }
|
||||
{ label: 'Captura', href: '/captura.html' },
|
||||
{ label: 'POS', href: '/pos/login' },
|
||||
{ label: 'Cuentas', href: '/cuentas.html' },
|
||||
{ label: 'Bodega', href: '/bodega.html' },
|
||||
{ label: 'Admin', href: '/admin.html' }
|
||||
];
|
||||
|
||||
var linksHTML = navLinks.map(function (link) {
|
||||
|
||||
2086
dashboard/server.py
2086
dashboard/server.py
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,5 @@
|
||||
global:
|
||||
smtp_smarthost: 'localhost:587'
|
||||
smtp_from: 'alerts@nexus.local'
|
||||
smtp_require_tls: false
|
||||
resolve_timeout: 5m
|
||||
|
||||
route:
|
||||
group_by: ['alertname', 'severity']
|
||||
@@ -12,19 +10,14 @@ route:
|
||||
|
||||
receivers:
|
||||
- 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:
|
||||
- url: 'http://localhost:5001/pos/api/notifications/webhook'
|
||||
# El contenedor llega al host por el gateway del bridge docker
|
||||
- url: 'http://172.17.0.1:5001/pos/api/notifications/webhook'
|
||||
send_resolved: true
|
||||
http_config:
|
||||
authorization:
|
||||
type: Bearer
|
||||
credentials: 'c58db62766712e618a881dbe8de580960812e57a069ef92c9dd00e7e69158cb2'
|
||||
|
||||
inhibit_rules:
|
||||
- source_match:
|
||||
|
||||
@@ -3,6 +3,8 @@ version: "3.8"
|
||||
services:
|
||||
prometheus:
|
||||
image: prom/prometheus:v2.51.0
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
container_name: nexus-prometheus
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"uid": "nexus-app",
|
||||
"title": "Nexus — Application",
|
||||
"tags": ["gunicorn", "flask"],
|
||||
"tags": ["app", "flask", "nexus"],
|
||||
"timezone": "browser",
|
||||
"schemaVersion": 36,
|
||||
"refresh": "30s",
|
||||
@@ -26,15 +26,15 @@
|
||||
"panels": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Request Rate (nginx)",
|
||||
"title": "Request Rate (by app / status)",
|
||||
"type": "timeseries",
|
||||
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 0},
|
||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||
"expr": "rate(nginx_http_requests_total[5m])",
|
||||
"legendFormat": "Requests/sec",
|
||||
"expr": "sum by (app, status) (rate(nexus_http_requests_total[5m]))",
|
||||
"legendFormat": "{{app}} {{status}}",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
@@ -49,20 +49,20 @@
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"title": "Response Time (nginx)",
|
||||
"title": "Response Time p95 / p99",
|
||||
"type": "timeseries",
|
||||
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 0},
|
||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||
"expr": "histogram_quantile(0.95, sum(rate(nginx_http_request_duration_seconds_bucket[5m])) by (le))",
|
||||
"expr": "histogram_quantile(0.95, sum by (le) (rate(nexus_http_request_duration_seconds_bucket[5m])))",
|
||||
"legendFormat": "p95",
|
||||
"refId": "A"
|
||||
},
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||
"expr": "histogram_quantile(0.99, sum(rate(nginx_http_request_duration_seconds_bucket[5m])) by (le))",
|
||||
"expr": "histogram_quantile(0.99, sum by (le) (rate(nexus_http_request_duration_seconds_bucket[5m])))",
|
||||
"legendFormat": "p99",
|
||||
"refId": "B"
|
||||
}
|
||||
@@ -78,15 +78,15 @@
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"title": "Active Workers",
|
||||
"title": "In-flight Requests",
|
||||
"type": "timeseries",
|
||||
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 8},
|
||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||
"expr": "count by (instance) (node_processes_state{state=\"S\", cmdline=~\".*gunicorn.*\"})",
|
||||
"legendFormat": "Workers {{instance}}",
|
||||
"expr": "sum by (app) (nexus_http_requests_in_flight)",
|
||||
"legendFormat": "{{app}}",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
@@ -108,8 +108,8 @@
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||
"expr": "rate(nginx_http_requests_total{status=~\"5..\"}[5m])",
|
||||
"legendFormat": "5xx/sec",
|
||||
"expr": "sum by (app) (rate(nexus_http_requests_total{status=\"5xx\"}[5m]))",
|
||||
"legendFormat": "{{app}} 5xx/s",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
@@ -124,22 +124,22 @@
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"title": "Memory per Worker",
|
||||
"title": "Master DB Connections",
|
||||
"type": "timeseries",
|
||||
"gridPos": {"h": 8, "w": 24, "x": 0, "y": 16},
|
||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||
"expr": "process_resident_memory_bytes{cmdline=~\".*gunicorn.*\"} / 1024 / 1024",
|
||||
"legendFormat": "{{cmdline}}",
|
||||
"expr": "nexus_db_connections",
|
||||
"legendFormat": "{{app}} ({{database}})",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
|
||||
"unit": "mbytes",
|
||||
"unit": "short",
|
||||
"min": 0
|
||||
},
|
||||
"overrides": []
|
||||
|
||||
@@ -19,7 +19,7 @@ scrape_configs:
|
||||
|
||||
- job_name: 'node'
|
||||
static_configs:
|
||||
- targets: ['node-exporter:9100']
|
||||
- targets: ['host.docker.internal:9100']
|
||||
|
||||
- job_name: 'postgres'
|
||||
static_configs:
|
||||
@@ -29,12 +29,16 @@ scrape_configs:
|
||||
static_configs:
|
||||
- targets: ['redis-exporter:9121']
|
||||
|
||||
- job_name: 'nexus-pos'
|
||||
static_configs:
|
||||
- targets: ['host.docker.internal:5001']
|
||||
# Métricas de aplicación expuestas por POS (gunicorn :5001) y dashboard (:5000)
|
||||
# en el host; se alcanzan desde el contenedor por el gateway del bridge docker.
|
||||
- job_name: 'nexus_pos'
|
||||
metrics_path: /metrics
|
||||
static_configs:
|
||||
- targets: ['172.17.0.1:5001']
|
||||
|
||||
- job_name: 'nexus-quart'
|
||||
static_configs:
|
||||
- targets: ['host.docker.internal:5002']
|
||||
- job_name: 'nexus_dashboard'
|
||||
metrics_path: /metrics
|
||||
static_configs:
|
||||
- targets: ['172.17.0.1:5000']
|
||||
|
||||
|
||||
|
||||
@@ -320,6 +320,30 @@ 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)
|
||||
|
||||
### 🔴 Crítico — Deuda Técnica
|
||||
|
||||
47
docs/YONKE_COMERCIAL.md
Normal file
47
docs/YONKE_COMERCIAL.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# 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.
|
||||
51
docs/YONKE_ONBOARDING.md
Normal file
51
docs/YONKE_ONBOARDING.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# 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.
|
||||
106
docs/plans/2026-10-11-auditoria-2-mejoras-ia.md
Normal file
106
docs/plans/2026-10-11-auditoria-2-mejoras-ia.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# 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.
|
||||
125
docs/plans/2026-10-11-auditoria-completa-mejoras-ia.md
Normal file
125
docs/plans/2026-10-11-auditoria-completa-mejoras-ia.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# 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.
|
||||
199
docs/plans/2026-10-11-plan-maestro-mejoras.md
Normal file
199
docs/plans/2026-10-11-plan-maestro-mejoras.md
Normal file
@@ -0,0 +1,199 @@
|
||||
# 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.
|
||||
@@ -8,6 +8,7 @@ Panel de control central para gestionar instancias multi-tenant de Nexus POS.
|
||||
- **Monitorear** salud de todos los servicios (POS, DB, Redis, Quart, Systemd)
|
||||
- **Gestionar tenants**: activar/desactivar, resetear datos, eliminar
|
||||
- **Ejecutar migraciones** de schema en todos los tenants desde una UI
|
||||
- **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
|
||||
|
||||
## Estructura
|
||||
@@ -21,12 +22,15 @@ manager/
|
||||
├── services/ # Lógica de negocio
|
||||
│ ├── health_service.py # Health checks de infraestructura
|
||||
│ ├── 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
|
||||
├── blueprints/ # API REST
|
||||
│ ├── auth_bp.py # Login/logout JWT
|
||||
│ ├── tenants_bp.py # Gestión de tenants
|
||||
│ ├── demos_bp.py # Creación de demos
|
||||
│ ├── health_bp.py # Health checks
|
||||
│ ├── portfolio_bp.py # /api/tenants/metrics y /api/portfolio/anomalies
|
||||
│ └── admin_bp.py # Dashboard stats y migraciones
|
||||
├── static/ # Frontend SPA
|
||||
│ ├── css/manager.css
|
||||
@@ -193,6 +197,29 @@ systemctl start nexus-manager
|
||||
- Ve a **Migraciones** para ver la versión de schema de cada tenant
|
||||
- **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
|
||||
|
||||
@@ -15,6 +15,7 @@ from blueprints.tenants_bp import tenants_bp
|
||||
from blueprints.demos_bp import demos_bp
|
||||
from blueprints.health_bp import health_bp
|
||||
from blueprints.admin_bp import admin_bp
|
||||
from blueprints.portfolio_bp import portfolio_bp
|
||||
|
||||
|
||||
def create_app():
|
||||
@@ -31,6 +32,7 @@ def create_app():
|
||||
app.register_blueprint(demos_bp)
|
||||
app.register_blueprint(health_bp)
|
||||
app.register_blueprint(admin_bp)
|
||||
app.register_blueprint(portfolio_bp)
|
||||
|
||||
# ─── Frontend Routes ───────────────────────────────────────────────────
|
||||
@app.route("/")
|
||||
@@ -57,6 +59,10 @@ def create_app():
|
||||
def health_page():
|
||||
return render_template("index.html")
|
||||
|
||||
@app.route("/portfolio")
|
||||
def portfolio_page():
|
||||
return render_template("index.html")
|
||||
|
||||
@app.route("/migrations")
|
||||
def migrations_page():
|
||||
return render_template("index.html")
|
||||
|
||||
48
manager/blueprints/portfolio_bp.py
Normal file
48
manager/blueprints/portfolio_bp.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""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))
|
||||
352
manager/services/business_metrics.py
Normal file
352
manager/services/business_metrics.py
Normal file
@@ -0,0 +1,352 @@
|
||||
"""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)",
|
||||
}
|
||||
522
manager/services/portfolio_service.py
Normal file
522
manager/services/portfolio_service.py
Normal file
@@ -0,0 +1,522 @@
|
||||
"""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
|
||||
@@ -193,40 +193,59 @@ def reset_tenant(tenant_id, keep_config=True):
|
||||
db_name = tenant["db_name"]
|
||||
dsn = TENANT_DB_URL_TEMPLATE.format(db_name=db_name)
|
||||
|
||||
tables_to_truncate = [
|
||||
"inventory_operations",
|
||||
"inventory",
|
||||
"sale_items",
|
||||
"sales",
|
||||
"customer_payments",
|
||||
"cash_register_closings",
|
||||
"cash_register_movements",
|
||||
"cash_registers",
|
||||
"invoices",
|
||||
"accounting_entries",
|
||||
"journal_entries",
|
||||
"service_orders",
|
||||
"fleet_vehicles",
|
||||
"crm_activities",
|
||||
"quotations",
|
||||
"quotation_items",
|
||||
"savings_transactions",
|
||||
"savings_accounts",
|
||||
"supplier_orders",
|
||||
"supplier_order_items",
|
||||
"warranty_claims",
|
||||
"notifications",
|
||||
"inventory_uploads",
|
||||
# Whitelist de tablas de NEGOCIO/PII a limpiar. Antes faltaban customers,
|
||||
# whatsapp/wa_*, audit_log, layaways, returns, marketplace_*, consignaciones
|
||||
# → el "reset" dejaba datos personales e historial de conversaciones.
|
||||
candidate_tables = [
|
||||
# Inventario
|
||||
"inventory_operations", "inventory_stock", "inventory_stock_summary",
|
||||
"inventory_images", "inventory_sku_aliases", "inventory_vehicle_compat",
|
||||
"inventory_transfer_lines", "inventory_transfers", "physical_count_lines",
|
||||
"physical_counts", "reorder_alerts", "inventory", "inventory_uploads",
|
||||
# Ventas / caja
|
||||
"sale_payments", "sale_items", "sales", "customer_payments",
|
||||
"cash_movements", "cash_register_movements", "cash_registers",
|
||||
"layaway_payments", "layaway_items", "layaways",
|
||||
# Devoluciones / garantías
|
||||
"return_items", "returns", "warranty_claims", "warranties",
|
||||
# Cotizaciones / compras
|
||||
"quotation_items", "quotations",
|
||||
"purchase_order_items", "purchase_orders",
|
||||
# Taller / flotilla
|
||||
"service_order_items", "service_order_labor", "service_orders",
|
||||
"fleet_maintenance_logs", "fleet_vehicles",
|
||||
# CRM / clientes (PII)
|
||||
"customer_activities", "crm_activities", "loyalty_transactions",
|
||||
"loyalty_points", "customers",
|
||||
# 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)
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
for table in tables_to_truncate:
|
||||
# Solo se truncan las que existan en este tenant (esquema puede variar).
|
||||
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:
|
||||
cur.execute(f"TRUNCATE TABLE {table} RESTART IDENTITY CASCADE")
|
||||
cur.execute(f'TRUNCATE TABLE public."{table}" RESTART IDENTITY CASCADE')
|
||||
truncated += 1
|
||||
except Exception:
|
||||
pass # Table may not exist
|
||||
conn.rollback()
|
||||
conn.commit()
|
||||
success = True
|
||||
except Exception as e:
|
||||
@@ -237,7 +256,7 @@ def reset_tenant(tenant_id, keep_config=True):
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
return {"success": success, "tenant_id": tenant_id, "tables_reset": len(tables_to_truncate)}
|
||||
return {"success": success, "tenant_id": tenant_id, "tables_reset": truncated}
|
||||
|
||||
|
||||
def delete_tenant(tenant_id):
|
||||
|
||||
@@ -10,6 +10,7 @@ const routes = {
|
||||
"#dashboard": "dashboard",
|
||||
"#demos": "demos",
|
||||
"#tenants": "tenants",
|
||||
"#portfolio": "portfolio",
|
||||
"#health": "health",
|
||||
"#migrations": "migrations"
|
||||
};
|
||||
@@ -29,6 +30,7 @@ function navigate() {
|
||||
dashboard: "Dashboard",
|
||||
demos: "Crear Demos",
|
||||
tenants: "Tenants",
|
||||
portfolio: "Cartera y Salud",
|
||||
health: "Salud del Sistema",
|
||||
migrations: "Migraciones"
|
||||
};
|
||||
@@ -38,6 +40,7 @@ function navigate() {
|
||||
if (page === "dashboard") loadDashboard();
|
||||
if (page === "demos") loadDemos();
|
||||
if (page === "tenants") loadTenants();
|
||||
if (page === "portfolio") loadPortfolio();
|
||||
if (page === "health") loadHealth();
|
||||
if (page === "migrations") loadMigrations();
|
||||
}
|
||||
@@ -271,6 +274,77 @@ 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 ────────────────────────────────────────────────────────────────
|
||||
async function loadHealth() {
|
||||
const res = await api("/api/health");
|
||||
|
||||
@@ -56,6 +56,10 @@
|
||||
<span>Tenants</span>
|
||||
<span class="badge" id="tenant-count">0</span>
|
||||
</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">
|
||||
<i class="fas fa-heartbeat"></i>
|
||||
<span>Salud</span>
|
||||
@@ -240,6 +244,63 @@
|
||||
</div>
|
||||
</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 -->
|
||||
<section id="page-health" class="page" style="display:none;">
|
||||
<div class="grid-3">
|
||||
|
||||
393
pos/app.py
393
pos/app.py
@@ -1,5 +1,201 @@
|
||||
from flask import Flask, request, redirect, make_response
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
|
||||
from flask import Flask, request, redirect, make_response, g
|
||||
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():
|
||||
@@ -10,11 +206,128 @@ def create_app():
|
||||
from middleware_tenant import 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 ──────
|
||||
@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')
|
||||
def pos_sw():
|
||||
from flask import send_from_directory, make_response
|
||||
@@ -25,10 +338,68 @@ def create_app():
|
||||
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
|
||||
from blueprints.auth_bp import 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
|
||||
app.register_blueprint(config_bp)
|
||||
|
||||
@@ -128,11 +499,25 @@ def create_app():
|
||||
from blueprints.internal_bp import 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
|
||||
@app.route('/pos/health')
|
||||
def health():
|
||||
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
|
||||
|
||||
@app.route('/favicon.ico')
|
||||
@@ -242,6 +627,10 @@ def create_app():
|
||||
response.headers['Expires'] = '0'
|
||||
return response
|
||||
|
||||
@app.route('/pos/yonke')
|
||||
def pos_yonke():
|
||||
return render_template('yonke.html')
|
||||
|
||||
@app.route('/pos/static/<path:filename>')
|
||||
def pos_static(filename):
|
||||
return send_from_directory('static', filename)
|
||||
@@ -297,7 +686,7 @@ def create_app():
|
||||
|
||||
cur.execute("""
|
||||
SELECT i.part_number, i.name, i.brand, i.price_1, i.tax_rate,
|
||||
i.category, COALESCE(s.stock, 0) AS stock,
|
||||
i.category_id, COALESCE(s.stock, 0) AS stock,
|
||||
COALESCE(sv.total_sold, 0) AS total_sold
|
||||
FROM inventory i
|
||||
LEFT JOIN (
|
||||
|
||||
@@ -13,12 +13,56 @@ from datetime import date, datetime, timedelta
|
||||
from flask import Blueprint, request, jsonify, g, Response
|
||||
from middleware import require_auth
|
||||
from tenant_db import get_tenant_conn
|
||||
from services.accounting_engine import create_manual_entry
|
||||
from services.accounting_engine import create_manual_entry, record_supplier_payment_entry
|
||||
from services.audit import log_action
|
||||
|
||||
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 ─────────────────────────────
|
||||
|
||||
@accounting_bp.route('/accounts', methods=['GET'])
|
||||
@@ -309,29 +353,11 @@ def create_entry():
|
||||
|
||||
# ─── Financial Reports ──────────────────────────────
|
||||
|
||||
@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)
|
||||
def _trial_balance_rows(conn, year, month):
|
||||
"""Balanza de comprobacion rows for a fiscal month."""
|
||||
cur = conn.cursor()
|
||||
|
||||
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'
|
||||
period_start, period_end = _period_bounds(year, month)
|
||||
|
||||
cur.execute("""
|
||||
WITH initial_balances AS (
|
||||
@@ -384,31 +410,62 @@ def trial_balance():
|
||||
})
|
||||
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({'data': rows, 'period': {'year': year, 'month': month}})
|
||||
return rows
|
||||
|
||||
|
||||
@accounting_bp.route('/income-statement', methods=['GET'])
|
||||
@accounting_bp.route('/trial-balance', methods=['GET'])
|
||||
@require_auth('accounting.view')
|
||||
def income_statement():
|
||||
"""Estado de resultados for a period.
|
||||
def trial_balance():
|
||||
"""Balanza de comprobacion for a period.
|
||||
|
||||
Query params: year, month
|
||||
Query params:
|
||||
year: int (default current year)
|
||||
month: int (default current month)
|
||||
|
||||
Groups: Ingresos (400), Costos (500), Gastos (600)
|
||||
Result: Ingresos - Costos - Gastos = Utilidad/Perdida
|
||||
Returns each account with: saldo_inicial, cargos, abonos, saldo_final
|
||||
"""
|
||||
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))
|
||||
|
||||
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'
|
||||
rows = _trial_balance_rows(conn, year, month)
|
||||
|
||||
conn.close()
|
||||
return jsonify({'data': rows, 'period': {'year': year, 'month': month}})
|
||||
|
||||
|
||||
@accounting_bp.route('/trial-balance/export', methods=['GET'])
|
||||
@require_auth('accounting.view')
|
||||
def export_trial_balance():
|
||||
"""Export the balanza de comprobacion 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()
|
||||
|
||||
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()
|
||||
|
||||
period_start, period_end = _period_bounds(year, month)
|
||||
|
||||
cur.execute("""
|
||||
SELECT a.id, a.code, a.name, a.type,
|
||||
@@ -456,16 +513,215 @@ def income_statement():
|
||||
utilidad_neta = round(utilidad_bruta - total_gastos, 2)
|
||||
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
return jsonify({
|
||||
return {
|
||||
'period': {'year': year, 'month': month},
|
||||
'ingresos': {'items': ingresos, 'total': round(total_ingresos, 2)},
|
||||
'costos': {'items': costos, 'total': round(total_costos, 2)},
|
||||
'utilidad_bruta': utilidad_bruta,
|
||||
'gastos': {'items': gastos, 'total': round(total_gastos, 2)},
|
||||
'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'])
|
||||
@@ -578,6 +834,7 @@ def aging_report():
|
||||
|
||||
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,
|
||||
@@ -596,7 +853,7 @@ def aging_report():
|
||||
status = r[5]
|
||||
vendor_name = r[7]
|
||||
|
||||
paid = 0 # TODO: sum supplier payments when that table is added
|
||||
paid = paid_by_po.get(po_id, 0.0)
|
||||
balance = round(total - paid, 2)
|
||||
if balance <= 0:
|
||||
continue
|
||||
@@ -772,6 +1029,7 @@ def export_aging():
|
||||
|
||||
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',
|
||||
@@ -792,10 +1050,10 @@ def export_aging():
|
||||
expected = r[4]
|
||||
status = r[5]
|
||||
vendor = r[6]
|
||||
paid = 0
|
||||
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 'Pendiente'
|
||||
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 '',
|
||||
@@ -916,6 +1174,100 @@ def export_aging():
|
||||
)
|
||||
|
||||
|
||||
# ─── 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 ────────────────────────────────
|
||||
|
||||
@accounting_bp.route('/periods', methods=['GET'])
|
||||
|
||||
@@ -254,7 +254,7 @@ def list_login_employees(tenant_id=None):
|
||||
name = row[1]
|
||||
parts = name.split()
|
||||
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'}
|
||||
role_labels = {'owner': 'Dueño', 'admin': 'Administrador', 'cashier': 'Cajero', 'counter': 'Mostrador', 'warehouse': 'Almacén', 'accountant': 'Contador', 'workshop': 'Taller', 'mechanic': 'Mecánico', 'driver': 'Repartidor'}
|
||||
employees.append({
|
||||
'id': row[0],
|
||||
'name': name,
|
||||
|
||||
258
pos/blueprints/b2b_bp.py
Normal file
258
pos/blueprints/b2b_bp.py
Normal file
@@ -0,0 +1,258 @@
|
||||
# /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()
|
||||
@@ -1,12 +1,15 @@
|
||||
# /home/Autopartes/pos/blueprints/cashregister_bp.py
|
||||
"""Cash register blueprint: open/close register, cash movements, X/Z cuts."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from middleware import require_auth, has_permission
|
||||
from tenant_db import get_tenant_conn
|
||||
from services.audit import log_action
|
||||
|
||||
logger = logging.getLogger('pos')
|
||||
|
||||
cashregister_bp = Blueprint('cashregister', __name__, url_prefix='/pos/api/register')
|
||||
|
||||
|
||||
@@ -18,6 +21,29 @@ 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'])
|
||||
@require_auth()
|
||||
def open_register():
|
||||
@@ -220,8 +246,35 @@ def _compute_register_summary(conn, register_id):
|
||||
total_sales += amount
|
||||
total_sales_count += count
|
||||
|
||||
# Cash sales specifically (for expected cash calculation)
|
||||
cash_from_sales = sales_by_method.get('efectivo', {}).get('amount', 0)
|
||||
# Cash received for the expected-cash calculation:
|
||||
# - credit sales collect nothing at sale time (their abonos arrive as
|
||||
# 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)
|
||||
cur.execute("""
|
||||
@@ -230,15 +283,26 @@ def _compute_register_summary(conn, register_id):
|
||||
""", (register_id,))
|
||||
change_given = float(cur.fetchone()[0])
|
||||
|
||||
# Cash movements
|
||||
# Cash movements. Refunds from devoluciones (reason 'Devolucion #...') are
|
||||
# kept out of the generic 'out' bucket and subtracted separately below, so
|
||||
# a refund lowers the expected cash exactly once.
|
||||
cur.execute("""
|
||||
SELECT type, COALESCE(SUM(amount), 0) FROM cash_movements
|
||||
WHERE register_id = %s GROUP BY type
|
||||
WHERE register_id = %s
|
||||
AND NOT (type = 'out' AND reason LIKE 'Devolucion #%%')
|
||||
GROUP BY type
|
||||
""", (register_id,))
|
||||
movements = {'in': 0.0, 'out': 0.0}
|
||||
for r in cur.fetchall():
|
||||
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
|
||||
cur.execute("""
|
||||
SELECT COUNT(*), COALESCE(SUM(total), 0) FROM sales
|
||||
@@ -248,9 +312,10 @@ def _compute_register_summary(conn, register_id):
|
||||
cancelled_count = cancelled[0]
|
||||
cancelled_amount = float(cancelled[1])
|
||||
|
||||
# Expected cash = opening + cash sales - change + cash_in - cash_out
|
||||
# Expected cash = opening + cash sales - change + cash_in - cash_out - refunds
|
||||
expected_cash = round(
|
||||
opening_amount + cash_from_sales - change_given + movements['in'] - movements['out'],
|
||||
opening_amount + cash_from_sales - change_given
|
||||
+ movements['in'] - movements['out'] - refunds_out,
|
||||
2
|
||||
)
|
||||
|
||||
@@ -277,6 +342,7 @@ def _compute_register_summary(conn, register_id):
|
||||
'change_given': round(change_given, 2),
|
||||
'cash_movements_in': round(movements['in'], 2),
|
||||
'cash_movements_out': round(movements['out'], 2),
|
||||
'refunds_out': round(refunds_out, 2),
|
||||
'movement_detail': movement_detail,
|
||||
'cancelled_count': cancelled_count,
|
||||
'cancelled_amount': round(cancelled_amount, 2),
|
||||
@@ -330,7 +396,7 @@ def cut_z():
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute("""
|
||||
SELECT id FROM cash_registers
|
||||
SELECT id, register_number FROM cash_registers
|
||||
WHERE employee_id = %s AND status = 'open'
|
||||
""", (g.employee_id,))
|
||||
reg = cur.fetchone()
|
||||
@@ -338,7 +404,7 @@ def cut_z():
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'No open register'}), 400
|
||||
|
||||
register_id = reg[0]
|
||||
register_id, register_number = reg
|
||||
summary = _compute_register_summary(conn, register_id)
|
||||
|
||||
expected = summary['expected_cash']
|
||||
@@ -362,6 +428,22 @@ def cut_z():
|
||||
|
||||
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
|
||||
if abs(difference) > 500:
|
||||
try:
|
||||
@@ -410,8 +492,8 @@ def register_history():
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
page = int(request.args.get('page', 1))
|
||||
per_page = min(int(request.args.get('per_page', 50)), 200)
|
||||
page = _int_arg('page', 1, min_value=1)
|
||||
per_page = _int_arg('per_page', 50, min_value=1, max_value=200)
|
||||
|
||||
where_clauses = ["cr.status = 'closed'"]
|
||||
params = []
|
||||
@@ -422,7 +504,7 @@ def register_history():
|
||||
|
||||
date_from = request.args.get('date_from')
|
||||
date_to = request.args.get('date_to')
|
||||
employee_id = request.args.get('employee_id')
|
||||
employee_id = _int_arg('employee_id')
|
||||
|
||||
if not can_view_all:
|
||||
employee_id = g.employee_id
|
||||
@@ -435,7 +517,7 @@ def register_history():
|
||||
params.append(date_to)
|
||||
if employee_id:
|
||||
where_clauses.append("cr.employee_id = %s")
|
||||
params.append(int(employee_id))
|
||||
params.append(employee_id)
|
||||
|
||||
where = " AND ".join(where_clauses)
|
||||
|
||||
|
||||
@@ -19,11 +19,32 @@ from tenant_db import get_master_conn, get_tenant_conn
|
||||
from services import catalog_service
|
||||
from services.vin_decoder import decode_vin
|
||||
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
|
||||
|
||||
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():
|
||||
"""Return a 403 response if OEM catalog is disabled."""
|
||||
if not CATALOG_OEM_ENABLED:
|
||||
@@ -66,6 +87,9 @@ def _with_conns(fn):
|
||||
branch_id = request.args.get('branch_id', g.branch_id)
|
||||
return fn(master, tenant, branch_id)
|
||||
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
|
||||
finally:
|
||||
if master:
|
||||
@@ -76,6 +100,15 @@ def _with_conns(fn):
|
||||
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):
|
||||
"""Helper: open only master connection for hierarchy endpoints."""
|
||||
master = None
|
||||
@@ -83,6 +116,9 @@ def _master_only(fn):
|
||||
master = get_master_conn()
|
||||
return fn(master)
|
||||
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
|
||||
finally:
|
||||
if master:
|
||||
@@ -487,6 +523,33 @@ def decode_vin_route(vin):
|
||||
|
||||
# ─── 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'])
|
||||
@require_auth('catalog.view')
|
||||
def plate_lookup(plate):
|
||||
@@ -541,6 +604,9 @@ def plate_lookup(plate):
|
||||
return jsonify(response)
|
||||
|
||||
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
|
||||
finally:
|
||||
if tenant:
|
||||
@@ -576,6 +642,9 @@ def plate_register():
|
||||
)
|
||||
return jsonify({'id': rec_id, 'message': 'Placa registrada.'})
|
||||
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
|
||||
finally:
|
||||
if tenant:
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"""Config blueprint: tenant configuration, branches, theming."""
|
||||
|
||||
import json
|
||||
import re
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from middleware import require_auth, has_permission
|
||||
from tenant_db import get_tenant_conn
|
||||
@@ -14,6 +15,7 @@ _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',
|
||||
@@ -40,6 +42,7 @@ _DEFAULT_ROLE_PERMISSIONS = {
|
||||
'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'],
|
||||
}
|
||||
@@ -108,6 +111,10 @@ _AVAILABLE_PERMISSIONS = [
|
||||
{'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'},
|
||||
@@ -433,7 +440,7 @@ def create_employee():
|
||||
nxt_name = PLANS[nxt]['name'] if nxt else 'Enterprise'
|
||||
return jsonify({'error': f'Plan limit reached ({limit} employees). Upgrade to {nxt_name}.'}), 403
|
||||
|
||||
valid_roles = ['admin', 'cashier', 'counter', 'warehouse', 'accountant', 'workshop', 'mechanic']
|
||||
valid_roles = ['admin', 'cashier', 'counter', 'warehouse', 'accountant', 'workshop', 'mechanic', 'driver']
|
||||
if data['role'] not in valid_roles:
|
||||
return jsonify({'error': f'role must be one of: {", ".join(valid_roles)}'}), 400
|
||||
|
||||
@@ -455,6 +462,14 @@ def create_employee():
|
||||
(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
|
||||
log_action(conn, 'EMPLOYEE_CREATE', 'employee', emp_id,
|
||||
new_value={'name': data['name'], 'role': data['role']})
|
||||
@@ -617,6 +632,15 @@ def update_employee(emp_id):
|
||||
"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
|
||||
log_action(conn, 'EMPLOYEE_UPDATE', 'employee', emp_id,
|
||||
@@ -876,16 +900,15 @@ def get_billing():
|
||||
@config_bp.route('/billing/upgrade', methods=['POST'])
|
||||
@require_auth('config.edit')
|
||||
def upgrade_billing():
|
||||
"""Upgrade tenant plan."""
|
||||
from services.billing import upgrade_plan
|
||||
data = request.get_json() or {}
|
||||
new_plan = data.get('plan')
|
||||
if not new_plan:
|
||||
return jsonify({'error': 'plan required'}), 400
|
||||
result = upgrade_plan(g.tenant_id, new_plan)
|
||||
if 'error' in result:
|
||||
return jsonify(result), 400
|
||||
return jsonify(result)
|
||||
"""Plan changes are handled by Nexus support, never self-service.
|
||||
|
||||
This endpoint previously let any tenant owner upgrade themselves to
|
||||
`enterprise` (unlimited) for free — a direct revenue leak. It now
|
||||
refuses every change; plans are managed from the master DB / admin panel.
|
||||
"""
|
||||
return jsonify({
|
||||
'error': 'Los cambios de plan los gestiona Nexus. Contacta a soporte para actualizar tu plan.'
|
||||
}), 403
|
||||
|
||||
|
||||
# ─── Vehicle Compatibility Source ────────────────────
|
||||
@@ -1000,6 +1023,49 @@ def update_allowed_brands():
|
||||
|
||||
# ─── 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'])
|
||||
@require_auth('config.view')
|
||||
def get_whatsapp_config():
|
||||
@@ -1016,6 +1082,14 @@ def get_whatsapp_config():
|
||||
'bridge_key': rows.get('whatsapp_bridge_key', ''),
|
||||
'enabled': rows.get('whatsapp_enabled', 'false').lower() == 'true',
|
||||
'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', ''),
|
||||
})
|
||||
|
||||
|
||||
@@ -1032,7 +1106,18 @@ def update_whatsapp_config():
|
||||
'whatsapp_bridge_key': data.get('bridge_key', ''),
|
||||
'whatsapp_enabled': 'true' if data.get('enabled') else 'false',
|
||||
'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():
|
||||
cur.execute("""
|
||||
@@ -1047,26 +1132,151 @@ def update_whatsapp_config():
|
||||
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'])
|
||||
@require_auth()
|
||||
def get_modules():
|
||||
"""Get enabled modules for this tenant."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT key, value FROM tenant_config WHERE key LIKE 'module_%'")
|
||||
cur.execute("SELECT key, value FROM tenant_config WHERE key LIKE 'module_%' OR key LIKE '%enabled'")
|
||||
rows = {row[0]: row[1] for row in cur.fetchall()}
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
def _bool(key):
|
||||
return rows.get(key, 'true').lower() == 'true'
|
||||
def _bool(name):
|
||||
# Unificado 2026-10-11: el POS escribe 'module_X' y el dashboard 'X_enabled';
|
||||
# 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({
|
||||
'whatsapp': _bool('module_whatsapp'),
|
||||
'marketplace': _bool('module_marketplace'),
|
||||
'meli': _bool('module_meli'),
|
||||
'catalog': _bool('module_catalog'),
|
||||
'workshop': _bool('module_workshop'),
|
||||
'whatsapp': _bool('whatsapp'),
|
||||
'marketplace': _bool('marketplace'),
|
||||
'meli': _bool('meli'),
|
||||
'catalog': _bool('catalog'),
|
||||
'workshop': _bool('workshop'),
|
||||
'yonke': _bool('yonke'),
|
||||
})
|
||||
|
||||
|
||||
@@ -1078,13 +1288,14 @@ def update_modules():
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
settings = {
|
||||
'module_whatsapp': 'true' if data.get('whatsapp') else 'false',
|
||||
'module_marketplace': 'true' if data.get('marketplace') else 'false',
|
||||
'module_meli': 'true' if data.get('meli') else 'false',
|
||||
'module_catalog': 'true' if data.get('catalog') else 'false',
|
||||
'module_workshop': 'true' if data.get('workshop') else 'false',
|
||||
}
|
||||
# Doble escritura: 'module_X' (POS) + 'X_enabled' (dashboard/consumidores).
|
||||
# Solo se tocan las claves presentes en el payload (no se pisan las demás).
|
||||
settings = {}
|
||||
for name in ('whatsapp', 'marketplace', 'meli', 'catalog', 'workshop', 'yonke'):
|
||||
if name in data:
|
||||
val = 'true' if data.get(name) else 'false'
|
||||
settings['module_' + name] = val
|
||||
settings[name + '_enabled'] = val
|
||||
|
||||
for key, value in settings.items():
|
||||
cur.execute("""
|
||||
@@ -1222,6 +1433,7 @@ RECEIPT_CONFIG_KEYS = [
|
||||
'receipt_show_payment_details',
|
||||
'receipt_show_employee',
|
||||
'receipt_paper_width',
|
||||
'receipt_auto_print',
|
||||
]
|
||||
|
||||
|
||||
@@ -1258,6 +1470,7 @@ def get_receipt_config():
|
||||
'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',
|
||||
})
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
"""Customers blueprint: CRUD, credit management, vehicles, account statements."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from middleware import require_auth, has_permission
|
||||
from tenant_db import get_tenant_conn
|
||||
from services.audit import log_action
|
||||
|
||||
logger = logging.getLogger('pos')
|
||||
|
||||
customers_bp = Blueprint('customers', __name__, url_prefix='/pos/api/customers')
|
||||
|
||||
|
||||
@@ -155,6 +158,14 @@ def get_customer(customer_id):
|
||||
|
||||
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)
|
||||
cur.execute("""
|
||||
SELECT s.id, s.total, s.payment_method, s.sale_type, s.status, s.created_at,
|
||||
@@ -199,6 +210,28 @@ def get_customer(customer_id):
|
||||
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):
|
||||
@@ -624,6 +657,24 @@ def record_customer_payment(customer_id):
|
||||
new_value={'credit_balance': new_balance, 'payment': amount})
|
||||
|
||||
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()
|
||||
|
||||
return jsonify({
|
||||
|
||||
@@ -7,7 +7,7 @@ import subprocess
|
||||
import socket
|
||||
from flask import Blueprint, request, jsonify
|
||||
from config import INTERNAL_API_KEY
|
||||
from tenant_db import get_master_conn, get_tenant_conn
|
||||
from tenant_db import get_master_conn, get_tenant_conn, get_tenant_conn_by_dbname
|
||||
|
||||
internal_bp = Blueprint('internal', __name__, url_prefix='/pos/api/internal')
|
||||
|
||||
|
||||
@@ -6,9 +6,13 @@ import json
|
||||
import os
|
||||
import csv
|
||||
from datetime import datetime, timedelta
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from flask import Blueprint, request, jsonify, g, Response
|
||||
from middleware import require_auth, has_permission
|
||||
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 (
|
||||
get_stock, get_stock_bulk, record_purchase, record_return,
|
||||
record_adjustment, record_transfer, record_initial,
|
||||
@@ -65,14 +69,275 @@ def _to_int(val, default=0):
|
||||
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 ───────────────────────────
|
||||
|
||||
@inventory_bp.route('/classify/<part_number>', methods=['GET'])
|
||||
@inventory_bp.route('/classify/<part_number>', methods=['GET', 'POST'])
|
||||
@require_auth('inventory.create')
|
||||
def classify_part_endpoint(part_number):
|
||||
"""Ask AI to identify a part by its OEM number."""
|
||||
"""Ask AI to identify a part by its OEM number and persist its category.
|
||||
|
||||
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.product_categorizer import CategoryResolver, map_llm_category
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@@ -414,6 +679,119 @@ def create_item():
|
||||
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'])
|
||||
@require_auth('inventory.edit')
|
||||
def bulk_import_items():
|
||||
@@ -422,8 +800,12 @@ def bulk_import_items():
|
||||
Expects multipart/form-data with a 'file' (CSV/Excel) or JSON body.
|
||||
Headers:
|
||||
X-Import-Mode: 'strict' (default) aborts on first error; 'lenient' skips bad rows.
|
||||
X-Import-Strategy: 'qwen' (default) auto-generates missing compat via QWEN;
|
||||
'skip' ignores missing compat; 'reject' requires all compat.
|
||||
X-Import-Strategy: 'skip' (default) ignores missing compat and never
|
||||
calls QWEN; 'qwen' auto-generates missing compat via
|
||||
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):
|
||||
sku/part_number, name, brand, price, stock, cost,
|
||||
sku_secondary, description, category, make, model, year, engine, engine_code
|
||||
@@ -434,7 +816,8 @@ def bulk_import_items():
|
||||
import services.inventory_vehicle_compat as ivc_service
|
||||
|
||||
mode = request.headers.get('X-Import-Mode', 'strict').lower()
|
||||
strategy = request.headers.get('X-Import-Strategy', 'qwen').lower()
|
||||
strategy = request.headers.get('X-Import-Strategy', 'skip').lower()
|
||||
stock_mode = request.headers.get('X-Import-Stock', 'add').lower()
|
||||
errors = []
|
||||
warnings = []
|
||||
created_ids = []
|
||||
@@ -594,8 +977,8 @@ def bulk_import_items():
|
||||
(name, brand, cost, cost, price_1, price_1, location, description, category_id, item_id)
|
||||
)
|
||||
was_inserted = False
|
||||
# Record stock adjustment for existing item if stock > 0
|
||||
if stock > 0:
|
||||
# Record stock adjustment for existing item if stock > 0 (unless skipped)
|
||||
if stock > 0 and stock_mode != 'skip':
|
||||
record_initial(conn, item_id, branch_id, stock, cost if cost > 0 else None)
|
||||
else:
|
||||
# Generate barcode for new item
|
||||
@@ -648,24 +1031,9 @@ def bulk_import_items():
|
||||
year = _to_int(year_str, None)
|
||||
mye_id = None
|
||||
if make and model and year:
|
||||
# Try exact match against model_year_engine
|
||||
cur.execute(
|
||||
"""
|
||||
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:
|
||||
# Resolver contra la BD maestra (no la del tenant).
|
||||
mye_id = _resolve_mye_master(make, model, year)
|
||||
if mye_id is None:
|
||||
warnings.append(
|
||||
f'Row {row_num}: vehicle "{make} {model} {year}" not found in catalog; '
|
||||
'saving as text-only compatibility.'
|
||||
@@ -1078,6 +1446,251 @@ def api_adjustment():
|
||||
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'])
|
||||
@require_auth('inventory.transfer')
|
||||
def api_transfer():
|
||||
|
||||
@@ -20,6 +20,7 @@ from services.cfdi_facturapi_builder import (
|
||||
from services.cfdi_queue import (
|
||||
cancel_cfdi,
|
||||
enqueue_cfdi,
|
||||
enqueue_pending_complements,
|
||||
get_queue_status,
|
||||
process_queue,
|
||||
retry_failed,
|
||||
@@ -62,7 +63,7 @@ def _get_issuer_config(cur, branch_id=None):
|
||||
if branch_id:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT rfc, razon_social, regimen_fiscal, codigo_postal, serie_cfdi
|
||||
SELECT rfc, razon_social, regimen_fiscal, cp, serie_cfdi
|
||||
FROM branches WHERE id = %s
|
||||
""",
|
||||
(branch_id,),
|
||||
@@ -84,7 +85,8 @@ def _get_sale_with_items(cur, sale_id):
|
||||
"""
|
||||
SELECT id, branch_id, customer_id, employee_id, sale_type,
|
||||
payment_method, subtotal, discount_total, tax_total, total,
|
||||
metodo_pago_sat, forma_pago_sat, status, created_at
|
||||
metodo_pago_sat, forma_pago_sat, status, created_at,
|
||||
currency, exchange_rate
|
||||
FROM sales WHERE id = %s
|
||||
""",
|
||||
(sale_id,),
|
||||
@@ -108,6 +110,8 @@ def _get_sale_with_items(cur, sale_id):
|
||||
"forma_pago_sat": row[11] or "01",
|
||||
"status": row[12],
|
||||
"created_at": str(row[13]),
|
||||
"currency": row[14] or "MXN",
|
||||
"exchange_rate": float(row[15]) if row[15] else None,
|
||||
}
|
||||
|
||||
cur.execute(
|
||||
@@ -366,6 +370,44 @@ def trigger_process_queue():
|
||||
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 ────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -10,11 +10,19 @@ Routes:
|
||||
Listings
|
||||
GET /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>/pause
|
||||
POST /pos/api/marketplace-ext/listings/<id>/activate
|
||||
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
|
||||
GET /pos/api/marketplace-ext/orders
|
||||
GET /pos/api/marketplace-ext/orders/<id>
|
||||
@@ -62,10 +70,84 @@ def _require_meli_manage():
|
||||
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
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
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"])
|
||||
@require_auth()
|
||||
def get_config():
|
||||
@@ -78,6 +160,7 @@ def get_config():
|
||||
if k not in ("meli_access_token", "meli_refresh_token", "meli_client_secret")
|
||||
}
|
||||
safe["connected"] = bool(cfg.get("meli_access_token"))
|
||||
safe["has_refresh_token"] = bool(cfg.get("meli_refresh_token"))
|
||||
return jsonify(safe)
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -178,12 +261,17 @@ def connect_meli():
|
||||
"meli_client_id": client_id,
|
||||
"meli_client_secret": client_secret,
|
||||
})
|
||||
return jsonify({
|
||||
resp = {
|
||||
"ok": True,
|
||||
"user_id": user_id or user.get("id"),
|
||||
"nickname": user.get("nickname"),
|
||||
"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:
|
||||
conn.close()
|
||||
|
||||
@@ -214,13 +302,13 @@ def search_categories():
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
cfg = meli_svc.get_meli_config(conn)
|
||||
svc = meli_svc._get_meli_service(cfg)
|
||||
svc = meli_svc._get_meli_service(cfg, conn)
|
||||
if not svc:
|
||||
return jsonify({"error": "MercadoLibre not connected"}), 400
|
||||
return jsonify({"error": "MercadoLibre no está conectado. Conecta la cuenta en la pestaña de configuración."}), 400
|
||||
result = svc.search_categories(site_id, q)
|
||||
return jsonify({"categories": result})
|
||||
except MeliAuthError:
|
||||
return jsonify({"error": "MercadoLibre token expired. Please reconnect."}), 401
|
||||
except MeliAuthError as e:
|
||||
return _meli_auth_error(e)
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
finally:
|
||||
@@ -234,16 +322,16 @@ def search_categories():
|
||||
@marketplace_ext_bp.route("/listings", methods=["GET"])
|
||||
@require_auth()
|
||||
def list_listings():
|
||||
page = int(request.args.get("page", 1))
|
||||
per_page = min(int(request.args.get("per_page", 50)), 200)
|
||||
page = max(1, _safe_int(request.args.get("page"), 1))
|
||||
per_page = min(max(1, _safe_int(request.args.get("per_page"), 50)), 200)
|
||||
status = request.args.get("status")
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = meli_svc.get_listings(conn, page=page, per_page=per_page, status=status)
|
||||
return jsonify(result)
|
||||
except MeliAuthError:
|
||||
return jsonify({"error": "MercadoLibre token expired. Please reconnect."}), 401
|
||||
except MeliAuthError as e:
|
||||
return _meli_auth_error(e)
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
finally:
|
||||
@@ -279,6 +367,7 @@ def create_listings():
|
||||
shipping_mode=shipping_mode,
|
||||
custom_data=custom_data,
|
||||
base_url=_get_public_base_url(),
|
||||
use_llm_description=bool(data.get("use_llm_description", False)),
|
||||
)
|
||||
return jsonify(result), 201
|
||||
except ValueError as e:
|
||||
@@ -309,6 +398,36 @@ def import_existing_listings():
|
||||
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"])
|
||||
@require_auth()
|
||||
def inventory_check():
|
||||
@@ -338,7 +457,8 @@ def sync_stock_to_meli():
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = meli_svc.process_meli_sync_queue(conn)
|
||||
# Manual run from the UI: bypass the meli_auto_sync_stock_price gate.
|
||||
result = meli_svc.process_meli_sync_queue(conn, force=True)
|
||||
return jsonify(result)
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
@@ -355,15 +475,41 @@ def category_attributes(category_id):
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
cfg = meli_svc.get_meli_config(conn)
|
||||
svc = meli_svc._get_meli_service(cfg)
|
||||
svc = meli_svc._get_meli_service(cfg, conn)
|
||||
if not svc:
|
||||
return jsonify({"error": "MercadoLibre not connected"}), 400
|
||||
return jsonify({"error": "MercadoLibre no está conectado. Conecta la cuenta en la pestaña de configuración."}), 400
|
||||
attrs = svc.get_category_attributes(category_id)
|
||||
# Filter to required attributes only for the UI
|
||||
required = [a for a in attrs if a.get("tags", {}).get("required")]
|
||||
return jsonify({"attributes": required, "all": attrs})
|
||||
except MeliAuthError:
|
||||
return jsonify({"error": "MercadoLibre token expired. Please reconnect."}), 401
|
||||
except MeliAuthError as e:
|
||||
return _meli_auth_error(e)
|
||||
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:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
finally:
|
||||
@@ -400,6 +546,7 @@ def validate_listings():
|
||||
shipping_mode=shipping_mode,
|
||||
custom_data=custom_data,
|
||||
base_url=_get_public_base_url(),
|
||||
use_llm_description=bool(data.get("use_llm_description", False)),
|
||||
)
|
||||
return jsonify(result)
|
||||
except ValueError as e:
|
||||
@@ -440,6 +587,8 @@ def create_listings_async():
|
||||
shipping_mode=shipping_mode,
|
||||
custom_data=custom_data,
|
||||
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
|
||||
except Exception as e:
|
||||
@@ -452,7 +601,7 @@ def get_async_listing_status(task_id):
|
||||
"""Get status of an async ML publishing task."""
|
||||
try:
|
||||
from celery.result import AsyncResult
|
||||
from app import celery as celery_app
|
||||
from celery_app import celery as celery_app
|
||||
result = AsyncResult(task_id, app=celery_app)
|
||||
if result.ready():
|
||||
return jsonify({"status": "done", "result": result.result or {}})
|
||||
@@ -616,6 +765,34 @@ def answer_question(question_id):
|
||||
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
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -643,8 +820,8 @@ def sync_orders():
|
||||
@marketplace_ext_bp.route("/orders", methods=["GET"])
|
||||
@require_auth()
|
||||
def list_orders():
|
||||
page = int(request.args.get("page", 1))
|
||||
per_page = min(int(request.args.get("per_page", 50)), 200)
|
||||
page = max(1, _safe_int(request.args.get("page"), 1))
|
||||
per_page = min(max(1, _safe_int(request.args.get("per_page"), 50)), 200)
|
||||
status = request.args.get("status")
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
@@ -673,11 +850,15 @@ def get_order(order_id):
|
||||
def convert_order(order_id):
|
||||
data = request.get_json() or {}
|
||||
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)
|
||||
try:
|
||||
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)
|
||||
except ValueError as e:
|
||||
@@ -716,38 +897,35 @@ def update_order_status_route(order_id):
|
||||
def meli_webhook():
|
||||
"""Receive MercadoLibre notifications.
|
||||
|
||||
ML sends a lightweight payload with topic + resource URL.
|
||||
We ack immediately and enqueue Celery for async processing.
|
||||
ML sends a lightweight payload with topic + resource URL. We resolve the
|
||||
tenant from ML's user_id, validate the X-Signature header when present, ack
|
||||
immediately and enqueue Celery to process the specific resource.
|
||||
"""
|
||||
raw_body = request.get_data()
|
||||
data = request.get_json(force=True, silent=True) or {}
|
||||
topic = data.get("topic", "")
|
||||
resource = data.get("resource", "")
|
||||
user_id = data.get("user_id")
|
||||
|
||||
# Resolve tenant by meli_user_id
|
||||
tenant_id = None
|
||||
if user_id:
|
||||
try:
|
||||
mconn = get_master_conn()
|
||||
mcur = mconn.cursor()
|
||||
mcur.execute(
|
||||
"""
|
||||
SELECT t.id FROM tenants t
|
||||
JOIN tenant_config c ON c.key = 'meli_user_id' AND c.value = %s
|
||||
WHERE t.is_active = true
|
||||
LIMIT 1
|
||||
""",
|
||||
(str(user_id),),
|
||||
)
|
||||
row = mcur.fetchone()
|
||||
if row:
|
||||
tenant_id = row[0]
|
||||
mcur.close()
|
||||
mconn.close()
|
||||
except Exception:
|
||||
pass
|
||||
# tenant_config lives in each tenant DB, not in master
|
||||
tenant_id = _resolve_tenant_by_meli_user(user_id)
|
||||
if not tenant_id:
|
||||
print(f"[ML Webhook] No tenant found for meli_user_id={user_id}")
|
||||
return jsonify({"error": "unknown tenant"}), 403
|
||||
|
||||
if tenant_id and topic:
|
||||
cfg = _get_meli_config(tenant_id) or {}
|
||||
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:
|
||||
from tasks import process_meli_webhook_task
|
||||
process_meli_webhook_task.delay(tenant_id, topic, resource)
|
||||
|
||||
@@ -18,6 +18,11 @@ from services.pos_engine import (
|
||||
)
|
||||
from services.inventory_engine import get_stock
|
||||
from services.audit import log_action
|
||||
from services.cross_sell import get_cross_sell
|
||||
from services.anomaly_detector import (
|
||||
detect_anomalies, DEFAULT_MIN_SAMPLES as _ANOM_MIN_SAMPLES,
|
||||
DEFAULT_Z as _ANOM_Z,
|
||||
)
|
||||
from config import JWT_SECRET
|
||||
|
||||
pos_bp = Blueprint('pos', __name__, url_prefix='/pos/api')
|
||||
@@ -41,6 +46,88 @@ def _tenant_allows_zero_price(conn):
|
||||
return str(row[0]).lower() in ('true', '1', 'yes') if row else True
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def _float_arg(name, default, min_value=None, max_value=None):
|
||||
"""Parse a float query-string argument safely (bad input → default)."""
|
||||
raw = request.args.get(name)
|
||||
try:
|
||||
value = float(raw) if raw not in (None, '') else default
|
||||
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
|
||||
|
||||
|
||||
def _validate_price_floor(conn, items):
|
||||
"""Raise ValueError if any line price is below item cost and the employee
|
||||
is not owner/admin. Prevents margin leaks via price editing."""
|
||||
from flask import g as _g
|
||||
if getattr(_g, 'employee_role', None) in ('owner', 'admin'):
|
||||
return
|
||||
cur = conn.cursor()
|
||||
for item in items:
|
||||
inv_id = item.get('inventory_id')
|
||||
unit_price = float(item.get('unit_price', 0) or 0)
|
||||
if not inv_id or unit_price <= 0:
|
||||
continue
|
||||
cur.execute("SELECT part_number, cost FROM inventory WHERE id = %s", (inv_id,))
|
||||
row = cur.fetchone()
|
||||
if row and row[1] and unit_price < float(row[1]):
|
||||
cur.close()
|
||||
raise ValueError(
|
||||
f"Precio por debajo del costo en {row[0]} (${unit_price:,.2f} < ${float(row[1]):,.2f}). "
|
||||
"Solicita autorización de gerencia.")
|
||||
cur.close()
|
||||
|
||||
|
||||
def _validate_max_discount(conn, items):
|
||||
"""Raise ValueError if any line discount exceeds the employee's cap.
|
||||
|
||||
Mirrors the check done by pos_engine.process_sale so quotations and
|
||||
layaways cannot be used to bypass the discount limit.
|
||||
"""
|
||||
from flask import g as _g
|
||||
if getattr(_g, 'employee_role', None) in ('owner', 'admin'):
|
||||
return
|
||||
from services.pos_engine import get_max_discount_pct
|
||||
max_discount = get_max_discount_pct(conn)
|
||||
for item in items:
|
||||
discount_pct = float(item.get('discount_pct', 0) or 0)
|
||||
if discount_pct > max_discount:
|
||||
name = item.get('name') or item.get('part_number') or item.get('inventory_id')
|
||||
raise ValueError(
|
||||
f"Descuento {discount_pct}% excede tu máximo permitido {max_discount}% "
|
||||
f"para {name}"
|
||||
)
|
||||
|
||||
|
||||
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):
|
||||
@@ -156,7 +243,38 @@ def create_sale():
|
||||
try:
|
||||
_validate_stock_availability(conn, data.get('items', []), branch_id)
|
||||
_validate_zero_price(conn, data.get('items', []))
|
||||
_validate_price_floor(conn, data.get('items', []))
|
||||
|
||||
# Idempotencia offline: si el client_uuid ya existe, devolver esa venta
|
||||
client_uuid = (data.get('client_uuid') or '').strip() or None
|
||||
cur_iu = conn.cursor()
|
||||
if client_uuid:
|
||||
cur_iu.execute("SELECT id FROM sales WHERE client_uuid = %s LIMIT 1", (client_uuid,))
|
||||
dup = cur_iu.fetchone()
|
||||
if dup:
|
||||
cur_iu.execute("SELECT id, total FROM sales WHERE id = %s", (dup[0],))
|
||||
srow = cur_iu.fetchone()
|
||||
cur_iu.close()
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify({'id': srow[0], 'total': float(srow[1]),
|
||||
'duplicate': True,
|
||||
'message': 'Venta ya registrada (offline sync)'}), 200
|
||||
|
||||
sale = process_sale(conn, data)
|
||||
if client_uuid:
|
||||
try:
|
||||
cur_iu.execute("UPDATE sales SET client_uuid = %s WHERE id = %s",
|
||||
(client_uuid, sale['id']))
|
||||
except Exception:
|
||||
conn.rollback() # duplicate race: fetch existing
|
||||
cur_iu.execute("SELECT id FROM sales WHERE client_uuid = %s LIMIT 1", (client_uuid,))
|
||||
dup = cur_iu.fetchone()
|
||||
cur_iu.close()
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify({'id': dup[0], 'duplicate': True}), 200
|
||||
cur_iu.close()
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify(sale), 201
|
||||
@@ -267,22 +385,22 @@ def list_sales():
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
page = int(request.args.get('page', 1))
|
||||
per_page = min(int(request.args.get('per_page', 50)), 200)
|
||||
page = _int_arg('page', 1, min_value=1)
|
||||
per_page = _int_arg('per_page', 50, min_value=1, max_value=200)
|
||||
|
||||
where_clauses = ["1=1"]
|
||||
params = []
|
||||
|
||||
date_from = request.args.get('date_from')
|
||||
date_to = request.args.get('date_to')
|
||||
employee_id = request.args.get('employee_id')
|
||||
customer_id = request.args.get('customer_id')
|
||||
employee_id = _int_arg('employee_id')
|
||||
customer_id = _int_arg('customer_id')
|
||||
status = request.args.get('status')
|
||||
sale_type = request.args.get('sale_type')
|
||||
q = request.args.get('q')
|
||||
customer_q = request.args.get('customer')
|
||||
courier_id = request.args.get('courier_id')
|
||||
register_id = request.args.get('register_id')
|
||||
courier_id = _int_arg('courier_id')
|
||||
register_id = _int_arg('register_id')
|
||||
|
||||
if date_from:
|
||||
where_clauses.append("s.created_at >= %s")
|
||||
@@ -292,10 +410,10 @@ def list_sales():
|
||||
params.append(date_to)
|
||||
if employee_id:
|
||||
where_clauses.append("s.employee_id = %s")
|
||||
params.append(int(employee_id))
|
||||
params.append(employee_id)
|
||||
if customer_id:
|
||||
where_clauses.append("s.customer_id = %s")
|
||||
params.append(int(customer_id))
|
||||
params.append(customer_id)
|
||||
if status:
|
||||
where_clauses.append("s.status = %s")
|
||||
params.append(status)
|
||||
@@ -310,10 +428,10 @@ def list_sales():
|
||||
params.append(f'%{customer_q}%')
|
||||
if courier_id:
|
||||
where_clauses.append("s.courier_id = %s")
|
||||
params.append(int(courier_id))
|
||||
params.append(courier_id)
|
||||
if register_id:
|
||||
where_clauses.append("s.register_id = %s")
|
||||
params.append(int(register_id))
|
||||
params.append(register_id)
|
||||
|
||||
# Default to current branch
|
||||
if g.branch_id:
|
||||
@@ -386,7 +504,7 @@ def recent_sales():
|
||||
|
||||
date_from = request.args.get('date_from') or str(date.today())
|
||||
date_to = request.args.get('date_to') or date_from
|
||||
limit = min(int(request.args.get('limit', 10)), 50)
|
||||
limit = _int_arg('limit', 10, min_value=1, max_value=50)
|
||||
|
||||
where_clauses = [
|
||||
"s.created_at >= %s",
|
||||
@@ -454,8 +572,8 @@ def list_historical_sales():
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
page = int(request.args.get('page', 1))
|
||||
per_page = min(int(request.args.get('per_page', 50)), 200)
|
||||
page = _int_arg('page', 1, min_value=1)
|
||||
per_page = _int_arg('per_page', 50, min_value=1, max_value=200)
|
||||
|
||||
where_clauses = ["1=1"]
|
||||
params = []
|
||||
@@ -616,6 +734,25 @@ def api_cancel_sale(sale_id):
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = cancel_sale(conn, sale_id, reason)
|
||||
# Auto-cancel stamped CFDI (motivo 03: no se llevó a cabo la operación)
|
||||
try:
|
||||
from services.cfdi_queue import cancel_cfdi
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT id FROM cfdi_queue WHERE sale_id = %s AND status = 'stamped' "
|
||||
"ORDER BY id DESC LIMIT 1", (sale_id,))
|
||||
cfdi_row = cur.fetchone()
|
||||
cur.execute("SELECT key, value FROM tenant_config WHERE key IN "
|
||||
"('cfdi_facturapi_key', 'cfdi_facturapi_org_id', 'facturapi_secret_key')")
|
||||
tcfg = {k: v for k, v in cur.fetchall()}
|
||||
cur.close()
|
||||
if cfdi_row:
|
||||
cancel_cfdi(conn, cfdi_row[0], '03', tenant_config=tcfg)
|
||||
except Exception:
|
||||
try:
|
||||
conn.rollback() # CFDI cancel is best-effort
|
||||
except Exception:
|
||||
pass
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify(result)
|
||||
@@ -748,6 +885,8 @@ def create_quotation():
|
||||
enriched = _enrich_items(cur, items, data.get('customer_id'))
|
||||
_validate_stock_availability(conn, enriched, g.branch_id)
|
||||
_validate_zero_price(conn, enriched)
|
||||
_validate_price_floor(conn, enriched)
|
||||
_validate_max_discount(conn, enriched)
|
||||
except ValueError as e:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': str(e)}), 400
|
||||
@@ -844,18 +983,18 @@ def list_quotations():
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
page = int(request.args.get('page', 1))
|
||||
per_page = min(int(request.args.get('per_page', 50)), 200)
|
||||
page = _int_arg('page', 1, min_value=1)
|
||||
per_page = _int_arg('per_page', 50, min_value=1, max_value=200)
|
||||
|
||||
where_clauses = ["1=1"]
|
||||
params = []
|
||||
|
||||
customer_id = request.args.get('customer_id')
|
||||
customer_id = _int_arg('customer_id')
|
||||
status = request.args.get('status')
|
||||
|
||||
if customer_id:
|
||||
where_clauses.append("q.customer_id = %s")
|
||||
params.append(int(customer_id))
|
||||
params.append(customer_id)
|
||||
if status:
|
||||
where_clauses.append("q.status = %s")
|
||||
params.append(status)
|
||||
@@ -1138,6 +1277,8 @@ def update_quotation(quot_id):
|
||||
enriched = _enrich_items(cur, items, data.get('customer_id'))
|
||||
_validate_stock_availability(conn, enriched, g.branch_id)
|
||||
_validate_zero_price(conn, enriched)
|
||||
_validate_price_floor(conn, enriched)
|
||||
_validate_max_discount(conn, enriched)
|
||||
except ValueError as e:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': str(e)}), 400
|
||||
@@ -1454,7 +1595,11 @@ def get_quotation_pdf(quot_id):
|
||||
# Get business info from tenant config
|
||||
business_info = None
|
||||
try:
|
||||
cur.execute("SELECT key, value FROM config WHERE key IN ('business_name','rfc','address','phone','email')")
|
||||
cur.execute("""SELECT 'business_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')
|
||||
UNION ALL SELECT 'email', value FROM tenant_config WHERE key = 'tenant_email' LIMIT 5""")
|
||||
config_rows = cur.fetchall()
|
||||
if config_rows:
|
||||
business_info = {r[0]: r[1] for r in config_rows}
|
||||
@@ -1626,6 +1771,8 @@ def convert_quotation(quot_id):
|
||||
try:
|
||||
_validate_stock_availability(conn, items, g.branch_id)
|
||||
_validate_zero_price(conn, items)
|
||||
_validate_price_floor(conn, items)
|
||||
_validate_max_discount(conn, items)
|
||||
sale = process_sale(conn, sale_data)
|
||||
|
||||
# Mark quotation as converted
|
||||
@@ -1786,6 +1933,8 @@ def create_layaway():
|
||||
enriched = _enrich_items(cur, items, customer_id)
|
||||
_validate_stock_availability(conn, enriched, g.branch_id)
|
||||
_validate_zero_price(conn, enriched)
|
||||
_validate_price_floor(conn, enriched)
|
||||
_validate_max_discount(conn, enriched)
|
||||
except ValueError as e:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': str(e)}), 400
|
||||
@@ -1867,6 +2016,23 @@ def create_layaway():
|
||||
'customer_id': customer_id})
|
||||
|
||||
conn.commit()
|
||||
|
||||
# Accounting entry (non-blocking): anticipo de cliente
|
||||
try:
|
||||
import logging
|
||||
from services.accounting_engine import record_layaway_entry
|
||||
record_layaway_entry(conn, {
|
||||
'id': layaway_id,
|
||||
'amount': initial_payment,
|
||||
'payment_method': data.get('payment_method', 'efectivo'),
|
||||
})
|
||||
conn.commit()
|
||||
except Exception as acc_err:
|
||||
conn.rollback()
|
||||
logging.getLogger('pos').error(
|
||||
f'Accounting entry for layaway #{layaway_id} deposit failed: {acc_err}'
|
||||
)
|
||||
|
||||
cur.close(); conn.close()
|
||||
|
||||
return jsonify({
|
||||
@@ -1892,18 +2058,18 @@ def list_layaways():
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
page = int(request.args.get('page', 1))
|
||||
per_page = min(int(request.args.get('per_page', 50)), 200)
|
||||
page = _int_arg('page', 1, min_value=1)
|
||||
per_page = _int_arg('per_page', 50, min_value=1, max_value=200)
|
||||
|
||||
where_clauses = ["1=1"]
|
||||
params = []
|
||||
|
||||
customer_id = request.args.get('customer_id')
|
||||
customer_id = _int_arg('customer_id')
|
||||
status = request.args.get('status')
|
||||
|
||||
if customer_id:
|
||||
where_clauses.append("l.customer_id = %s")
|
||||
params.append(int(customer_id))
|
||||
params.append(customer_id)
|
||||
if status:
|
||||
where_clauses.append("l.status = %s")
|
||||
params.append(status)
|
||||
@@ -2070,6 +2236,23 @@ def layaway_payment(layaway_id):
|
||||
""", (register_id, amount, f'Apartado #{layaway_id} - abono', g.employee_id))
|
||||
|
||||
conn.commit()
|
||||
|
||||
# Accounting entry (non-blocking): anticipo de cliente
|
||||
try:
|
||||
import logging
|
||||
from services.accounting_engine import record_layaway_entry
|
||||
record_layaway_entry(conn, {
|
||||
'id': layaway_id,
|
||||
'amount': amount,
|
||||
'payment_method': data.get('payment_method', 'efectivo'),
|
||||
})
|
||||
conn.commit()
|
||||
except Exception as acc_err:
|
||||
conn.rollback()
|
||||
logging.getLogger('pos').error(
|
||||
f'Accounting entry for layaway #{layaway_id} payment failed: {acc_err}'
|
||||
)
|
||||
|
||||
cur.close(); conn.close()
|
||||
|
||||
new_remaining = round(total - new_paid, 2)
|
||||
@@ -2117,10 +2300,21 @@ def complete_layaway(layaway_id):
|
||||
return jsonify({'error': f'Layaway is {status}'}), 400
|
||||
|
||||
remaining = round(total - paid, 2)
|
||||
|
||||
# Payment method for the conversion sale / final payment. Defaults to cash
|
||||
# but honours whatever the register sent (tarjeta, transferencia, ...).
|
||||
payment_method = data.get('payment_method', 'efectivo')
|
||||
forma_pago_map = {
|
||||
'efectivo': '01', 'cheque': '02', 'transferencia': '03',
|
||||
'tarjeta': '04', 'mixto': '99', 'pendiente': '99', 'credito': '99'
|
||||
}
|
||||
forma_pago_sat = forma_pago_map.get(payment_method, '99')
|
||||
register_id = data.get('register_id')
|
||||
|
||||
try:
|
||||
# If there's a remaining balance, accept a final payment with the complete call
|
||||
if remaining > 0:
|
||||
final_method = data.get('payment_method', 'efectivo')
|
||||
final_method = payment_method
|
||||
cur.execute("""
|
||||
INSERT INTO layaway_payments
|
||||
(layaway_id, amount, payment_method, reference, employee_id)
|
||||
@@ -2131,13 +2325,24 @@ def complete_layaway(layaway_id):
|
||||
paid = total
|
||||
|
||||
# Record cash movement for final payment
|
||||
register_id = data.get('register_id')
|
||||
if register_id and final_method == 'efectivo':
|
||||
cur.execute("""
|
||||
INSERT INTO cash_movements (register_id, type, amount, reason, employee_id)
|
||||
VALUES (%s, 'in', %s, %s, %s)
|
||||
""", (register_id, remaining, f'Apartado #{layaway_id} - pago final', g.employee_id))
|
||||
|
||||
# Accounting entry for the final payment (part of the same transaction)
|
||||
try:
|
||||
from services.accounting_engine import record_layaway_entry
|
||||
record_layaway_entry(conn, {
|
||||
'id': layaway_id, 'amount': remaining, 'payment_method': final_method,
|
||||
})
|
||||
except Exception as acc_err:
|
||||
import logging
|
||||
logging.getLogger('pos').error(
|
||||
f'Accounting entry for layaway #{layaway_id} final payment failed: {acc_err}'
|
||||
)
|
||||
|
||||
# Get layaway items
|
||||
cur.execute("""
|
||||
SELECT inventory_id, quantity, unit_price, discount_pct, tax_rate
|
||||
@@ -2165,12 +2370,14 @@ def complete_layaway(layaway_id):
|
||||
payment_method, subtotal, discount_total, tax_total, total,
|
||||
amount_paid, change_given, metodo_pago_sat, forma_pago_sat,
|
||||
status, notes)
|
||||
VALUES (%s,%s,%s,%s,'cash','efectivo',%s,%s,%s,%s,%s,0,'PUE','01','completed',%s)
|
||||
VALUES (%s,%s,%s,%s,'cash',%s,%s,%s,%s,%s,%s,0,'PUE',%s,'completed',%s)
|
||||
RETURNING id, created_at
|
||||
""", (
|
||||
branch_id, cust_id, g.employee_id, data.get('register_id'),
|
||||
branch_id, cust_id, g.employee_id, register_id,
|
||||
payment_method,
|
||||
totals_calc['subtotal'], totals_calc['discount_total'],
|
||||
totals_calc['tax_total'], totals_calc['total'], total,
|
||||
forma_pago_sat,
|
||||
f'Completado de apartado #{layaway_id}',
|
||||
))
|
||||
sale_id, sale_created = cur.fetchone()
|
||||
@@ -2201,13 +2408,12 @@ def complete_layaway(layaway_id):
|
||||
))
|
||||
|
||||
# Record payment on register
|
||||
register_id = data.get('register_id')
|
||||
if register_id:
|
||||
cur.execute("""
|
||||
INSERT INTO sale_payments
|
||||
(sale_id, register_id, method, amount, reference)
|
||||
VALUES (%s,%s,'efectivo',%s,%s)
|
||||
""", (sale_id, register_id, total, f'Apartado #{layaway_id} completado'))
|
||||
VALUES (%s,%s,%s,%s,%s)
|
||||
""", (sale_id, register_id, payment_method, total, f'Apartado #{layaway_id} completado'))
|
||||
|
||||
sale = {
|
||||
'id': sale_id, 'status': 'completed', 'total': totals_calc['total'],
|
||||
@@ -2225,6 +2431,24 @@ def complete_layaway(layaway_id):
|
||||
|
||||
conn.commit()
|
||||
|
||||
# Accounting entry (non-blocking): apply advances and recognize the sale
|
||||
try:
|
||||
import logging
|
||||
from services.accounting_engine import record_layaway_conversion_entry
|
||||
record_layaway_conversion_entry(conn, {
|
||||
'id': sale['id'],
|
||||
'layaway_id': layaway_id,
|
||||
'subtotal': totals_calc['subtotal'],
|
||||
'tax_total': totals_calc['tax_total'],
|
||||
'total': totals_calc['total'],
|
||||
})
|
||||
conn.commit()
|
||||
except Exception as acc_err:
|
||||
conn.rollback()
|
||||
logging.getLogger('pos').error(
|
||||
f'Accounting entry for layaway #{layaway_id} conversion failed: {acc_err}'
|
||||
)
|
||||
|
||||
# WhatsApp learning hook (non-blocking)
|
||||
try:
|
||||
from services.wa_learning import check_learning_resolution
|
||||
@@ -2245,6 +2469,74 @@ def complete_layaway(layaway_id):
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
def _record_layaway_cancel_entry(conn, layaway_id):
|
||||
"""Reverse the customer advances (240) booked while collecting a layaway.
|
||||
|
||||
Deposits were journalised as 'Debit Caja/Bancos / Credit 240 Anticipos'
|
||||
when collected; cancelling the layaway refunds them, so the entry is
|
||||
reversed per payment method. Without this, account 240 keeps a phantom
|
||||
liability after cancellation. Mirrors accounting_engine.record_layaway_entry.
|
||||
"""
|
||||
from datetime import date as _date
|
||||
from services.accounting_engine import (
|
||||
_create_entry, _get_account_ids, _ensure_account,
|
||||
_check_period_open, get_next_entry_number,
|
||||
)
|
||||
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT payment_method, COALESCE(SUM(amount), 0)
|
||||
FROM layaway_payments WHERE layaway_id = %s
|
||||
GROUP BY payment_method
|
||||
""", (layaway_id,))
|
||||
payments = cur.fetchall()
|
||||
|
||||
credits = []
|
||||
total = 0.0
|
||||
for method, amount in payments:
|
||||
amt = round(float(amount or 0), 2)
|
||||
if amt <= 0:
|
||||
continue
|
||||
credits.append((method, amt))
|
||||
total = round(total + amt, 2)
|
||||
|
||||
if total <= 0:
|
||||
cur.close()
|
||||
return None
|
||||
|
||||
accounts = _get_account_ids(cur, ['110', '111'])
|
||||
advance_account = _ensure_account(cur, '240', 'Anticipos de Clientes', '200', 'pasivo')
|
||||
|
||||
lines = [{
|
||||
'account_id': advance_account,
|
||||
'debit': total,
|
||||
'credit': 0,
|
||||
'description': f'Anticipos de clientes - Cancelacion apartado #{layaway_id}',
|
||||
}]
|
||||
for method, amount in credits:
|
||||
if method in ('transferencia', 'tarjeta'):
|
||||
account_id = accounts['111']
|
||||
desc = f'Bancos - Cancelacion apartado #{layaway_id}'
|
||||
else:
|
||||
account_id = accounts['110']
|
||||
desc = f'Caja - Cancelacion apartado #{layaway_id}'
|
||||
lines.append({
|
||||
'account_id': account_id, 'debit': 0, 'credit': amount,
|
||||
'description': desc,
|
||||
})
|
||||
|
||||
entry_date = _date.today()
|
||||
_check_period_open(cur, entry_date)
|
||||
entry_number = get_next_entry_number(conn)
|
||||
entry_id = _create_entry(
|
||||
cur, entry_number, entry_date, 'egreso',
|
||||
f'Apartado #{layaway_id} - cancelacion (reversa anticipos)',
|
||||
'layaway', layaway_id, lines
|
||||
)
|
||||
cur.close()
|
||||
return entry_id
|
||||
|
||||
|
||||
@pos_bp.route('/layaways/<int:layaway_id>/cancel', methods=['PUT'])
|
||||
@require_auth('pos.sell')
|
||||
def cancel_layaway(layaway_id):
|
||||
@@ -2298,6 +2590,19 @@ def cancel_layaway(layaway_id):
|
||||
'items_unreserved': len(layaway_items)})
|
||||
|
||||
conn.commit()
|
||||
|
||||
# Accounting entry (non-blocking): reverse the customer advances collected
|
||||
# for this layaway, otherwise account 240 keeps a phantom liability.
|
||||
try:
|
||||
import logging
|
||||
_record_layaway_cancel_entry(conn, layaway_id)
|
||||
conn.commit()
|
||||
except Exception as acc_err:
|
||||
conn.rollback()
|
||||
logging.getLogger('pos').error(
|
||||
f'Accounting reversal for cancelled layaway #{layaway_id} failed: {acc_err}'
|
||||
)
|
||||
|
||||
cur.close(); conn.close()
|
||||
|
||||
return jsonify({
|
||||
@@ -2311,14 +2616,19 @@ def cancel_layaway(layaway_id):
|
||||
# ─── Returns / Warranty ───────────────────────────
|
||||
|
||||
@pos_bp.route('/returns', methods=['POST'])
|
||||
@require_auth('pos.sell')
|
||||
@require_auth('pos.sell', 'pos.cancel')
|
||||
def create_return():
|
||||
"""Process a product return with warranty support.
|
||||
|
||||
Requires supervisor-level authorisation (pos.cancel) in addition to
|
||||
pos.sell, since a return moves stock back and refunds money.
|
||||
|
||||
Body: {
|
||||
sale_id: int,
|
||||
items: [{sale_item_id: int, quantity: int, reason: str}],
|
||||
notes: str
|
||||
notes: str,
|
||||
register_id: int (optional; cash register refunding the money),
|
||||
refund_method: str (optional; defaults to the sale's payment method)
|
||||
}
|
||||
"""
|
||||
data = request.get_json() or {}
|
||||
@@ -2337,7 +2647,7 @@ def create_return():
|
||||
try:
|
||||
# Validate sale exists and is completed
|
||||
cur.execute("""
|
||||
SELECT id, customer_id, total, status, branch_id, sale_type
|
||||
SELECT id, customer_id, total, status, branch_id, sale_type, payment_method
|
||||
FROM sales WHERE id = %s
|
||||
""", (sale_id,))
|
||||
sale = cur.fetchone()
|
||||
@@ -2349,6 +2659,7 @@ def create_return():
|
||||
sale_customer_id = sale[1]
|
||||
sale_branch_id = sale[4] or g.branch_id
|
||||
sale_type = sale[5]
|
||||
refund_method = data.get('refund_method') or sale[6] or 'efectivo'
|
||||
|
||||
# Validate each return item against original sale items
|
||||
total_refund = 0
|
||||
@@ -2433,6 +2744,27 @@ def create_return():
|
||||
notes=f'Devolucion #{return_id} de venta #{sale_id}: {vi["reason"]}'
|
||||
)
|
||||
|
||||
# Refund through the cash register: a cash refund leaves the drawer, so
|
||||
# it is recorded as an 'out' movement (see _compute_register_summary,
|
||||
# which subtracts these refunds from the expected cash). Credit sales
|
||||
# instead lower the customer's balance (handled below), and non-cash
|
||||
# refunds (tarjeta/transferencia) never touch the drawer.
|
||||
register_id = data.get('register_id')
|
||||
if not register_id:
|
||||
cur.execute("""
|
||||
SELECT id FROM cash_registers
|
||||
WHERE employee_id = %s AND status = 'open'
|
||||
""", (g.employee_id,))
|
||||
reg = cur.fetchone()
|
||||
register_id = reg[0] if reg else None
|
||||
|
||||
if register_id and refund_method == 'efectivo' and sale_type != 'credit':
|
||||
cur.execute("""
|
||||
INSERT INTO cash_movements (register_id, type, amount, reason, employee_id)
|
||||
VALUES (%s, 'out', %s, %s, %s)
|
||||
""", (register_id, total_refund,
|
||||
f'Devolucion #{return_id} de venta #{sale_id}', g.employee_id))
|
||||
|
||||
# Update sale status if all items returned
|
||||
cur.execute("""
|
||||
SELECT COALESCE(SUM(ri2.quantity), 0), COALESCE(SUM(si2.quantity), 0)
|
||||
@@ -2466,6 +2798,19 @@ def create_return():
|
||||
})
|
||||
|
||||
conn.commit()
|
||||
|
||||
# Accounting entry (non-blocking): devolucion sobre ventas + IVA + inventario
|
||||
try:
|
||||
import logging
|
||||
from services.accounting_engine import record_return_entry
|
||||
record_return_entry(conn, {'id': return_id, 'sale_id': sale_id})
|
||||
conn.commit()
|
||||
except Exception as acc_err:
|
||||
conn.rollback()
|
||||
logging.getLogger('pos').error(
|
||||
f'Accounting entry for return #{return_id} failed: {acc_err}'
|
||||
)
|
||||
|
||||
cur.close(); conn.close()
|
||||
|
||||
return jsonify({
|
||||
@@ -2494,20 +2839,20 @@ def list_returns():
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
page = int(request.args.get('page', 1))
|
||||
per_page = min(int(request.args.get('per_page', 50)), 200)
|
||||
page = _int_arg('page', 1, min_value=1)
|
||||
per_page = _int_arg('per_page', 50, min_value=1, max_value=200)
|
||||
|
||||
where_clauses = ["1=1"]
|
||||
params = []
|
||||
|
||||
sale_id = request.args.get('sale_id')
|
||||
customer_id = request.args.get('customer_id')
|
||||
sale_id = _int_arg('sale_id')
|
||||
customer_id = _int_arg('customer_id')
|
||||
if sale_id:
|
||||
where_clauses.append("r.sale_id = %s")
|
||||
params.append(int(sale_id))
|
||||
params.append(sale_id)
|
||||
if customer_id:
|
||||
where_clauses.append("r.customer_id = %s")
|
||||
params.append(int(customer_id))
|
||||
params.append(customer_id)
|
||||
|
||||
where = " AND ".join(where_clauses)
|
||||
|
||||
@@ -2655,7 +3000,9 @@ def print_ticket(sale_id):
|
||||
# Fetch business info from config
|
||||
business_info = {'name': 'NEXUS AUTOPARTS', 'rfc': '', 'address': ''}
|
||||
try:
|
||||
cur.execute("SELECT key, value FROM config WHERE key IN ('business_name','rfc','address')")
|
||||
cur.execute("""SELECT 'business_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')""")
|
||||
for rw in cur.fetchall():
|
||||
if rw[0] == 'business_name':
|
||||
business_info['name'] = rw[1]
|
||||
@@ -2736,7 +3083,9 @@ def print_remission(sale_id):
|
||||
|
||||
business_info = {'name': 'NEXUS AUTOPARTS', 'rfc': '', 'address': ''}
|
||||
try:
|
||||
cur.execute("SELECT key, value FROM config WHERE key IN ('business_name','rfc','address')")
|
||||
cur.execute("""SELECT 'business_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')""")
|
||||
for rw in cur.fetchall():
|
||||
if rw[0] == 'business_name':
|
||||
business_info['name'] = rw[1]
|
||||
@@ -2765,6 +3114,67 @@ def print_remission(sale_id):
|
||||
})
|
||||
|
||||
|
||||
# ─── Cross-sell recommendations (A10) ───────────────────────────────────────
|
||||
|
||||
@pos_bp.route('/recommendations', methods=['POST'])
|
||||
@require_auth('pos.sell')
|
||||
def recommendations():
|
||||
"""Suggest complementary parts for the items currently in the cart.
|
||||
|
||||
Body: {"inventory_ids": [1,2,3], "limit": 5}
|
||||
"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
raw_ids = data.get('inventory_ids') or data.get('items') or []
|
||||
if not isinstance(raw_ids, list):
|
||||
return jsonify({'error': 'inventory_ids must be a list'}), 400
|
||||
try:
|
||||
inventory_ids = [int(x) for x in raw_ids][:50]
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({'error': 'inventory_ids must be a list of integers'}), 400
|
||||
|
||||
try:
|
||||
limit = int(data.get('limit') or 5)
|
||||
except (TypeError, ValueError):
|
||||
limit = 5
|
||||
limit = max(1, min(limit, 20))
|
||||
|
||||
if not inventory_ids:
|
||||
return jsonify({'recommendations': []})
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
recs = get_cross_sell(conn, inventory_ids, limit=limit)
|
||||
finally:
|
||||
conn.close()
|
||||
return jsonify({'recommendations': recs})
|
||||
|
||||
|
||||
# ─── Business anomaly report (A5) ───────────────────────────────────────────
|
||||
|
||||
@pos_bp.route('/reports/anomalies', methods=['GET'])
|
||||
@require_auth('reports.view')
|
||||
def report_anomalies():
|
||||
"""Robust anomaly scan over discounts, below-cost sales, cancellations
|
||||
and off-hours tickets.
|
||||
|
||||
Query params: days (90), min_samples (5), z (3.5), limit (50).
|
||||
"""
|
||||
days = _int_arg('days', 90, 1, 3650)
|
||||
min_samples = _int_arg('min_samples', _ANOM_MIN_SAMPLES, 1, 1000)
|
||||
z_threshold = _float_arg('z', _ANOM_Z, 0.5, 20.0)
|
||||
limit = _int_arg('limit', 50, 1, 200)
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = detect_anomalies(
|
||||
conn, days=days, min_samples=min_samples,
|
||||
z_threshold=z_threshold, limit=limit,
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
# ─── Public Quote HTML Template ─────────────────────────────────────────────
|
||||
|
||||
PUBLIC_QUOTE_TEMPLATE = """
|
||||
|
||||
@@ -123,7 +123,10 @@ def public_catalog_search():
|
||||
from services.catalog_service import smart_search
|
||||
master = get_master_conn()
|
||||
try:
|
||||
results = smart_search(master, q, conn, branch_id=None, limit=limit)
|
||||
# Catálogo público: scope 'catalog' → flag SEARCH_RERANK_ENABLED
|
||||
# (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:
|
||||
master.close()
|
||||
|
||||
|
||||
@@ -777,7 +777,9 @@ def print_service_order_ticket(so_id):
|
||||
business_info = {'name': 'NEXUS AUTOPARTS', 'rfc': '', 'address': ''}
|
||||
try:
|
||||
cur.execute(
|
||||
"SELECT key, value FROM config WHERE key IN ('business_name','rfc','address')"
|
||||
"""SELECT 'business_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')"""
|
||||
)
|
||||
for rw in cur.fetchall():
|
||||
if rw[0] == 'business_name':
|
||||
|
||||
240
pos/blueprints/storefront_bp.py
Normal file
240
pos/blueprints/storefront_bp.py
Normal file
@@ -0,0 +1,240 @@
|
||||
# /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.',
|
||||
})
|
||||
@@ -3,6 +3,7 @@
|
||||
Endpoints (all under /pos/api):
|
||||
GET/POST /warranties
|
||||
GET /warranties/<id>
|
||||
GET /warranties/<id>/print (HTML)
|
||||
GET /customers/<id>/warranties
|
||||
POST /warranty-claims
|
||||
GET /warranty-claims
|
||||
@@ -11,7 +12,7 @@ Endpoints (all under /pos/api):
|
||||
PUT /warranty-claims/<id>/close
|
||||
"""
|
||||
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from flask import Blueprint, request, jsonify, g, render_template_string
|
||||
from middleware import require_auth
|
||||
from tenant_db import get_tenant_conn
|
||||
from services.warranty_engine import (
|
||||
@@ -43,7 +44,10 @@ def get_warranties():
|
||||
def post_warranty():
|
||||
"""Register a warranty (usually called at sale time)."""
|
||||
data = request.get_json() or {}
|
||||
required = ['sale_id', 'sale_item_id', 'inventory_id', 'customer_id', 'warranty_months']
|
||||
required = ['sale_id', 'sale_item_id', 'inventory_id', 'customer_id']
|
||||
# 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]
|
||||
if missing:
|
||||
return jsonify({'error': f'Missing fields: {missing}'}), 400
|
||||
@@ -56,11 +60,12 @@ def post_warranty():
|
||||
sale_item_id=data['sale_item_id'],
|
||||
inventory_id=data['inventory_id'],
|
||||
customer_id=data['customer_id'],
|
||||
warranty_months=int(data['warranty_months']),
|
||||
warranty_months=int(data.get('warranty_months') or 0),
|
||||
supplier_id=data.get('supplier_id'),
|
||||
part_number=data.get('part_number'),
|
||||
name=data.get('name'),
|
||||
notes=data.get('notes'),
|
||||
warranty_days=int(data['warranty_days']) if data.get('warranty_days') else None,
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
@@ -87,6 +92,49 @@ def get_warranty_detail(warranty_id):
|
||||
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'])
|
||||
@require_auth('inventory.view')
|
||||
def get_customer_warranties(customer_id):
|
||||
@@ -206,3 +254,68 @@ def put_close_claim(claim_id):
|
||||
conn.rollback()
|
||||
conn.close()
|
||||
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 %} · 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>
|
||||
"""
|
||||
|
||||
@@ -34,6 +34,7 @@ def _get_whatsapp_config(conn):
|
||||
|
||||
bridge_url = config.get('whatsapp_bridge_url', '') or WHATSAPP_BRIDGE_URL 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()
|
||||
if enabled_raw == 'true':
|
||||
enabled = True
|
||||
@@ -43,7 +44,17 @@ def _get_whatsapp_config(conn):
|
||||
# No explicit tenant setting: auto-enable if a bridge URL is configured
|
||||
enabled = bool(bridge_url)
|
||||
|
||||
return {
|
||||
return { 'provider': provider,
|
||||
'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_key': bridge_key,
|
||||
'enabled': enabled,
|
||||
@@ -359,6 +370,11 @@ def webhook():
|
||||
return jsonify({'ok': True})
|
||||
|
||||
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'):
|
||||
return jsonify({'ok': True})
|
||||
|
||||
@@ -393,7 +409,7 @@ def webhook():
|
||||
|
||||
|
||||
# Resolve tenant
|
||||
tenant_id = request.args.get('tenant_id', type=int)
|
||||
tenant_id = tenant_id_override or request.args.get('tenant_id', type=int)
|
||||
if not tenant_id:
|
||||
try:
|
||||
mconn = get_master_conn()
|
||||
@@ -494,7 +510,7 @@ def webhook():
|
||||
from services.part_kits import should_send_followup
|
||||
followup = should_send_followup(phone, tenant_conn)
|
||||
if followup:
|
||||
whatsapp_service.send_message(reply_to, followup, bridge_url=wa_config.get('bridge_url'))
|
||||
whatsapp_service.wa_send_text(wa_config, reply_to, followup)
|
||||
cur_fu = tenant_conn.cursor()
|
||||
cur_fu.execute(
|
||||
"INSERT INTO whatsapp_messages (phone, direction, message_text) VALUES (%s, 'outgoing', %s)",
|
||||
@@ -544,7 +560,7 @@ def webhook():
|
||||
|
||||
# 7. Send reply
|
||||
if reply:
|
||||
result = whatsapp_service.send_message(reply_to, reply, bridge_url=wa_config.get('bridge_url'))
|
||||
result = whatsapp_service.wa_send_text(wa_config, reply_to, reply)
|
||||
print(f"[WA-SM] Replied to {phone}: {reply[:80]}... result={result}")
|
||||
|
||||
# Log outgoing
|
||||
@@ -568,7 +584,7 @@ def webhook():
|
||||
"Estoy teniendo problemas técnicos en este momento. 😕\n\n"
|
||||
f"Por favor llámanos directamente al {phone_branch}."
|
||||
)
|
||||
whatsapp_service.send_message(reply_to, fallback, bridge_url=wa_config.get('bridge_url'))
|
||||
whatsapp_service.wa_send_text(wa_config, reply_to, fallback)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -583,10 +599,221 @@ def webhook():
|
||||
master_conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
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'])
|
||||
@require_auth()
|
||||
def send():
|
||||
|
||||
911
pos/blueprints/yonke_bp.py
Normal file
911
pos/blueprints/yonke_bp.py
Normal file
@@ -0,0 +1,911 @@
|
||||
# /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()
|
||||
@@ -90,6 +90,26 @@ 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')
|
||||
|
||||
# ─── 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_API_KEY = os.environ.get('INTERNAL_API_KEY', '')
|
||||
|
||||
@@ -89,8 +89,10 @@ def resolve_tenant():
|
||||
g.tenant_name = None
|
||||
g.tenant_subdomain = None
|
||||
|
||||
# Skip for static files and health check
|
||||
if request.path.startswith('/pos/static/') or request.path == '/pos/health':
|
||||
# Skip for static files, health check and Prometheus metrics scrape
|
||||
if (request.path.startswith('/pos/static/')
|
||||
or request.path == '/pos/health'
|
||||
or request.path == '/metrics'):
|
||||
return
|
||||
|
||||
subdomain = _extract_subdomain()
|
||||
|
||||
@@ -70,6 +70,11 @@ MIGRATIONS = {
|
||||
"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",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
-- : SKIP
|
||||
-- v1.2: Add subdomain column to tenants table (nexus_master)
|
||||
-- This migration runs against the MASTER database, not tenant databases.
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
-- : SKIP
|
||||
-- Marketplace orders (cross-tenant)
|
||||
-- These tables go in nexus_autoparts (master DB), not tenant DBs
|
||||
|
||||
|
||||
@@ -2,15 +2,20 @@
|
||||
-- Applies to: master DB (nexus_autoparts) and all tenant DBs
|
||||
|
||||
-- ─── 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.
|
||||
|
||||
-- Index for parts name lookups (used by get_part_types, shop supplies)
|
||||
CREATE INDEX IF NOT EXISTS idx_parts_name_part ON parts(name_part);
|
||||
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,
|
||||
-- kept here for completeness on fresh installs)
|
||||
CREATE INDEX IF NOT EXISTS idx_wi_part_stock_positive
|
||||
ON warehouse_inventory(part_id) WHERE stock_quantity > 0;
|
||||
DO $$
|
||||
BEGIN
|
||||
IF to_regclass('public.parts') IS NOT NULL THEN
|
||||
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)';
|
||||
END IF;
|
||||
IF to_regclass('public.warehouse_inventory') IS NOT NULL THEN
|
||||
EXECUTE 'CREATE INDEX IF NOT EXISTS idx_wi_part_stock_positive ON warehouse_inventory(part_id) WHERE stock_quantity > 0';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ─── TENANT DB (run on each tenant) ────────────
|
||||
|
||||
|
||||
@@ -10,9 +10,12 @@
|
||||
-- To apply manually on a tenant with WhatsApp tables:
|
||||
-- psql <tenant_db> -f pos/migrations/v3.5_whatsapp_state_machine.sql
|
||||
-- (Remove the ': SKIP' line above before manual execution.)
|
||||
--
|
||||
-- 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
|
||||
-- 1. Extender whatsapp_sessions con estado y contexto
|
||||
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'whatsapp_sessions') THEN
|
||||
@@ -41,12 +44,12 @@ BEGIN
|
||||
CREATE INDEX IF NOT EXISTS idx_wa_cust_link_customer ON wa_customer_links(customer_id);
|
||||
|
||||
CREATE OR REPLACE FUNCTION update_wa_link_timestamp()
|
||||
RETURNS TRIGGER AS $$
|
||||
RETURNS TRIGGER AS $fn$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
$fn$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_wa_link_updated ON wa_customer_links;
|
||||
CREATE TRIGGER trg_wa_link_updated
|
||||
@@ -115,4 +118,4 @@ BEGIN
|
||||
FROM wa_customer_links wcl
|
||||
WHERE ws.phone = wcl.phone AND ws.customer_id IS NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
END $do$;
|
||||
|
||||
18
pos/migrations/v4.24_trgm_search_indexes.sql
Normal file
18
pos/migrations/v4.24_trgm_search_indexes.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
-- 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;
|
||||
28
pos/migrations/v4.25_transfer_documents.sql
Normal file
28
pos/migrations/v4.25_transfer_documents.sql
Normal file
@@ -0,0 +1,28 @@
|
||||
-- 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);
|
||||
4
pos/migrations/v4.26_sales_client_uuid.sql
Normal file
4
pos/migrations/v4.26_sales_client_uuid.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
-- 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;
|
||||
113
pos/migrations/v4.27_yonke_m1.sql
Normal file
113
pos/migrations/v4.27_yonke_m1.sql
Normal file
@@ -0,0 +1,113 @@
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- 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);
|
||||
56
pos/migrations/v4.28_meli_commerce.sql
Normal file
56
pos/migrations/v4.28_meli_commerce.sql
Normal file
@@ -0,0 +1,56 @@
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- 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;
|
||||
|
||||
@@ -29,6 +29,8 @@ 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);
|
||||
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);
|
||||
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)
|
||||
|
||||
@@ -13,6 +13,7 @@ Account codes (from SAT seed):
|
||||
140 = IVA Acreditable
|
||||
210 = Proveedores
|
||||
220 = IVA Trasladado
|
||||
240 = Anticipos de Clientes (created on demand for layaways)
|
||||
410 = Ventas
|
||||
420 = Devoluciones sobre Ventas
|
||||
510 = Costo de Mercancia Vendida
|
||||
@@ -20,10 +21,13 @@ Account codes (from SAT seed):
|
||||
All functions receive a psycopg2 connection (caller controls commit).
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import date
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
from flask import g
|
||||
|
||||
logger = logging.getLogger('accounting')
|
||||
|
||||
|
||||
def _to_dec(val):
|
||||
"""Convert a value to Decimal for precise arithmetic."""
|
||||
@@ -57,6 +61,48 @@ def _get_account_ids(cur, codes):
|
||||
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):
|
||||
"""Get the next sequential journal entry number.
|
||||
|
||||
@@ -576,6 +622,434 @@ def record_cancellation_entry(conn, sale):
|
||||
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):
|
||||
"""Create a manual journal entry (type='diario').
|
||||
|
||||
|
||||
@@ -3,8 +3,17 @@
|
||||
|
||||
import requests
|
||||
import json
|
||||
from config import OPENROUTER_API_KEY, HERMES_API_URL, HERMES_API_KEY
|
||||
from config import QWEN_API_URL, QWEN_API_KEY, QWEN_MODEL
|
||||
import os as _os
|
||||
try: # config del POS; con sys.path del dashboard puede resolver el config raíz
|
||||
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"
|
||||
HERMES_ENABLED = bool(HERMES_API_KEY and HERMES_API_URL)
|
||||
@@ -394,7 +403,7 @@ def classify_part(part_number):
|
||||
|
||||
for url, key, model_id in backends:
|
||||
_validate_model(model_id)
|
||||
result = _post_chat_completion(url, key, model_id, messages, max_tokens=300, temperature=0.2, timeout=15)
|
||||
result = _post_chat_completion(url, key, model_id, messages, max_tokens=800, temperature=0.2, timeout=70)
|
||||
if result is None:
|
||||
continue
|
||||
content = result["content"]
|
||||
@@ -543,7 +552,7 @@ def chat_cache_clear():
|
||||
_RESPONSE_CACHE.clear()
|
||||
|
||||
|
||||
def chat(user_message, conversation_history=None, inventory_context=None):
|
||||
def chat(user_message, conversation_history=None, inventory_context=None, persona=None):
|
||||
"""Send a message to the AI and get a response with search suggestions.
|
||||
|
||||
Caches responses for repeated identical questions (subject to bypass
|
||||
@@ -553,6 +562,7 @@ def chat(user_message, conversation_history=None, inventory_context=None):
|
||||
user_message: The user's chat message.
|
||||
conversation_history: Previous messages in the conversation.
|
||||
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_key = None
|
||||
@@ -564,6 +574,9 @@ def chat(user_message, conversation_history=None, inventory_context=None):
|
||||
return cached
|
||||
|
||||
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:
|
||||
system_content = SYSTEM_PROMPT + "\n\n" + inventory_context
|
||||
|
||||
|
||||
357
pos/services/anomaly_detector.py
Normal file
357
pos/services/anomaly_detector.py
Normal file
@@ -0,0 +1,357 @@
|
||||
"""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,
|
||||
},
|
||||
}
|
||||
985
pos/services/b2b_service.py
Normal file
985
pos/services/b2b_service.py
Normal file
@@ -0,0 +1,985 @@
|
||||
# /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,
|
||||
}
|
||||
@@ -3,13 +3,21 @@
|
||||
from tenant_db import get_master_conn
|
||||
|
||||
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},
|
||||
'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},
|
||||
'enterprise': {'name': 'Enterprise', 'price_mxn': 3999, 'max_products': None, 'max_employees': None, 'max_branches': None},
|
||||
}
|
||||
|
||||
PLAN_ORDER = ['free', 'basic', 'pro', 'enterprise']
|
||||
PLAN_ORDER = ['free', 'demo', 'basic', 'standard', 'pro', 'enterprise']
|
||||
|
||||
|
||||
def get_plan(tenant_id):
|
||||
|
||||
@@ -737,9 +737,10 @@ def get_nexpart_groups_for_vehicle(master_conn, mye_id, tenant_conn=None):
|
||||
|
||||
classified = _classify_vehicle_parts(master_conn, mye_id)
|
||||
# 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:
|
||||
_merge_classified(classified, _classify_inventory_parts(tenant_conn, mye_id))
|
||||
_merge_classified(classified, _classify_supplier_catalog_parts(master_conn, mye_id))
|
||||
|
||||
result = []
|
||||
# Iterate in canonical Nexpart order so the UI is stable
|
||||
@@ -769,9 +770,10 @@ def get_nexpart_subgroups_for_vehicle(master_conn, mye_id, group_slug, tenant_co
|
||||
)
|
||||
|
||||
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:
|
||||
_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, {})
|
||||
if not group_data:
|
||||
return []
|
||||
@@ -1172,9 +1174,10 @@ def get_parts_for_nexpart_triple(master_conn, mye_id, group_slug, subgroup_slug,
|
||||
"""
|
||||
classified = _classify_vehicle_parts(master_conn, mye_id)
|
||||
# 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:
|
||||
_merge_classified(classified, _classify_inventory_parts(tenant_conn, mye_id))
|
||||
_merge_classified(classified, _classify_supplier_catalog_parts(master_conn, mye_id))
|
||||
|
||||
all_ids = (
|
||||
classified
|
||||
@@ -1327,9 +1330,10 @@ def get_nexpart_part_types_for_vehicle(master_conn, mye_id, group_slug, subgroup
|
||||
)
|
||||
|
||||
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:
|
||||
_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, {})
|
||||
if not subgroup_data:
|
||||
return []
|
||||
@@ -1852,49 +1856,169 @@ def get_part_detail(master_conn, part_id, tenant_conn, branch_id):
|
||||
}
|
||||
|
||||
|
||||
def _get_alternatives(cur, part_id):
|
||||
"""Get cross-references + aftermarket parts for a given OEM part."""
|
||||
def _alternatives_from_supplier_catalog(conn, catalog_id=None, part_number=None,
|
||||
name=None, category=None, limit=20,
|
||||
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 = []
|
||||
seen = set()
|
||||
|
||||
# Cross-references (other OEM numbers that reference this part)
|
||||
cur.execute("""
|
||||
SELECT pcr.cross_reference_number, pcr.source_ref
|
||||
FROM part_cross_references pcr
|
||||
WHERE pcr.part_id = %s
|
||||
LIMIT 50
|
||||
""", (part_id,))
|
||||
for r in cur.fetchall():
|
||||
results.append({
|
||||
'part_number': r[0],
|
||||
'manufacturer': r[1] or 'OEM Cross-Ref',
|
||||
'name': None,
|
||||
'type': 'cross_reference',
|
||||
'local_stock': 0,
|
||||
'bodega_count': 0,
|
||||
})
|
||||
base = None
|
||||
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()
|
||||
|
||||
# Aftermarket alternatives
|
||||
cur.execute("""
|
||||
SELECT ap.part_number, m.name_manufacture,
|
||||
COALESCE(ap.name_es, ap.name_aftermarket_parts) AS name
|
||||
FROM aftermarket_parts ap
|
||||
JOIN manufacturers m ON m.id_manufacture = ap.manufacturer_id
|
||||
WHERE ap.oem_part_id = %s
|
||||
LIMIT 50
|
||||
""", (part_id,))
|
||||
for r in cur.fetchall():
|
||||
results.append({
|
||||
'part_number': r[0],
|
||||
'manufacturer': r[1],
|
||||
'name': r[2],
|
||||
'type': 'aftermarket',
|
||||
'local_stock': 0,
|
||||
'bodega_count': 0,
|
||||
})
|
||||
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("""
|
||||
SELECT id, supplier_name, sku, name FROM supplier_catalog
|
||||
WHERE UPPER(sku) = ANY(%s) AND is_active = true
|
||||
LIMIT %s
|
||||
""", (key_list, limit * 2))
|
||||
exact_rows = list(cur.fetchall())
|
||||
# (b) catalog items whose interchange list contains one of our keys
|
||||
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({
|
||||
'part_number': sku,
|
||||
'manufacturer': sup,
|
||||
'name': cname,
|
||||
'type': 'cross_reference',
|
||||
'similarity': None,
|
||||
'catalog_id': cid,
|
||||
'local_stock': 0,
|
||||
'bodega_count': 0,
|
||||
})
|
||||
if len(results) >= limit:
|
||||
break
|
||||
|
||||
# ── 2. Semantic substitutes (same category, different wording) ─────────
|
||||
if use_semantic and base_name and len(results) < limit:
|
||||
pool_cat = base_cat
|
||||
try:
|
||||
if pool_cat:
|
||||
cur.execute("""
|
||||
SELECT id, supplier_name, sku, name FROM supplier_catalog
|
||||
WHERE is_active = true AND category = %s AND id <> COALESCE(%s, -1)
|
||||
LIMIT %s
|
||||
""", (pool_cat, base_id, semantic_pool))
|
||||
else:
|
||||
cur.execute("""
|
||||
SELECT id, supplier_name, sku, name FROM supplier_catalog
|
||||
WHERE is_active = true AND id <> COALESCE(%s, -1)
|
||||
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({
|
||||
'part_number': sku,
|
||||
'manufacturer': sup,
|
||||
'name': cname,
|
||||
'type': 'semantic',
|
||||
'similarity': round(item['similarity'], 3),
|
||||
'catalog_id': cid,
|
||||
'local_stock': 0,
|
||||
'bodega_count': 0,
|
||||
})
|
||||
if len(results) >= limit:
|
||||
break
|
||||
|
||||
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
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -1939,7 +2063,46 @@ def _search_meili_fallback(master_conn, q, limit):
|
||||
return None
|
||||
|
||||
|
||||
def smart_search(master_conn, q, tenant_conn, branch_id, limit=50, mye_id=None, tenant_id=None):
|
||||
def _rerank_document(item):
|
||||
"""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.
|
||||
|
||||
Strategy:
|
||||
@@ -1947,6 +2110,9 @@ 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
|
||||
3. Search local inventory items by part_number or name
|
||||
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()
|
||||
if not q or len(q) < 2:
|
||||
@@ -1974,6 +2140,7 @@ def smart_search(master_conn, q, tenant_conn, branch_id, limit=50, mye_id=None,
|
||||
'image_url': li['image_url'],
|
||||
'local_stock': li['stock'],
|
||||
'local_price': li['price_1'],
|
||||
'brand': li.get('brand'),
|
||||
'vehicle_info': '',
|
||||
'source': 'local_inventory',
|
||||
})
|
||||
@@ -1983,7 +2150,10 @@ def smart_search(master_conn, q, tenant_conn, branch_id, limit=50, mye_id=None,
|
||||
|
||||
# ── Inject supplier catalog items ───────────────────────────────────────
|
||||
if master_conn:
|
||||
supplier_items = _search_supplier_catalog(master_conn, q, mye_id, limit, tenant_id=tenant_id)
|
||||
# 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)
|
||||
for si in supplier_items:
|
||||
if f"sc:{si['id']}" in seen_local_ids:
|
||||
continue
|
||||
@@ -2003,7 +2173,7 @@ def smart_search(master_conn, q, tenant_conn, branch_id, limit=50, mye_id=None,
|
||||
if len(results) >= limit:
|
||||
break
|
||||
|
||||
return results
|
||||
return _apply_rerank(q, results, rerank_scope)
|
||||
|
||||
|
||||
def _get_supplier_prices(master_conn, tenant_id, catalog_ids):
|
||||
@@ -2027,6 +2197,33 @@ def _get_supplier_prices(master_conn, tenant_id, catalog_ids):
|
||||
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):
|
||||
"""Search supplier catalog items by SKU or name.
|
||||
|
||||
@@ -2092,13 +2289,181 @@ def _search_supplier_catalog(master_conn, q, mye_id, limit, tenant_id=None):
|
||||
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):
|
||||
"""Search tenant inventory items by part_number or name.
|
||||
|
||||
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:
|
||||
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()
|
||||
clean_q = q.replace(' ', '').upper()
|
||||
|
||||
@@ -2122,7 +2487,7 @@ def _search_local_inventory(tenant_conn, q, mye_id, branch_id, limit):
|
||||
if branch_id:
|
||||
cur.execute(f"""
|
||||
SELECT i.id, i.part_number, i.name, i.image_url,
|
||||
i.price_1, COALESCE(s.stock, 0) as stock
|
||||
i.price_1, i.brand, COALESCE(s.stock, 0) as stock
|
||||
FROM inventory i
|
||||
JOIN inventory_vehicle_compat ivc ON ivc.inventory_id = i.id
|
||||
LEFT JOIN inventory_stock_summary s
|
||||
@@ -2137,7 +2502,7 @@ def _search_local_inventory(tenant_conn, q, mye_id, branch_id, limit):
|
||||
else:
|
||||
cur.execute(f"""
|
||||
SELECT i.id, i.part_number, i.name, i.image_url,
|
||||
i.price_1, COALESCE(SUM(s.stock), 0) as stock
|
||||
i.price_1, i.brand, COALESCE(SUM(s.stock), 0) as stock
|
||||
FROM inventory i
|
||||
JOIN inventory_vehicle_compat ivc ON ivc.inventory_id = i.id
|
||||
LEFT JOIN inventory_stock_summary s ON s.inventory_id = i.id
|
||||
@@ -2145,7 +2510,7 @@ def _search_local_inventory(tenant_conn, q, mye_id, branch_id, limit):
|
||||
AND i.is_active = true
|
||||
AND (REPLACE(UPPER(i.part_number), ' ', '') LIKE %s
|
||||
OR {_SQL_UNACCENT} LIKE %s)
|
||||
GROUP BY i.id, i.part_number, i.name, i.image_url, i.price_1
|
||||
GROUP BY i.id, i.part_number, i.name, i.image_url, i.price_1, i.brand
|
||||
ORDER BY i.name
|
||||
LIMIT %s
|
||||
""", (mye_id, f'%{clean_q}%', f'%{_q_unaccent}%', limit))
|
||||
@@ -2154,7 +2519,7 @@ def _search_local_inventory(tenant_conn, q, mye_id, branch_id, limit):
|
||||
if branch_id:
|
||||
cur.execute(f"""
|
||||
SELECT i.id, i.part_number, i.name, i.image_url,
|
||||
i.price_1, COALESCE(s.stock, 0) as stock
|
||||
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
|
||||
@@ -2167,30 +2532,32 @@ def _search_local_inventory(tenant_conn, q, mye_id, branch_id, limit):
|
||||
else:
|
||||
cur.execute(f"""
|
||||
SELECT i.id, i.part_number, i.name, i.image_url,
|
||||
i.price_1, COALESCE(SUM(s.stock), 0) as stock
|
||||
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 (REPLACE(UPPER(i.part_number), ' ', '') LIKE %s
|
||||
OR {_SQL_UNACCENT} LIKE %s)
|
||||
GROUP BY i.id, i.part_number, i.name, i.image_url, i.price_1
|
||||
GROUP BY i.id, i.part_number, i.name, i.image_url, i.price_1, i.brand
|
||||
ORDER BY i.name
|
||||
LIMIT %s
|
||||
""", (f'%{clean_q}%', f'%{_q_unaccent}%', limit))
|
||||
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
return [
|
||||
results = [
|
||||
{
|
||||
'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,
|
||||
'stock': int(r[5]) if r[5] is not None else 0,
|
||||
'brand': r[5],
|
||||
'stock': int(r[6]) if r[6] is not None else 0,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
return _augment_with_aliases(tenant_conn, results, q, mye_id, branch_id, limit)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -2332,11 +2699,50 @@ def get_bodega_availability(master_conn, part_id):
|
||||
]
|
||||
|
||||
|
||||
def get_alternatives(master_conn, part_id):
|
||||
"""Public wrapper: get cross-references + aftermarket parts for a given OEM part."""
|
||||
def get_alternatives(master_conn, catalog_id=None, part_number=None, name=None,
|
||||
category=None, tenant_conn=None, branch_id=None, limit=20,
|
||||
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()
|
||||
results = _get_alternatives(cur, part_id)
|
||||
cur.close()
|
||||
try:
|
||||
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()
|
||||
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -63,6 +63,28 @@ def _format_rate(val):
|
||||
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):
|
||||
"""Create a subelement with the given namespace."""
|
||||
elem = etree.SubElement(parent, f'{{{ns}}}{tag}')
|
||||
@@ -83,6 +105,9 @@ def build_ingreso_xml(sale, tenant_config, customer=None):
|
||||
items: [{part_number, name, quantity, unit_price, discount_amount,
|
||||
tax_rate, tax_amount, subtotal,
|
||||
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:
|
||||
rfc, razon_social, regimen_fiscal, cp (codigo postal),
|
||||
serie (optional), nombre_comercial (optional)
|
||||
@@ -175,10 +200,10 @@ def build_ingreso_xml(sale, tenant_config, customer=None):
|
||||
base = (importe - discount_amount).quantize(TWO, ROUND_HALF_UP)
|
||||
|
||||
concepto = _make_element(conceptos, 'Concepto')
|
||||
concepto.set('ClaveProdServ', item.get('clave_prod_serv') or '25174800')
|
||||
concepto.set('ClaveProdServ', _item_sat_keys(item)[0])
|
||||
concepto.set('NoIdentificacion', item.get('part_number') or '')
|
||||
concepto.set('Cantidad', str(qty))
|
||||
concepto.set('ClaveUnidad', item.get('clave_unidad') or 'H87')
|
||||
concepto.set('ClaveUnidad', _item_sat_keys(item)[1])
|
||||
concepto.set('Unidad', 'PZA')
|
||||
concepto.set('Descripcion', item.get('name') or 'Autoparte')
|
||||
concepto.set('ValorUnitario', _format_amount(unit_price))
|
||||
@@ -301,10 +326,10 @@ def build_egreso_xml(sale, tenant_config, customer, original_uuid):
|
||||
base = (importe - discount_amount).quantize(TWO, ROUND_HALF_UP)
|
||||
|
||||
concepto = _make_element(conceptos, 'Concepto')
|
||||
concepto.set('ClaveProdServ', item.get('clave_prod_serv') or '25174800')
|
||||
concepto.set('ClaveProdServ', _item_sat_keys(item)[0])
|
||||
concepto.set('NoIdentificacion', item.get('part_number') or '')
|
||||
concepto.set('Cantidad', str(qty))
|
||||
concepto.set('ClaveUnidad', item.get('clave_unidad') or 'H87')
|
||||
concepto.set('ClaveUnidad', _item_sat_keys(item)[1])
|
||||
concepto.set('Unidad', 'PZA')
|
||||
concepto.set('Descripcion', item.get('name') or 'Autoparte')
|
||||
concepto.set('ValorUnitario', _format_amount(unit_price))
|
||||
@@ -556,10 +581,10 @@ def build_global_invoice_xml(sales, tenant_config, year, month):
|
||||
base = (importe - discount_amount).quantize(TWO, ROUND_HALF_UP)
|
||||
|
||||
concepto = _make_element(conceptos, 'Concepto')
|
||||
concepto.set('ClaveProdServ', item.get('clave_prod_serv') or '25174800')
|
||||
concepto.set('ClaveProdServ', _item_sat_keys(item)[0])
|
||||
concepto.set('NoIdentificacion', item.get('part_number') or str(sale['id']))
|
||||
concepto.set('Cantidad', str(qty))
|
||||
concepto.set('ClaveUnidad', item.get('clave_unidad') or 'H87')
|
||||
concepto.set('ClaveUnidad', _item_sat_keys(item)[1])
|
||||
concepto.set('Unidad', 'PZA')
|
||||
concepto.set('Descripcion', item.get('name') or 'Autoparte')
|
||||
concepto.set('ValorUnitario', _format_amount(unit_price))
|
||||
|
||||
@@ -69,6 +69,27 @@ def _resolve_metodo_pago(sale):
|
||||
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):
|
||||
items = []
|
||||
for item in sale_items or []:
|
||||
@@ -76,12 +97,13 @@ def _build_items(sale_items):
|
||||
unit_price = _to_dec(item.get("unit_price", 0))
|
||||
discount = _to_dec(item.get("discount_amount", 0))
|
||||
tax_rate = _to_dec(item.get("tax_rate", "0.16"))
|
||||
product_key, unit_key = _item_sat_keys(item)
|
||||
|
||||
# Facturapi price is unit price before taxes and discounts
|
||||
product = {
|
||||
"description": item.get("name") or "Autoparte",
|
||||
"product_key": item.get("clave_prod_serv") or "25174800",
|
||||
"unit_key": item.get("clave_unidad") or "H87",
|
||||
"product_key": product_key,
|
||||
"unit_key": unit_key,
|
||||
"unit_name": "Pieza",
|
||||
"price": _fmt2(unit_price),
|
||||
"tax_included": False,
|
||||
@@ -136,65 +158,91 @@ def build_ingreso_payload(sale, tenant_config, customer=None):
|
||||
"folio_number": sale["id"],
|
||||
}
|
||||
|
||||
# Optional exchange rate for USD
|
||||
if sale.get("currency") and sale["currency"] != "MXN" and sale.get("exchange_rate"):
|
||||
payload["exchange"] = _fmt6(sale["exchange_rate"])
|
||||
payload["currency"] = sale["currency"]
|
||||
# Optional currency/exchange rate for non-MXN sales
|
||||
currency = (sale.get("currency") or "MXN").upper()
|
||||
if currency != "MXN":
|
||||
payload["currency"] = currency
|
||||
if sale.get("exchange_rate"):
|
||||
payload["exchange"] = _fmt6(sale["exchange_rate"])
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
def build_egreso_payload(sale, tenant_config, customer, original_uuid):
|
||||
def build_egreso_payload(sale, tenant_config, customer, original_uuid, folio_number=None, idempotency_key=None):
|
||||
"""Build Facturapi payload for a credit note (Comprobante tipo Egreso)."""
|
||||
payload = build_ingreso_payload(sale, tenant_config, customer)
|
||||
payload["type"] = "E"
|
||||
payload["related_documents"] = [{"relationship": "01", "documents": [original_uuid]}]
|
||||
payload["use"] = "G02" # Devoluciones, descuentos o bonificaciones
|
||||
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
|
||||
|
||||
|
||||
def build_pago_payload(payment, tenant_config, customer, original_uuid):
|
||||
"""Build Facturapi payload for a payment complement (Comprobante tipo Pago)."""
|
||||
def _normalize_payment_date(value):
|
||||
"""Return the ISO8601 datetime string Facturapi expects for a payment."""
|
||||
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")
|
||||
customer_payload = _build_customer_payload(customer, tenant_cp)
|
||||
|
||||
amount = _to_dec(payment.get("amount", 0))
|
||||
base = (amount / Decimal("1.16")).quantize(TWO, ROUND_HALF_UP)
|
||||
|
||||
payment_date = payment.get("date") or datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
|
||||
if "T" not in str(payment_date):
|
||||
payment_date = f"{payment_date}T12:00:00"
|
||||
rate = _to_dec(payment.get("tax_rate") or "0.16") or Decimal("0.16")
|
||||
base = (amount / (Decimal("1") + rate)).quantize(TWO, ROUND_HALF_UP)
|
||||
|
||||
forma_pago = FORMA_PAGO_MAP.get((payment.get("payment_method") or "").lower().strip(), "01")
|
||||
|
||||
payload = {
|
||||
"type": "P",
|
||||
"customer": customer_payload,
|
||||
"payment_form": forma_pago,
|
||||
"complements": [
|
||||
{
|
||||
"type": "pago",
|
||||
"data": {
|
||||
"payment_form": forma_pago,
|
||||
"payment_date": payment_date,
|
||||
"amount": _fmt2(amount),
|
||||
"related_documents": [
|
||||
{
|
||||
"uuid": original_uuid,
|
||||
"amount": _fmt2(amount),
|
||||
"taxes": [
|
||||
{
|
||||
"type": "IVA",
|
||||
"rate": 0.16,
|
||||
"factor": "Tasa",
|
||||
"base": _fmt2(base),
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
},
|
||||
"data": [
|
||||
{
|
||||
"payment_form": forma_pago,
|
||||
"date": _normalize_payment_date(payment.get("date")),
|
||||
"related_documents": [
|
||||
{
|
||||
"uuid": original_uuid,
|
||||
"amount": _fmt2(amount),
|
||||
"installment": int(payment.get("installment") or 1),
|
||||
"last_balance": _fmt2(payment.get("last_balance") or 0),
|
||||
"taxes": [
|
||||
{
|
||||
"base": _fmt2(base),
|
||||
"type": "IVA",
|
||||
"rate": _fmt6(rate),
|
||||
"factor": "Tasa",
|
||||
"withholding": False,
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
if idempotency_key:
|
||||
payload["idempotency_key"] = idempotency_key
|
||||
return payload
|
||||
|
||||
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
|
||||
Flow:
|
||||
1. enqueue_cfdi() — inserts Facturapi JSON payload into cfdi_queue with status='pending'
|
||||
2. process_queue() — sends pending items to Facturapi, updates status
|
||||
3. retry_failed() — retries failed items with exponential backoff
|
||||
4. cancel_cfdi() — cancels a stamped CFDI via Facturapi
|
||||
2. enqueue_pending_complements() — enqueues the missing payment complements
|
||||
(CFDI type "P") of PPD invoices and the credit notes (type "E") of returns
|
||||
3. process_queue() — sends pending items to Facturapi, updates status
|
||||
4. retry_failed() — retries failed items with exponential backoff
|
||||
5. cancel_cfdi() — cancels a stamped CFDI via Facturapi
|
||||
|
||||
Facturapi endpoints used:
|
||||
POST /v2/invoices — create and stamp an invoice
|
||||
@@ -17,9 +19,11 @@ Retry backoff: 5s, 30s, 2m, 10m, 1h (max 5 retries)
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
|
||||
from services import facturapi_service
|
||||
from services.cfdi_facturapi_builder import build_egreso_payload, build_pago_payload
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -27,6 +31,64 @@ logger = logging.getLogger(__name__)
|
||||
BACKOFF_INTERVALS = [5, 30, 120, 600, 3600]
|
||||
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):
|
||||
"""Generate a provisional folio like PRE-00001.
|
||||
@@ -81,6 +143,340 @@ 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):
|
||||
"""Process all pending CFDI items in the queue.
|
||||
|
||||
@@ -96,6 +492,15 @@ def process_queue(conn, tenant_config, dry_run=False):
|
||||
Returns:
|
||||
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.execute(
|
||||
@@ -164,10 +569,17 @@ def process_queue(conn, tenant_config, dry_run=False):
|
||||
error_message = NULL
|
||||
WHERE id = %s
|
||||
""",
|
||||
(xml_signed_str, uuid_fiscal, invoice_id, cfdi_id),
|
||||
(xml_signed_str, uuid_fiscal, invoice_id, cfdi_id),
|
||||
)
|
||||
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["details"].append(
|
||||
{
|
||||
|
||||
463
pos/services/credit_scoring.py
Normal file
463
pos/services/credit_scoring.py
Normal file
@@ -0,0 +1,463 @@
|
||||
"""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(),
|
||||
}
|
||||
@@ -7,7 +7,16 @@ Provides:
|
||||
- Customer analytics (LTV, frequency, churn risk)
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
|
||||
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 ─────────────────────────────
|
||||
@@ -302,11 +311,15 @@ def get_customer_analytics(conn, customer_id):
|
||||
WHERE customer_id = %s AND status = 'completed'
|
||||
""", (customer_id,))
|
||||
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 = None
|
||||
if last_purchase:
|
||||
days_since_last = (datetime.utcnow() - last_purchase).days
|
||||
days_since_last = (now - last_purchase).days
|
||||
|
||||
# Purchase frequency (orders per month)
|
||||
frequency = 0.0
|
||||
@@ -314,13 +327,69 @@ def get_customer_analytics(conn, customer_id):
|
||||
months = max(1, (last_purchase - first_purchase).days / 30.0)
|
||||
frequency = round(total_orders / months, 2)
|
||||
|
||||
# Churn risk: no purchase in 90+ days = high, 60-90 = medium, <60 = low
|
||||
churn_risk = 'low'
|
||||
if days_since_last is not None:
|
||||
if days_since_last > 90:
|
||||
# Real inter-purchase interval per customer (A12). Uses the gaps between
|
||||
# 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_probability = 0.15
|
||||
churn_reason = f"Dentro de su intervalo habitual de compra ({avg_interval:.0f} días)."
|
||||
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_probability = 0.75
|
||||
churn_reason = f"Sin compras desde hace {days_since_last} días."
|
||||
elif days_since_last > 60:
|
||||
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)
|
||||
cur.execute("""
|
||||
@@ -362,6 +431,15 @@ def get_customer_analytics(conn, customer_id):
|
||||
'days_since_last_purchase': days_since_last,
|
||||
'purchase_frequency_monthly': frequency,
|
||||
'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,
|
||||
'loyalty': {
|
||||
'points_balance': loyalty_balance,
|
||||
|
||||
233
pos/services/cross_sell.py
Normal file
233
pos/services/cross_sell.py
Normal file
@@ -0,0 +1,233 @@
|
||||
"""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)]
|
||||
@@ -108,6 +108,18 @@ def _get_user_key() -> str | 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:
|
||||
return bool(_get_user_key()) and not _get_secret_key(tenant_config)
|
||||
|
||||
@@ -138,7 +150,9 @@ 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:
|
||||
"""Upload CSD (Certificado de Sello Digital) to Facturapi.
|
||||
|
||||
cer_b64 and key_b64 are base64-encoded strings.
|
||||
cer_b64 and key_b64 are base64-encoded strings. Uses the documented
|
||||
PUT /organizations/{id}/certificate endpoint with the `cer`, `key` and
|
||||
`password` multipart fields.
|
||||
"""
|
||||
api_key = get_api_key(tenant_config)
|
||||
org_id = _get_org_id(tenant_config)
|
||||
@@ -150,11 +164,11 @@ def upload_csd(tenant_config: dict, cer_b64: str, key_b64: str, password: str) -
|
||||
|
||||
url = f"{BASE_URL}/organizations/{org_id}/certificate"
|
||||
files = {
|
||||
"certificate": ("certificate.cer", cer_bytes, "application/octet-stream"),
|
||||
"private_key": ("private_key.key", key_bytes, "application/octet-stream"),
|
||||
"secret": (None, password),
|
||||
"cer": ("certificate.cer", cer_bytes, "application/octet-stream"),
|
||||
"key": ("private_key.key", key_bytes, "application/octet-stream"),
|
||||
"password": (None, password),
|
||||
}
|
||||
resp = requests.post(url, auth=(api_key, ""), files=files, timeout=60)
|
||||
resp = requests.put(url, auth=(api_key, ""), files=files, timeout=60)
|
||||
if not resp.ok:
|
||||
raise FacturapiError(
|
||||
f"CSD upload failed: {resp.status_code} {resp.text[:500]}",
|
||||
@@ -170,13 +184,9 @@ def _get_user_key_for_tenant(tenant_config: dict) -> str:
|
||||
1. FACTURAPI_USER_KEY environment variable
|
||||
2. tenant_config.facturapi_key if it starts with sk_user_
|
||||
"""
|
||||
user_key = _get_user_key()
|
||||
user_key = _find_user_key(tenant_config)
|
||||
if user_key:
|
||||
return user_key
|
||||
for key in ("facturapi_key", "cfdi_facturapi_key"):
|
||||
tenant_key = (tenant_config.get(key) or "").strip()
|
||||
if tenant_key.startswith("sk_user_"):
|
||||
return tenant_key
|
||||
raise FacturapiError("FACTURAPI_USER_KEY env or a Facturapi user key (sk_user_*) is required")
|
||||
|
||||
|
||||
@@ -347,7 +357,7 @@ def get_org_status(tenant_config: dict) -> dict:
|
||||
}
|
||||
|
||||
has_secret = bool(_get_secret_key(tenant_config))
|
||||
has_user = bool(_get_user_key_for_tenant(tenant_config))
|
||||
has_user = bool(_find_user_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
|
||||
@@ -366,10 +376,10 @@ def get_org_status(tenant_config: dict) -> dict:
|
||||
result["error"] = "No Facturapi key available"
|
||||
return result
|
||||
|
||||
def _fetch():
|
||||
org = get_organization(org_id, api_key)
|
||||
legal = org.get("legal", {})
|
||||
cert = org.get("certificate", {})
|
||||
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,
|
||||
"has_csd": bool(cert.get("has_certificate")),
|
||||
@@ -383,10 +393,11 @@ def get_org_status(tenant_config: dict) -> dict:
|
||||
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.
|
||||
if e.status_code == 401 and _get_user_key_for_tenant(tenant_config):
|
||||
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())
|
||||
result.update(_fetch(user_key))
|
||||
except FacturapiError as e2:
|
||||
result["error"] = str(e2)
|
||||
else:
|
||||
|
||||
@@ -35,194 +35,93 @@ def get_compat_source(tenant_id):
|
||||
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,
|
||||
brand=None, name=None):
|
||||
"""Find vehicle compatibility for an inventory item by part_number.
|
||||
"""Find vehicle compatibility for an inventory item via the supplier catalog.
|
||||
|
||||
Searches (in order of precision):
|
||||
1. parts.oem_part_number exact normalized
|
||||
2. aftermarket_parts.part_number exact normalized
|
||||
3. part_cross_references.cross_reference_number exact normalized
|
||||
4. parts.oem_part_number partial (ILIKE) — strips separators too
|
||||
5. aftermarket_parts.part_number partial (ILIKE)
|
||||
6. part_cross_references.cross_reference_number partial (ILIKE)
|
||||
7. parts.name_part / parts.name_es by inventory item name (ILIKE)
|
||||
8. parts.name_part / parts.name_es by inventory item name (tsvector)
|
||||
Legacy note: the original implementation queried the removed TecDoc tables
|
||||
(parts / aftermarket_parts / part_cross_references / vehicle_parts) and
|
||||
always failed. This version uses the LIVE master tables:
|
||||
1. supplier_catalog.sku exact-normalized
|
||||
2. supplier_catalog_interchange.part_number exact-normalized (cross-refs)
|
||||
3. prefix fallback on catalog SKU (only for >=5 char numbers, few matches)
|
||||
then collects DISTINCT model_year_engine_id from supplier_catalog_compat
|
||||
and inserts them into the tenant inventory_vehicle_compat table.
|
||||
|
||||
If a brand hint is provided, results are preferentially filtered to
|
||||
vehicles of that brand.
|
||||
|
||||
Returns:
|
||||
dict: {'matched': bool, 'matches': [...], 'myes': [...]}
|
||||
Returns dict {'matched': bool, 'matches': [...], 'myes': [...], 'inserted': int}
|
||||
(same shape as the legacy implementation).
|
||||
"""
|
||||
cur = master_conn.cursor()
|
||||
oem_ids = set()
|
||||
clean_pn = _norm_pn(part_number)
|
||||
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:
|
||||
# 1. Exact match on catalog SKU (with and without inner spaces)
|
||||
cur.execute("""
|
||||
SELECT id_part FROM parts
|
||||
WHERE REPLACE(UPPER(oem_part_number), ' ', '') = %s
|
||||
""", (clean_pn,))
|
||||
SELECT id FROM supplier_catalog
|
||||
WHERE REPLACE(UPPER(sku), '-', '') = %s
|
||||
OR REPLACE(UPPER(REPLACE(sku, ' ', '')), '-', '') = %s
|
||||
LIMIT 50
|
||||
""", (clean_pn, clean_pn))
|
||||
for r in cur.fetchall():
|
||||
oem_ids.add(r[0])
|
||||
catalog_ids.add(r[0])
|
||||
|
||||
if clean_pn and not oem_ids:
|
||||
cur.execute("""
|
||||
SELECT DISTINCT oem_part_id FROM aftermarket_parts
|
||||
WHERE REPLACE(UPPER(part_number), ' ', '') = %s
|
||||
""", (clean_pn,))
|
||||
for r in cur.fetchall():
|
||||
if r[0]:
|
||||
oem_ids.add(r[0])
|
||||
|
||||
if clean_pn and not oem_ids:
|
||||
cur.execute("""
|
||||
SELECT DISTINCT part_id FROM part_cross_references
|
||||
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:
|
||||
# 2. Exact match on interchange part numbers (cross references)
|
||||
if not catalog_ids:
|
||||
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]))
|
||||
SELECT DISTINCT catalog_id FROM supplier_catalog_interchange
|
||||
WHERE REPLACE(UPPER(REPLACE(part_number, '-', '')), ' ', '') = %s
|
||||
LIMIT 50
|
||||
""", (clean_pn,))
|
||||
for r in cur.fetchall():
|
||||
if r[0]:
|
||||
oem_ids.add(r[0])
|
||||
catalog_ids.add(r[0])
|
||||
|
||||
# Cross-reference partial
|
||||
if len(oem_ids) < 10:
|
||||
# 3. Prefix fallback for longer numbers when exact found little
|
||||
if len(clean_pn) >= 5 and len(catalog_ids) < 3:
|
||||
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[]))
|
||||
SELECT id FROM supplier_catalog
|
||||
WHERE REPLACE(UPPER(sku), '-', '') LIKE %s
|
||||
LIMIT 10
|
||||
""", (no_seps, list(oem_ids) or [0]))
|
||||
""", (clean_pn[:8] + '%',))
|
||||
for r in cur.fetchall():
|
||||
oem_ids.add(r[0])
|
||||
catalog_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
|
||||
mye_ids = []
|
||||
if catalog_ids:
|
||||
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
|
||||
SELECT DISTINCT model_year_engine_id FROM supplier_catalog_compat
|
||||
WHERE catalog_id = ANY(%s) AND model_year_engine_id IS NOT NULL
|
||||
LIMIT 500
|
||||
""", (oem_ids, brand_hint))
|
||||
""", (list(catalog_ids),))
|
||||
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()
|
||||
|
||||
# ── Insert into tenant table ─────────────────────────────────────────
|
||||
inserted = 0
|
||||
cur2 = tenant_conn.cursor()
|
||||
for mye_id in mye_ids:
|
||||
cur2.execute("""
|
||||
INSERT INTO inventory_vehicle_compat
|
||||
(inventory_id, model_year_engine_id, source, confidence)
|
||||
VALUES (%s, %s, 'auto_match', 1.0)
|
||||
ON CONFLICT (inventory_id, model_year_engine_id) DO NOTHING
|
||||
""", (inventory_id, mye_id))
|
||||
if cur2.rowcount > 0:
|
||||
inserted += 1
|
||||
tenant_conn.commit()
|
||||
cur2.close()
|
||||
if mye_ids:
|
||||
cur2 = tenant_conn.cursor()
|
||||
for mye_id in mye_ids:
|
||||
cur2.execute("""
|
||||
INSERT INTO inventory_vehicle_compat
|
||||
(inventory_id, model_year_engine_id, source, confidence)
|
||||
VALUES (%s, %s, 'supplier_catalog', 0.95)
|
||||
ON CONFLICT (inventory_id, model_year_engine_id) DO NOTHING
|
||||
""", (inventory_id, mye_id))
|
||||
if cur2.rowcount > 0:
|
||||
inserted += 1
|
||||
tenant_conn.commit()
|
||||
cur2.close()
|
||||
|
||||
return {
|
||||
'matched': len(mye_ids) > 0,
|
||||
'matches': oem_ids,
|
||||
'matches': sorted(catalog_ids),
|
||||
'myes': mye_ids,
|
||||
'inserted': inserted,
|
||||
}
|
||||
|
||||
474
pos/services/invoice_ocr.py
Normal file
474
pos/services/invoice_ocr.py
Normal file
@@ -0,0 +1,474 @@
|
||||
# /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],
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -40,6 +40,139 @@ INDEX_SETTINGS = {
|
||||
'typoTolerance': {'enabled': True, 'minWordSizeForTypos': {'oneTypo': 4, 'twoTypos': 8}},
|
||||
}
|
||||
|
||||
# ─── Synonyms (A6) ──────────────────────────────────────────────────────────
|
||||
# Spanish/English aftermarket equivalents so that "brake pad" finds "BALATA"
|
||||
# and vice-versa. Built from a curated core plus the existing bilingual
|
||||
# taxonomy (services.translations.PART_TRANSLATIONS). Multi-word synonyms are
|
||||
# supported by Meilisearch.
|
||||
CURATED_SYNONYMS = [
|
||||
('balata', 'pastilla de freno', 'brake pad', 'brake pads'),
|
||||
('disco de freno', 'disco', 'brake disc', 'brake rotor'),
|
||||
('bujia', 'bujía', 'spark plug', 'spark plugs'),
|
||||
('clutch', 'embrague', 'cloch', 'clochs'),
|
||||
('faro', 'faro delantero', 'headlight', 'headlamp'),
|
||||
('mofle', 'mofle', 'silenciador', 'muffler'),
|
||||
('amortiguador', 'shock absorber', 'strut'),
|
||||
('rotula', 'rótula', 'ball joint'),
|
||||
('terminal', 'terminal de direccion', 'tie rod end'),
|
||||
('banda', 'banda serpentina', 'belt', 'serpentine belt'),
|
||||
('banda de distribucion', 'timing belt'),
|
||||
('bomba de agua', 'water pump'),
|
||||
('radiador', 'radiator'),
|
||||
('alternador', 'alternator'),
|
||||
('marcha', 'arrancador', 'starter', 'starter motor'),
|
||||
('bobina', 'bobina de encendido', 'ignition coil'),
|
||||
('bateria', 'batería', 'battery'),
|
||||
('filtro de aire', 'air filter'),
|
||||
('filtro de aceite', 'oil filter'),
|
||||
('filtro de gasolina', 'fuel filter'),
|
||||
('sensor', 'sensor'),
|
||||
('collarín', 'collarín de clutch', 'throw out bearing', 'release bearing'),
|
||||
('bomba de gasolina', 'fuel pump'),
|
||||
('foco', 'foco', 'bulb', 'lamp'),
|
||||
('cristal', 'cristal', 'glass', 'windshield'),
|
||||
('parabrisas', 'parabrisas', 'windshield'),
|
||||
('espejo', 'espejo lateral', 'mirror', 'side mirror'),
|
||||
('abanico', 'ventilador', 'cooling fan', 'fan'),
|
||||
('compresor', 'compresor de aire', 'a/c compressor', 'ac compressor'),
|
||||
('valvula', 'válvula', 'valve'),
|
||||
('junta', 'empaque', 'gasket'),
|
||||
('manguera', 'hose'),
|
||||
]
|
||||
|
||||
|
||||
def _strip_accents(text):
|
||||
if not text:
|
||||
return ''
|
||||
out = text
|
||||
for a, b in (('Á', 'A'), ('É', 'E'), ('Í', 'I'), ('Ó', 'O'), ('Ú', 'U'),
|
||||
('À', 'A'), ('È', 'E'), ('Ì', 'I'), ('Ò', 'O'), ('Ù', 'U'),
|
||||
('Ä', 'A'), ('Ë', 'E'), ('Ï', 'I'), ('Ö', 'O'), ('Ü', 'U'), ('Ñ', 'N')):
|
||||
out = out.replace(a, b)
|
||||
return out
|
||||
|
||||
|
||||
def _symmetrize(groups):
|
||||
"""{term: [other equivalent terms]} from groups of equivalent terms."""
|
||||
synonyms = {}
|
||||
for group in groups:
|
||||
terms = []
|
||||
for term in group:
|
||||
t = (term or '').strip().lower()
|
||||
if t and t not in terms:
|
||||
terms.append(t)
|
||||
for term in terms:
|
||||
others = [t for t in terms if t != term]
|
||||
synonyms.setdefault(term, [])
|
||||
for o in others:
|
||||
if o not in synonyms[term]:
|
||||
synonyms[term].append(o)
|
||||
# Accent-insensitive variant so "bujia" matches "bujía".
|
||||
plain = _strip_accents(term)
|
||||
if plain and plain != term:
|
||||
synonyms.setdefault(plain, [])
|
||||
for o in others:
|
||||
if o not in synonyms[plain]:
|
||||
synonyms[plain].append(o)
|
||||
return synonyms
|
||||
|
||||
|
||||
def build_synonyms(include_taxonomy=True):
|
||||
"""Build the synonym map for the search indexes.
|
||||
|
||||
Curated colloquial Mexican terms first, then the curated bilingual
|
||||
taxonomy in ``services.translations.PART_TRANSLATIONS`` (English → Spanish).
|
||||
"""
|
||||
groups = list(CURATED_SYNONYMS)
|
||||
if include_taxonomy:
|
||||
try:
|
||||
from services.translations import PART_TRANSLATIONS
|
||||
for en, es in PART_TRANSLATIONS.items():
|
||||
en, es = (en or '').strip(), (es or '').strip()
|
||||
if not en or not es or '/' in es:
|
||||
continue
|
||||
if len(en) > 40 or len(es) > 40:
|
||||
continue
|
||||
if en.lower() == es.lower():
|
||||
continue
|
||||
groups.append((en.lower(), es.lower()))
|
||||
except Exception:
|
||||
pass
|
||||
return _symmetrize(groups)
|
||||
|
||||
|
||||
_SYNONYMS_CACHE = None
|
||||
|
||||
|
||||
def get_synonyms():
|
||||
"""Cached synonym map (built once per process)."""
|
||||
global _SYNONYMS_CACHE
|
||||
if _SYNONYMS_CACHE is None:
|
||||
_SYNONYMS_CACHE = build_synonyms()
|
||||
return _SYNONYMS_CACHE
|
||||
|
||||
|
||||
# Per-index settings used by scripts/index_meilisearch.py. ``aliases`` holds
|
||||
# inventory_sku_aliases / interchange numbers for cross-reference search.
|
||||
INVENTORY_SEARCHABLE_ATTRIBUTES = ['part_number', 'name', 'brand', 'aliases']
|
||||
CATALOG_SEARCHABLE_ATTRIBUTES = ['sku', 'name', 'category', 'supplier_name', 'aliases']
|
||||
|
||||
|
||||
def inventory_index_settings():
|
||||
return {
|
||||
'searchableAttributes': list(INVENTORY_SEARCHABLE_ATTRIBUTES),
|
||||
'filterableAttributes': ['brand'],
|
||||
'synonyms': get_synonyms(),
|
||||
}
|
||||
|
||||
|
||||
def catalog_index_settings():
|
||||
return {
|
||||
'searchableAttributes': list(CATALOG_SEARCHABLE_ATTRIBUTES),
|
||||
'filterableAttributes': ['category', 'supplier_name'],
|
||||
'synonyms': get_synonyms(),
|
||||
}
|
||||
|
||||
_client = None
|
||||
_client_url = None
|
||||
|
||||
@@ -124,6 +257,51 @@ def search_parts(query, limit=50, offset=0):
|
||||
return None
|
||||
|
||||
|
||||
def is_enabled():
|
||||
"""True si MEILI_ENABLED está activo (se lee en cada llamada)."""
|
||||
return os.environ.get('MEILI_ENABLED', 'true').lower() == 'true'
|
||||
|
||||
|
||||
def search_index(index_uid, query, options=None):
|
||||
"""Búsqueda genérica en cualquier índice.
|
||||
|
||||
Returns:
|
||||
dict con la respuesta de Meilisearch, o None si Meilisearch está
|
||||
apagado, sin API key, o falla — el caller debe usar su fallback SQL.
|
||||
"""
|
||||
if not is_enabled():
|
||||
return None
|
||||
try:
|
||||
return get_client().index(index_uid).search(query, options or {})
|
||||
except MeilisearchApiError as e:
|
||||
# Índice inexistente (tenant sin reindexar) es esperado: silencio.
|
||||
if e.code != 'index_not_found':
|
||||
print(f"[meili_search] Search error on {index_uid}: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"[meili_search] Search error on {index_uid}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def inventory_index_name(db_name):
|
||||
"""Índice de inventario de un tenant (ver scripts/index_meilisearch.py)."""
|
||||
return f"nexus_inventory_{db_name}"
|
||||
|
||||
|
||||
def search_inventory(db_name, query, limit=50, offset=0):
|
||||
"""Busca en el índice de inventario de un tenant.
|
||||
|
||||
Returns:
|
||||
list de hits (posiblemente vacía) o None si Meilisearch no está
|
||||
disponible o el índice no existe → el caller debe usar SQL.
|
||||
"""
|
||||
res = search_index(inventory_index_name(db_name), query,
|
||||
{'limit': limit, 'offset': offset})
|
||||
if res is None:
|
||||
return None
|
||||
return res.get('hits', [])
|
||||
|
||||
|
||||
def add_part(part_doc):
|
||||
"""Add or update a single part document."""
|
||||
try:
|
||||
@@ -157,3 +335,56 @@ def clear_index():
|
||||
except Exception as e:
|
||||
print(f"[meili_search] Clear error: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# ─── A6: settings / synonym refresh ─────────────────────────────────────────
|
||||
|
||||
def _index_uids(prefix=None):
|
||||
"""UIDs of existing indexes (optionally filtered by prefix)."""
|
||||
client = get_client()
|
||||
try:
|
||||
result = client.get_indexes()
|
||||
except TypeError: # older meilisearch-python returns a dict
|
||||
result = client.get_indexes()
|
||||
if isinstance(result, dict):
|
||||
results = result.get('results', [])
|
||||
else:
|
||||
results = getattr(result, 'results', result)
|
||||
uids = []
|
||||
for item in results:
|
||||
uid = item.get('uid') if isinstance(item, dict) else getattr(item, 'uid', None)
|
||||
if uid and (prefix is None or uid.startswith(prefix)):
|
||||
uids.append(uid)
|
||||
return uids
|
||||
|
||||
|
||||
def apply_index_settings(index_uid, settings=None):
|
||||
"""Apply searchable attributes + synonyms to one index (no reindex)."""
|
||||
if settings is None:
|
||||
if index_uid == 'nexus_supplier_catalog':
|
||||
settings = catalog_index_settings()
|
||||
elif index_uid.startswith('nexus_inventory_'):
|
||||
settings = inventory_index_settings()
|
||||
else:
|
||||
settings = {'synonyms': get_synonyms()}
|
||||
task = get_client().index(index_uid).update_settings(settings)
|
||||
return task
|
||||
|
||||
|
||||
def apply_synonyms_to_all(prefixes=('nexus_inventory_', 'nexus_supplier_catalog')):
|
||||
"""Push the synonym map to every inventory/catalog index.
|
||||
|
||||
Updating ``synonyms`` does not require reindexing documents, so this is
|
||||
safe to run against a live Meilisearch. Returns {index_uid: ok}.
|
||||
"""
|
||||
results = {}
|
||||
for prefix in prefixes:
|
||||
uids = _index_uids(prefix)
|
||||
for uid in uids:
|
||||
try:
|
||||
apply_index_settings(uid)
|
||||
results[uid] = True
|
||||
except Exception as e:
|
||||
results[uid] = f"error: {e}"
|
||||
return results
|
||||
|
||||
|
||||
@@ -14,13 +14,22 @@ References:
|
||||
https://developers.mercadolibre.com.ar/es_ar/api-docs-es
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
import requests
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BASE_URL = "https://api.mercadolibre.com"
|
||||
AUTH_URL = "https://api.mercadolibre.com/oauth/token"
|
||||
|
||||
# MercadoLibre rate-limits per app/user with HTTP 429 (+ Retry-After). Retry
|
||||
# with exponential backoff before giving up so a burst of listing updates
|
||||
# doesn't fail the whole batch.
|
||||
RATE_LIMIT_MAX_RETRIES = 4
|
||||
RATE_LIMIT_MAX_SLEEP = 60.0
|
||||
|
||||
|
||||
class MeliError(Exception):
|
||||
def __init__(self, message, status_code=None, response_body=None):
|
||||
@@ -40,8 +49,10 @@ class MeliService:
|
||||
refresh_token: Optional[str] = None,
|
||||
client_id: Optional[str] = None,
|
||||
client_secret: Optional[str] = None,
|
||||
on_refresh=None,
|
||||
):
|
||||
self.access_token = access_token
|
||||
self.on_refresh = on_refresh
|
||||
self.refresh_token = refresh_token
|
||||
self.client_id = client_id
|
||||
self.client_secret = client_secret
|
||||
@@ -50,6 +61,21 @@ class MeliService:
|
||||
|
||||
# ─── Low-level request ───────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _rate_limit_sleep(resp, attempt: int) -> float:
|
||||
"""Seconds to wait before retrying a 429.
|
||||
|
||||
Honors the Retry-After header when present, otherwise exponential
|
||||
backoff (1, 2, 4, ...) capped at RATE_LIMIT_MAX_SLEEP.
|
||||
"""
|
||||
header = resp.headers.get("Retry-After")
|
||||
if header:
|
||||
try:
|
||||
return max(0.0, min(float(header), RATE_LIMIT_MAX_SLEEP))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return min(2.0 ** attempt, RATE_LIMIT_MAX_SLEEP)
|
||||
|
||||
def _request(
|
||||
self,
|
||||
method: str,
|
||||
@@ -57,11 +83,32 @@ class MeliService:
|
||||
params: Optional[dict] = None,
|
||||
json_payload: Optional[dict] = None,
|
||||
retry_on_401: bool = True,
|
||||
max_rate_limit_retries: int = RATE_LIMIT_MAX_RETRIES,
|
||||
) -> dict:
|
||||
url = f"{BASE_URL}{path}"
|
||||
resp = self._session.request(
|
||||
method, url, params=params, json=json_payload, timeout=30
|
||||
)
|
||||
|
||||
# 429 (rate limit) is retried with backoff; every other status is
|
||||
# handled below. The 401 refresh path only runs once per call.
|
||||
attempt = 0
|
||||
while True:
|
||||
resp = self._session.request(
|
||||
method, url, params=params, json=json_payload, timeout=30
|
||||
)
|
||||
if resp.status_code != 429:
|
||||
break
|
||||
if attempt >= max_rate_limit_retries:
|
||||
raise MeliError(
|
||||
f"Meli API rate limit exceeded (429) after {attempt} retries: {resp.text}",
|
||||
status_code=429,
|
||||
response_body=resp.text,
|
||||
)
|
||||
delay = self._rate_limit_sleep(resp, attempt)
|
||||
logger.warning(
|
||||
"Meli 429 on %s %s — retry %s/%s in %.1fs",
|
||||
method, path, attempt + 1, max_rate_limit_retries, delay,
|
||||
)
|
||||
time.sleep(delay)
|
||||
attempt += 1
|
||||
|
||||
if resp.status_code == 401 and retry_on_401 and self.refresh_token:
|
||||
self._refresh_token()
|
||||
@@ -106,15 +153,31 @@ class MeliService:
|
||||
}
|
||||
resp = requests.post(AUTH_URL, data=payload, timeout=30)
|
||||
if not resp.ok:
|
||||
body = (resp.text or '')
|
||||
# Credenciales de la app rechazadas: el client_secret cambió en el
|
||||
# dev center de MercadoLibre — hay que actualizarlo, no reconectar.
|
||||
if 'invalid_client' in body:
|
||||
raise MeliAuthError(
|
||||
"Las credenciales de la app de MercadoLibre (Client ID/Secret) fueron "
|
||||
"rechazadas. Verifica que el Client Secret en Configuración → MercadoLibre "
|
||||
"sea el vigente en el dev center (probablemente se regeneró).",
|
||||
status_code=resp.status_code, response_body=body,
|
||||
)
|
||||
raise MeliAuthError(
|
||||
f"Token refresh failed: {resp.status_code} {resp.text}",
|
||||
f"Token refresh failed: {resp.status_code} {body}",
|
||||
status_code=resp.status_code,
|
||||
response_body=resp.text,
|
||||
response_body=body,
|
||||
)
|
||||
data = resp.json()
|
||||
self.access_token = data["access_token"]
|
||||
if "refresh_token" in data:
|
||||
self.refresh_token = data["refresh_token"]
|
||||
# Persist rotated tokens so the connection survives restarts/rotation
|
||||
if self.on_refresh:
|
||||
try:
|
||||
self.on_refresh(data)
|
||||
except Exception:
|
||||
pass # never break the request because persistence failed
|
||||
return data
|
||||
|
||||
# ─── Auth / User ─────────────────────────────────────────────────────
|
||||
@@ -237,6 +300,35 @@ class MeliService:
|
||||
def close_item(self, item_id: str) -> dict:
|
||||
return self.update_item(item_id, {"status": "closed"})
|
||||
|
||||
# ─── Item description ────────────────────────────────────────────────
|
||||
#
|
||||
# ML keeps the description in a sub-resource: POST /items/{id}/description
|
||||
# creates it (a freshly published item has none) and PUT updates it.
|
||||
|
||||
def get_item_description(self, item_id: str) -> dict:
|
||||
return self._request("GET", f"/items/{item_id}/description")
|
||||
|
||||
def set_item_description(self, item_id: str, plain_text: str, create: bool = True) -> dict:
|
||||
"""Create (create=True) or update (create=False) an item's description.
|
||||
|
||||
Falls back to the opposite verb when ML answers "description already
|
||||
exists" (400/405/409) — the two endpoints are idempotent in practice.
|
||||
"""
|
||||
method = "POST" if create else "PUT"
|
||||
fallback = "PUT" if create else "POST"
|
||||
try:
|
||||
return self._request(
|
||||
method, f"/items/{item_id}/description",
|
||||
json_payload={"plain_text": plain_text},
|
||||
)
|
||||
except MeliError as e:
|
||||
if e.status_code in (400, 404, 405, 409):
|
||||
return self._request(
|
||||
fallback, f"/items/{item_id}/description",
|
||||
json_payload={"plain_text": plain_text},
|
||||
)
|
||||
raise
|
||||
|
||||
# ─── Questions & Answers ─────────────────────────────────────────────
|
||||
|
||||
def get_questions(self, item_id: str, status: str = None, offset: int = 0, limit: int = 50) -> dict:
|
||||
@@ -270,6 +362,14 @@ class MeliService:
|
||||
def get_category_attributes(self, category_id: str) -> list:
|
||||
return self._request("GET", f"/categories/{category_id}/attributes")
|
||||
|
||||
def get_listing_prices(self, site_id: str, price: float, category_id: str) -> list:
|
||||
"""MeLi fee calculator: returns fee details per listing type for a price/category."""
|
||||
return self._request(
|
||||
"GET",
|
||||
f"/sites/{site_id}/listing_prices",
|
||||
params={"price": price, "category_id": category_id},
|
||||
)
|
||||
|
||||
def get_shipping_preferences(self, user_id: str) -> dict:
|
||||
return self._request("GET", f"/users/{user_id}/shipping_preferences")
|
||||
|
||||
|
||||
@@ -244,7 +244,7 @@ def _send_push(conn, tenant_id, recipient_type, recipient_id, title, body, metad
|
||||
def _send_whatsapp(conn, tenant_id, recipient_type, recipient_id, body, metadata):
|
||||
"""Send WhatsApp notification via existing service."""
|
||||
try:
|
||||
from services.whatsapp_service import send_message
|
||||
from services.whatsapp_service import wa_send_text
|
||||
cur = conn.cursor()
|
||||
phone = None
|
||||
if recipient_type == 'customer' and recipient_id:
|
||||
@@ -255,7 +255,22 @@ def _send_whatsapp(conn, tenant_id, recipient_type, recipient_id, body, metadata
|
||||
cur.close()
|
||||
|
||||
if phone:
|
||||
send_message(phone, body)
|
||||
# Route via tenant provider (Meta Cloud API / WA CRM or Baileys)
|
||||
cfg = {}
|
||||
try:
|
||||
cur2 = conn.cursor()
|
||||
cur2.execute("SELECT key, value FROM tenant_config WHERE key LIKE 'whatsapp_%' OR key LIKE 'wa_%'")
|
||||
raw = {k: v for k, v in cur2.fetchall()}
|
||||
cur2.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', ''),
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
wa_send_text(cfg, phone, body)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
267
pos/services/part_vision.py
Normal file
267
pos/services/part_vision.py
Normal file
@@ -0,0 +1,267 @@
|
||||
# /home/Autopartes/pos/services/part_vision.py
|
||||
"""A4 — Vision grading for used/yonke parts.
|
||||
|
||||
Given a photo of a used part (plus optional metadata: name, category,
|
||||
declared condition) a VLM suggests a condition grade:
|
||||
|
||||
A = excelente (sin daños, funcional con seguridad)
|
||||
B = bueno (desgaste normal de uso, funcional)
|
||||
C = regular (desgaste/defecto menor, requiere revisión o reparación)
|
||||
D = con daño (daño estructural visible / funcionalidad dudosa)
|
||||
|
||||
Backends (reused from ``services.ai_chat``): QWEN -> Hermes -> OpenRouter.
|
||||
When no backend answers (e.g. the current QWEN key returns 401) the function
|
||||
degrades gracefully: it never raises and returns a dict with
|
||||
``condition_grade_suggested=None`` and a human-readable ``error``.
|
||||
|
||||
Public API:
|
||||
suggest_grade(image_bytes=None, image_base64=None, name=None,
|
||||
category=None, condition='usado', notes=None) -> dict
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
|
||||
GRADES = ('A', 'B', 'C', 'D')
|
||||
|
||||
GRADE_RUBRIC = """Escala de grados para piezas usadas de yonke:
|
||||
- A (excelente): sin daños visibles, no requiere reparación; funcionamiento prácticamente seguro.
|
||||
- B (bueno): desgaste normal por uso, sin daños estructurales; funciona; puede tener detalles cosméticos (rayones leves, óxido superficial).
|
||||
- C (regular): desgaste notable o defecto menor que puede requerir reparación o cambio de componentes secundarios; funcionalidad probable pero a verificar.
|
||||
- D (con daño): daño visible estructural, pieza rota/incompleta/muy corroída, o funcionalidad dudosa; se vende como refacción o no funcional."""
|
||||
|
||||
GRADE_INSTRUCTIONS = """Evalúa la pieza de la foto y asigna un grado.
|
||||
|
||||
Reglas:
|
||||
1. Basa el grado SOLO en lo observable en la foto. Si la foto no muestra la pieza, está borrosa o no es de una autoparte, baja la confianza y explícalo en "notes".
|
||||
2. Sé conservador: ante duda razonable entre dos grados, asigna el peor (menor).
|
||||
3. "defects" enumera defectos concretos y visibles (en español). Vacío si no hay.
|
||||
4. "confidence" es tu certeza global del grado (0.0 a 1.0).
|
||||
|
||||
Responde EXCLUSIVAMENTE con JSON válido, sin texto adicional:
|
||||
{
|
||||
"condition_grade_suggested": "A" | "B" | "C" | "D",
|
||||
"condition_suggested": "usado",
|
||||
"defects": ["..."],
|
||||
"confidence": 0.0,
|
||||
"notes": "una o dos frases en español"
|
||||
}"""
|
||||
|
||||
|
||||
def _build_prompt(name=None, category=None, condition=None):
|
||||
"""Build the user prompt embedding the piece metadata."""
|
||||
lines = [GRADE_INSTRUCTIONS, "", GRADE_RUBRIC, ""]
|
||||
meta = []
|
||||
if name:
|
||||
meta.append(f"- Nombre declarado: {name}")
|
||||
if category:
|
||||
meta.append(f"- Categoría: {category}")
|
||||
if condition:
|
||||
meta.append(f"- Condición declarada: {condition}")
|
||||
if meta:
|
||||
lines.append("Metadatos de la pieza:")
|
||||
lines.extend(meta)
|
||||
else:
|
||||
lines.append("No hay metadatos de la pieza; evalúa solo lo visible.")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _empty_result():
|
||||
return {
|
||||
'condition_grade_suggested': None,
|
||||
'condition_suggested': 'usado',
|
||||
'defects': [],
|
||||
'confidence': 0.0,
|
||||
'notes': '',
|
||||
'source': None,
|
||||
'error': None,
|
||||
}
|
||||
|
||||
|
||||
def _extract_json_object(text):
|
||||
"""Best-effort JSON extraction from an LLM reply.
|
||||
|
||||
Handles markdown fences, leading/trailing prose and trailing commas.
|
||||
Returns a dict or None.
|
||||
"""
|
||||
if not text:
|
||||
return None
|
||||
s = str(text).strip()
|
||||
if s.startswith('```'):
|
||||
s = re.sub(r'^```[A-Za-z0-9_-]*\s*', '', s)
|
||||
s = re.sub(r'```\s*$', '', s).strip()
|
||||
try:
|
||||
parsed = json.loads(s)
|
||||
return parsed if isinstance(parsed, dict) else None
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
pass
|
||||
m = re.search(r'\{.*\}', s, re.DOTALL)
|
||||
if not m:
|
||||
return None
|
||||
frag = m.group(0)
|
||||
for candidate in (frag, re.sub(r',\s*([}\]])', r'\1', frag)):
|
||||
try:
|
||||
parsed = json.loads(candidate)
|
||||
if isinstance(parsed, dict):
|
||||
return parsed
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_grade(data):
|
||||
"""Coerce a raw model dict into the public grade schema."""
|
||||
out = _empty_result()
|
||||
if not isinstance(data, dict):
|
||||
return out
|
||||
|
||||
raw_grade = (data.get('condition_grade_suggested')
|
||||
or data.get('condition_grade')
|
||||
or data.get('grade'))
|
||||
grade = str(raw_grade).strip().upper()[:1] if raw_grade else None
|
||||
out['condition_grade_suggested'] = grade if grade in GRADES else None
|
||||
|
||||
cond = str(data.get('condition_suggested') or data.get('condition') or 'usado').strip().lower()
|
||||
out['condition_suggested'] = 'reconstruido' if 'reconstr' in cond else 'usado'
|
||||
|
||||
defects = data.get('defects')
|
||||
if isinstance(defects, str):
|
||||
defects = re.split(r'[;\n]|,', defects)
|
||||
if isinstance(defects, list):
|
||||
out['defects'] = [str(d).strip() for d in defects if str(d).strip()][:20]
|
||||
|
||||
try:
|
||||
conf = float(data.get('confidence'))
|
||||
if conf > 1:
|
||||
conf = conf / 100.0
|
||||
out['confidence'] = max(0.0, min(1.0, conf))
|
||||
except (TypeError, ValueError):
|
||||
out['confidence'] = 0.0
|
||||
|
||||
notes = data.get('notes') or data.get('message') or data.get('reason') or ''
|
||||
out['notes'] = str(notes).strip()[:1000]
|
||||
return out
|
||||
|
||||
|
||||
def _is_usable(parsed):
|
||||
"""True when a normalized dict carries a real grading signal.
|
||||
|
||||
Deliberately ignores ``notes``: the shared vision helper falls back to a
|
||||
plain ``{"message": ...}`` dict on backend errors, and treating that as a
|
||||
valid grade would hide outages behind a misleading answer.
|
||||
"""
|
||||
return bool(parsed.get('condition_grade_suggested') or parsed.get('defects'))
|
||||
|
||||
|
||||
def _backends():
|
||||
"""Ordered vision backends (url, key, model, tag) from ai_chat config."""
|
||||
from services import ai_chat
|
||||
|
||||
out = []
|
||||
if getattr(ai_chat, 'QWEN_ENABLED', False):
|
||||
out.append((ai_chat.QWEN_CHAT_URL, ai_chat.QWEN_API_KEY, ai_chat.QWEN_MODEL, 'qwen'))
|
||||
if getattr(ai_chat, 'HERMES_ENABLED', False):
|
||||
out.append((ai_chat.HERMES_CHAT_URL, ai_chat.HERMES_API_KEY, ai_chat.HERMES_MODEL, 'hermes'))
|
||||
if getattr(ai_chat, 'OPENROUTER_API_KEY', None):
|
||||
out.append((ai_chat.OPENROUTER_URL, ai_chat.OPENROUTER_API_KEY, ai_chat.VISION_MODEL, 'openrouter'))
|
||||
return out
|
||||
|
||||
|
||||
def suggest_grade(image_bytes=None, image_base64=None, name=None, category=None,
|
||||
condition='usado', notes=None):
|
||||
"""Suggest a condition grade for a used part photo.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"condition_grade_suggested": "A"|"B"|"C"|"D"|None,
|
||||
"condition_suggested": "usado"|"reconstruido",
|
||||
"defects": [str, ...],
|
||||
"confidence": float, # 0.0 - 1.0
|
||||
"notes": str,
|
||||
"source": "qwen"|"hermes"|"openrouter"|None,
|
||||
"error": str|None # set when no grade could be produced
|
||||
}
|
||||
"""
|
||||
result = _empty_result()
|
||||
|
||||
if not image_bytes and not image_base64:
|
||||
result['error'] = 'No se proporcionó imagen de la pieza.'
|
||||
return result
|
||||
|
||||
b64 = image_base64
|
||||
if not b64:
|
||||
try:
|
||||
b64 = base64.b64encode(image_bytes).decode('ascii')
|
||||
except Exception as e: # pragma: no cover - defensive
|
||||
result['error'] = f'Imagen inválida: {e}'
|
||||
return result
|
||||
b64_clean = b64.split(',', 1)[1] if b64.startswith('data:') else b64
|
||||
|
||||
prompt = _build_prompt(name=name, category=category, condition=condition)
|
||||
|
||||
# 1) Reuse the shared vision entry point (handles its own JSON parsing).
|
||||
try:
|
||||
from services import ai_chat
|
||||
res = ai_chat.chat_with_image(prompt, b64_clean)
|
||||
if isinstance(res, dict):
|
||||
parsed = _normalize_grade(res)
|
||||
if not _is_usable(parsed) and res.get('message'):
|
||||
extracted = _extract_json_object(res.get('message'))
|
||||
if extracted:
|
||||
parsed = _normalize_grade(extracted)
|
||||
if _is_usable(parsed):
|
||||
parsed['source'] = 'vision'
|
||||
return parsed
|
||||
except Exception as e:
|
||||
print(f"[PART_VISION] chat_with_image failed: {e}")
|
||||
|
||||
# 2) Direct OpenAI-compatible call with each backend's own tag.
|
||||
backends = _backends()
|
||||
if not backends:
|
||||
result['error'] = ('Servicio de visión no configurado: sin backend de IA disponible '
|
||||
'(QWEN/Hermes/OpenRouter).')
|
||||
return result
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": "Eres un perito de autopartes usadas. Responde SOLO en JSON válido."},
|
||||
{"role": "user", "content": [
|
||||
{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64," + b64_clean}},
|
||||
{"type": "text", "text": prompt},
|
||||
]},
|
||||
]
|
||||
|
||||
last_error = None
|
||||
soft = None # valid JSON with notes but no grade (model refused/unsure)
|
||||
for url, key, model, tag in backends:
|
||||
try:
|
||||
from services import ai_chat
|
||||
res = ai_chat._post_chat_completion(
|
||||
url, key, model, messages, max_tokens=500, temperature=0.1, timeout=45
|
||||
)
|
||||
except Exception as e:
|
||||
last_error = f'{tag}: {e}'
|
||||
continue
|
||||
if not res:
|
||||
last_error = f'{tag}: sin respuesta (ver logs [AI])'
|
||||
continue
|
||||
parsed = _extract_json_object(res.get('content'))
|
||||
if not parsed:
|
||||
last_error = f'{tag}: respuesta no-JSON'
|
||||
continue
|
||||
normalized = _normalize_grade(parsed)
|
||||
if _is_usable(normalized):
|
||||
normalized['source'] = tag
|
||||
return normalized
|
||||
if soft is None and normalized.get('notes'):
|
||||
normalized['source'] = tag
|
||||
soft = normalized
|
||||
last_error = f'{tag}: JSON sin grado'
|
||||
|
||||
if soft is not None:
|
||||
soft['error'] = 'El modelo no asignó un grado; revisa notes.'
|
||||
return soft
|
||||
|
||||
result['error'] = ('El servicio de visión no pudo graduar la pieza '
|
||||
f'({last_error or "sin backend disponible"}).')
|
||||
return result
|
||||
@@ -11,6 +11,7 @@ Tax: 16% IVA per item (from item.tax_rate field).
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
import logging
|
||||
import threading
|
||||
from flask import g
|
||||
from services.audit import log_action
|
||||
@@ -26,6 +27,8 @@ from services.accounting_engine import record_sale_entry, record_cancellation_en
|
||||
from services.currency import convert, to_mxn, get_exchange_rate
|
||||
from services.savings_engine import record_sale_savings
|
||||
|
||||
logger = logging.getLogger('pos')
|
||||
|
||||
|
||||
def _safe_g(attr, default=None):
|
||||
"""Safely read flask.g attribute outside of app context."""
|
||||
@@ -40,6 +43,122 @@ def _to_dec(val):
|
||||
return Decimal(str(val))
|
||||
|
||||
|
||||
def get_max_discount_pct(conn, employee_id=None):
|
||||
"""Resolve the max discount % the current employee is allowed to grant.
|
||||
|
||||
Prefers the value carried on flask.g (set from the JWT at login); falls
|
||||
back to the employees table when it is absent (e.g. tokens issued before
|
||||
the field was added). A NULL/0 limit means "no limit" (100%), matching
|
||||
the historical behaviour of the sale flow.
|
||||
"""
|
||||
limit = _safe_g('max_discount_pct')
|
||||
if limit in (None, ''):
|
||||
employee_id = employee_id or _safe_g('employee_id')
|
||||
if employee_id:
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT max_discount_pct FROM employees WHERE id = %s", (employee_id,))
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
limit = row[0] if row else None
|
||||
return float(limit or 100)
|
||||
|
||||
|
||||
def _credit_scoring_enforced(conn):
|
||||
"""Tenant opt-in flag for blocking credit sales to 'risk' customers (A9).
|
||||
|
||||
Reads tenant_config.credit_scoring_enforce. Defaults to False so the
|
||||
existing sale flow is unchanged unless a tenant explicitly enables it.
|
||||
"""
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT value FROM tenant_config WHERE key = 'credit_scoring_enforce'")
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
return bool(row) and str(row[0]).strip().lower() in ('true', '1', 'yes', 'on')
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# Used/rebuilt pieces sold by a Yonke get a warranty expressed in days (the
|
||||
# yonke module never uses month-based coverage).
|
||||
YONKE_WARRANTY_CONDITIONS = ('usado', 'reconstruido')
|
||||
|
||||
|
||||
def _get_yonke_default_warranty_days(conn):
|
||||
"""Read tenant_config.yonke_default_warranty_days (None when unset/blank).
|
||||
|
||||
The yonke module stores the coverage as a tenant-wide default (see
|
||||
yonke_service._store_default_warranty_days); per-piece overrides travel on
|
||||
the sale line. Raises if tenant_config is unavailable — the caller runs
|
||||
this inside a SAVEPOINT so it can never abort the sale.
|
||||
"""
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT value FROM tenant_config WHERE key = 'yonke_default_warranty_days'")
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
if not row or row[0] is None or str(row[0]).strip() == '':
|
||||
return None
|
||||
try:
|
||||
return int(str(row[0]).strip())
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _register_yonke_warranties(conn, sale_id, sold_items, customer_id):
|
||||
"""Auto-register day-based warranties for used/rebuilt items in a sale.
|
||||
|
||||
Coverage comes from the line's `warranty_days` when present, otherwise from
|
||||
the tenant default. A piece with no configured coverage is skipped. The
|
||||
caller wraps this in a SAVEPOINT so a failure never breaks the sale.
|
||||
Returns the number of warranties registered.
|
||||
"""
|
||||
used_items = [
|
||||
it for it in sold_items
|
||||
if str(it.get('condition') or '').strip().lower() in YONKE_WARRANTY_CONDITIONS
|
||||
]
|
||||
if not used_items:
|
||||
return 0
|
||||
|
||||
default_days = _get_yonke_default_warranty_days(conn)
|
||||
|
||||
# sale_items were inserted in `sold_items` order; pair them back by id so
|
||||
# each warranty points at its sale line.
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT id FROM sale_items WHERE sale_id = %s ORDER BY id", (sale_id,))
|
||||
sale_item_ids = [r[0] for r in cur.fetchall()]
|
||||
cur.close()
|
||||
|
||||
from services.warranty_engine import register_warranty
|
||||
|
||||
registered = 0
|
||||
for line_no, item in enumerate(sold_items):
|
||||
if str(item.get('condition') or '').strip().lower() not in YONKE_WARRANTY_CONDITIONS:
|
||||
continue
|
||||
if line_no >= len(sale_item_ids):
|
||||
break
|
||||
days = item.get('warranty_days') or default_days
|
||||
try:
|
||||
days = int(days)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if days <= 0:
|
||||
continue
|
||||
register_warranty(
|
||||
conn,
|
||||
sale_id=sale_id,
|
||||
sale_item_id=sale_item_ids[line_no],
|
||||
inventory_id=item['inventory_id'],
|
||||
customer_id=customer_id,
|
||||
warranty_months=0,
|
||||
part_number=item.get('part_number'),
|
||||
name=item.get('name'),
|
||||
notes='Garantía Yonke (pieza usada)',
|
||||
warranty_days=days,
|
||||
)
|
||||
registered += 1
|
||||
return registered
|
||||
|
||||
|
||||
def calculate_totals(items):
|
||||
"""Compute subtotal, discount amounts, tax, and total for a list of items.
|
||||
|
||||
@@ -241,7 +360,7 @@ def process_sale(conn, sale_data):
|
||||
# Lock inventory rows to prevent race conditions on concurrent sales
|
||||
cur.execute("""
|
||||
SELECT id, part_number, name, cost, price_1, price_2, price_3,
|
||||
tax_rate, branch_id, retail_price
|
||||
tax_rate, branch_id, retail_price, condition
|
||||
FROM inventory
|
||||
WHERE id = ANY(%s) AND is_active = true
|
||||
ORDER BY id
|
||||
@@ -264,6 +383,7 @@ def process_sale(conn, sale_data):
|
||||
stock_map = {r[0]: r[1] for r in cur.fetchall()}
|
||||
|
||||
# Validate and enrich items
|
||||
max_discount = get_max_discount_pct(conn, employee_id)
|
||||
enriched_items = []
|
||||
for item in items:
|
||||
inv_id = item.get('inventory_id')
|
||||
@@ -284,7 +404,6 @@ def process_sale(conn, sale_data):
|
||||
unit_cost = float(inv[3]) if inv[3] else 0
|
||||
|
||||
# Validate discount against employee max
|
||||
max_discount = float(_safe_g('max_discount_pct', 100) or 100)
|
||||
if _safe_g('employee_role', 'cashier') not in ('owner', 'admin') and discount_pct > max_discount:
|
||||
raise ValueError(
|
||||
f"Discount {discount_pct}% exceeds your maximum allowed {max_discount}% "
|
||||
@@ -302,6 +421,10 @@ def process_sale(conn, sale_data):
|
||||
'tax_rate': tax_rate,
|
||||
'branch_id': inv[8],
|
||||
'stock_before': current_stock,
|
||||
# condition drives the Yonke day-based warranty hook below;
|
||||
# warranty_days lets a single line override the tenant default.
|
||||
'condition': (inv[10] or '').strip().lower(),
|
||||
'warranty_days': item.get('warranty_days'),
|
||||
})
|
||||
|
||||
# Calculate totals
|
||||
@@ -321,6 +444,27 @@ def process_sale(conn, sale_data):
|
||||
f"Required: ${totals['total']:.2f}"
|
||||
)
|
||||
|
||||
# Optional credit-risk enforcement (A9). Off unless the tenant sets
|
||||
# tenant_config.credit_scoring_enforce = true. Never break the sale on
|
||||
# scoring errors — only block when we positively classify 'risk'.
|
||||
if _credit_scoring_enforced(conn):
|
||||
try:
|
||||
from services.credit_scoring import score_customer
|
||||
_cs = score_customer(conn, customer_id)
|
||||
except Exception as exc:
|
||||
logger.warning("credit scoring failed for customer %s: %s", customer_id, exc)
|
||||
_cs = None
|
||||
if _cs and _cs.get('tier') == 'risk':
|
||||
reasons = _cs.get('reasons') or []
|
||||
detail = next(
|
||||
(r for r in reasons if 'Mora' in r or 'atraso' in r or 'excedido' in r),
|
||||
reasons[-1] if reasons else '',
|
||||
)
|
||||
raise ValueError(
|
||||
f"Venta a crédito bloqueada: cliente en riesgo "
|
||||
f"(score {_cs['score']}/100). {detail}"
|
||||
)
|
||||
|
||||
# Pending payment sale (e.g. "Pendiente")
|
||||
is_pending = payment_method == 'pendiente'
|
||||
if is_pending:
|
||||
@@ -349,8 +493,8 @@ def process_sale(conn, sale_data):
|
||||
(branch_id, customer_id, employee_id, register_id, sale_type,
|
||||
payment_method, subtotal, discount_total, tax_total, total,
|
||||
amount_paid, change_given, metodo_pago_sat, forma_pago_sat,
|
||||
status, device_id, notes, currency, exchange_rate)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||
status, device_id, notes, currency, exchange_rate, courier_id)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||
RETURNING id, created_at
|
||||
""", (
|
||||
branch_id, customer_id, employee_id, register_id, sale_type,
|
||||
@@ -358,7 +502,7 @@ def process_sale(conn, sale_data):
|
||||
totals['tax_total'], totals['total'], amount_paid, change_given,
|
||||
metodo_pago_sat, forma_pago_sat,
|
||||
status, _safe_g('device_id'), notes,
|
||||
currency, exchange_rate
|
||||
currency, exchange_rate, sale_data.get('courier_id')
|
||||
))
|
||||
sale_id, created_at = cur.fetchone()
|
||||
|
||||
@@ -467,6 +611,33 @@ def process_sale(conn, sale_data):
|
||||
|
||||
cur.close()
|
||||
|
||||
# Yonke warranty hook (non-blocking). Every sold used/rebuilt piece gets a
|
||||
# day-based warranty from tenant_config.yonke_default_warranty_days (or the
|
||||
# line's warranty_days). SAVEPOINT-isolated like the other hooks: a missing
|
||||
# table or an invalid config only reverts this hook, never the sale. Kept
|
||||
# before the accounting/savings hooks because record_sale_savings commits —
|
||||
# that way the warranty lands in the same transaction as the sale.
|
||||
try:
|
||||
_cur = conn.cursor()
|
||||
_cur.execute("SAVEPOINT sp_yonke_warranty")
|
||||
_cur.close()
|
||||
try:
|
||||
registered = _register_yonke_warranties(
|
||||
conn, sale_id, totals['items'], customer_id
|
||||
)
|
||||
_c = conn.cursor()
|
||||
_c.execute("RELEASE SAVEPOINT sp_yonke_warranty")
|
||||
_c.close()
|
||||
if registered:
|
||||
logger.info("Registered %s Yonke warranty(ies) for sale #%s", registered, sale_id)
|
||||
except Exception as w_err:
|
||||
_c = conn.cursor()
|
||||
_c.execute("ROLLBACK TO SAVEPOINT sp_yonke_warranty")
|
||||
_c.close()
|
||||
logger.warning("Yonke warranty hook for sale #%s failed: %s", sale_id, w_err)
|
||||
except Exception:
|
||||
pass # Warranty errors never block sales
|
||||
|
||||
# Auto-generate accounting entry (non-blocking)
|
||||
# Accounting is always in MXN — convert if sale was in another currency
|
||||
try:
|
||||
@@ -482,8 +653,10 @@ def process_sale(conn, sale_data):
|
||||
'cost_total': sum(item.get('unit_cost', 0) * item['quantity'] for item in enriched_items),
|
||||
'payment_method': payment_method,
|
||||
})
|
||||
except Exception:
|
||||
pass # Accounting errors never block sales
|
||||
except Exception as acc_err:
|
||||
# Accounting errors never block sales, but they must never be silent:
|
||||
# a lost entry is a hole in the ledger that only shows up at audit time.
|
||||
logger.error(f'Accounting entry for sale #{sale_id} failed: {acc_err}')
|
||||
|
||||
# Calculate and record savings vs retail price (non-blocking)
|
||||
try:
|
||||
@@ -491,10 +664,70 @@ def process_sale(conn, sale_data):
|
||||
except Exception:
|
||||
pass # Savings errors never block sales
|
||||
|
||||
# WhatsApp learning hook (non-blocking)
|
||||
# Auto-invoice hook (non-blocking): enqueue CFDI when the tenant has
|
||||
# automatic invoicing on, or when the cashier explicitly requested it
|
||||
# with the "Facturar CFDI" checkbox (generate_cfdi). Either way the
|
||||
# customer must have an RFC.
|
||||
if customer_id and status == 'completed':
|
||||
try:
|
||||
generate_cfdi = bool(sale_data.get('generate_cfdi'))
|
||||
cur_ai = conn.cursor()
|
||||
cur_ai.execute("SELECT value FROM tenant_config WHERE key = 'invoice_auto'")
|
||||
row_ai = cur_ai.fetchone()
|
||||
cur_ai.execute(
|
||||
"SELECT rfc, razon_social, email, uso_cfdi, cp FROM customers WHERE id = %s",
|
||||
(customer_id,))
|
||||
cust = cur_ai.fetchone()
|
||||
cur_ai.close()
|
||||
auto_invoice = bool(row_ai and (row_ai[0] or '').lower() == 'true')
|
||||
if (generate_cfdi or auto_invoice) and cust and cust[0]:
|
||||
customer_rec = {
|
||||
'rfc': cust[0], 'razon_social': cust[1] or '',
|
||||
'name': customer_name or cust[1], 'email': cust[2],
|
||||
'uso_cfdi': cust[3], 'cp': cust[4],
|
||||
}
|
||||
from services.cfdi_facturapi_builder import build_ingreso_payload
|
||||
from services.cfdi_queue import enqueue_cfdi
|
||||
tcfg = {'cp': cust[4] or '00000', 'serie': 'A'}
|
||||
sale_for_cfdi = {
|
||||
'id': sale_id, 'items': enriched_items,
|
||||
'payment_method': payment_method, 'sale_type': sale_type,
|
||||
'currency': currency, 'exchange_rate': exchange_rate,
|
||||
}
|
||||
payload = build_ingreso_payload(sale_for_cfdi, tcfg, customer_rec)
|
||||
enqueue_cfdi(conn, sale_id, 'ingreso', payload)
|
||||
except Exception:
|
||||
pass # invoicing errors never block sales
|
||||
|
||||
# Loyalty points hook (non-blocking): 1 point per $10 MXN on completed sales
|
||||
if customer_id and status == 'completed':
|
||||
try:
|
||||
from services.crm_engine import add_loyalty_points
|
||||
points = int(totals['total'] // 10)
|
||||
if points > 0:
|
||||
add_loyalty_points(conn, customer_id, points, points_type='earned',
|
||||
source_type='sale', source_id=sale_id,
|
||||
description=f'Venta #{sale_id}')
|
||||
except Exception:
|
||||
pass # Loyalty errors never block sales
|
||||
|
||||
# WhatsApp learning hook (non-blocking). Se aísla en un SAVEPOINT: si falla
|
||||
# (p.ej. tabla ausente en algún tenant) se revierte SOLO el hook. Sin esto, un
|
||||
# error SQL deja la transacción abortada y el commit() posterior pierde la venta.
|
||||
try:
|
||||
from services.wa_learning import check_learning_resolution
|
||||
check_learning_resolution(sale_id, customer_id, conn)
|
||||
_cur = conn.cursor()
|
||||
_cur.execute("SAVEPOINT sp_wa_learning")
|
||||
_cur.close()
|
||||
try:
|
||||
from services.wa_learning import check_learning_resolution
|
||||
check_learning_resolution(sale_id, customer_id, conn)
|
||||
_c = conn.cursor()
|
||||
_c.execute("RELEASE SAVEPOINT sp_wa_learning")
|
||||
_c.close()
|
||||
except Exception:
|
||||
_c = conn.cursor()
|
||||
_c.execute("ROLLBACK TO SAVEPOINT sp_wa_learning")
|
||||
_c.close()
|
||||
except Exception:
|
||||
pass # Learning errors never block sales
|
||||
|
||||
@@ -550,6 +783,7 @@ def process_sale(conn, sale_data):
|
||||
'created_at': str(created_at),
|
||||
'currency': currency,
|
||||
'exchange_rate': exchange_rate,
|
||||
'courier_id': sale_data.get('courier_id'),
|
||||
}
|
||||
|
||||
|
||||
@@ -868,8 +1102,8 @@ def pay_pending_sale(conn, sale_id, data):
|
||||
'cost_total': cost_total,
|
||||
'payment_method': payment_method,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as acc_err:
|
||||
logger.error(f'Accounting entry for paid remission #{sale_id} failed: {acc_err}')
|
||||
|
||||
log_action(conn, 'REMISSION_PAID', 'sale', sale_id,
|
||||
old_value={'status': 'pending_payment', 'total': totals['total']},
|
||||
@@ -986,8 +1220,9 @@ def cancel_sale(conn, sale_id, reason):
|
||||
'sale_type': _sale_row[3] or 'cash',
|
||||
'payment_method': _sale_row[4] or 'efectivo',
|
||||
})
|
||||
except Exception:
|
||||
pass # Accounting errors never block cancellations
|
||||
except Exception as acc_err:
|
||||
# Accounting errors never block cancellations, but must be logged
|
||||
logger.error(f'Accounting reversal for cancelled sale #{sale_id} failed: {acc_err}')
|
||||
|
||||
# Reverse customer credit balance if credit sale
|
||||
if s_type == 'credit' and s_cust_id:
|
||||
|
||||
780
pos/services/product_categorizer.py
Normal file
780
pos/services/product_categorizer.py
Normal file
@@ -0,0 +1,780 @@
|
||||
# /home/Autopartes/pos/services/product_categorizer.py
|
||||
"""Product auto-categorization for tenant inventory.
|
||||
|
||||
Given one or more items (part_number / name / brand) assign a tenant
|
||||
``categories.id`` using, in order:
|
||||
|
||||
1. A rule engine over the Nexpart taxonomy (``services/nexpart_taxonomy``):
|
||||
the item (Spanish) name is matched against the Spanish translations of
|
||||
the 558 Nexpart part-types / 108 subgroups / 14 groups, plus a small
|
||||
supplemental Spanish keyword table for terms the catalog lacks.
|
||||
2. A fallback to the LLM classifier ``ai_chat.classify_part`` when the rule
|
||||
engine finds nothing (opt-in via ``use_llm``).
|
||||
|
||||
The rule engine maps a Nexpart node to a *canonical category* (a short Spanish
|
||||
name like "Frenos", "Motor", "Encendido"). The canonical name is then resolved
|
||||
against the tenant's own ``categories`` table: an existing row is reused when a
|
||||
close match exists, otherwise a new row is created.
|
||||
|
||||
Public API:
|
||||
categorize_item(conn, part_number, name, brand, ...) -> dict
|
||||
categorize_items(conn, items, ...) -> dict
|
||||
categorize_inventory(conn, limit, ...) -> dict
|
||||
|
||||
Every result dict has the shape:
|
||||
{part_number, category_id, category_name, canonical_category,
|
||||
confidence, source}
|
||||
|
||||
``source`` is one of: 'taxonomy', 'keyword', 'brand', 'llm', 'none'.
|
||||
"""
|
||||
|
||||
import re
|
||||
import unicodedata
|
||||
from difflib import SequenceMatcher
|
||||
|
||||
from services import nexpart_taxonomy as nt
|
||||
from services.nexpart_taxonomy import NEXPART_TAXONOMY, TAXONOMY_OVERRIDES_ES
|
||||
|
||||
try: # translations.py is optional; the taxonomy overrides are the main source
|
||||
from services.translations import PART_TRANSLATIONS
|
||||
except Exception: # pragma: no cover
|
||||
PART_TRANSLATIONS = {}
|
||||
|
||||
|
||||
# ── Canonical category names ───────────────────────────────────────────────
|
||||
# Short Spanish names that both align with the Nexpart groups and, in
|
||||
# practice, with the names tenants already use in their `categories` table.
|
||||
|
||||
CANONICAL_BY_GROUP = {
|
||||
"Ignition & Filters": "Filtros y Encendido",
|
||||
"Belts, Hoses, Water Pumps & Cooling System Parts": "Enfriamiento",
|
||||
"Starting & Charging System Parts (Alternators, Batteries & Cables)": "Eléctrico",
|
||||
"Brake System, Wheel Bearings, Studs, Nuts & Hardware": "Frenos",
|
||||
"Fuel & Emissions Parts": "Combustible y Emisiones",
|
||||
"Heating & Air Conditioning": "Aire Acondicionado",
|
||||
"Engine Parts": "Motor",
|
||||
"Drivetrain Parts": "Tren Motriz",
|
||||
"Steering & Suspension Parts": "Suspensión",
|
||||
"Exhaust, Clutch & Flywheel Parts": "Escape",
|
||||
"Wipers, Lamps & Fuses": "Luces y Limpiaparabrisas",
|
||||
"Body Parts, Cables, Caps, Electrical Motors, Switches & Other Miscellaneous Parts": "Carrocería",
|
||||
"Chemicals, Waxes & Lubricants": "Lubricantes y Químicos",
|
||||
"Tires, Wheels, Tools & Accessory Parts": "Llantas y Accesorios",
|
||||
}
|
||||
|
||||
# Subgroup overrides: more specific than the group default.
|
||||
CANONICAL_BY_SUBGROUP = {
|
||||
# Ignition & Filters
|
||||
"Filters & PCV": "Filtros",
|
||||
"Spark Plugs & Glow Plugs": "Encendido",
|
||||
"Tune-Up & Ignition Parts": "Encendido",
|
||||
"Ignition Wires": "Encendido",
|
||||
"Computers & Relays": "Eléctrico",
|
||||
# Starting & Charging
|
||||
"Batteries": "Eléctrico",
|
||||
"Starters": "Eléctrico",
|
||||
"Alternators & Voltage Regulators": "Eléctrico",
|
||||
"Starter Solenoids, Switches & Relays": "Eléctrico",
|
||||
# Cooling
|
||||
"Radiators & Electric Fan Motors": "Enfriamiento",
|
||||
"Water Pumps, Fan Blades & Clutches": "Enfriamiento",
|
||||
"Thermostats, Housings & Radiator Caps": "Enfriamiento",
|
||||
"Engine Coolant & Bypass Hoses": "Enfriamiento",
|
||||
# Brakes
|
||||
"Front Friction, Drums & Rotors": "Frenos",
|
||||
"Rear Friction, Drums & Rotors": "Frenos",
|
||||
"Front Calipers, Wheel Cylinders, Hoses": "Frenos",
|
||||
"Rear Calipers, Wheel Cylinders, Hoses": "Frenos",
|
||||
"Master Cylinders, Boosters & Switches": "Frenos",
|
||||
"Brake Cables, Studs, Nuts & Spindle Nuts": "Frenos",
|
||||
"Front Brake Hardware & ABS Sensors": "Frenos",
|
||||
"Rear Brake Hardware & ABS Sensors": "Frenos",
|
||||
"Miscellaneous Disc Hardware": "Frenos",
|
||||
"Miscellaneous Drum Hardware": "Frenos",
|
||||
"Front Wheel Bearings & Seals": "Baleros y Mazas",
|
||||
"Rear Wheel Bearings & Seals": "Baleros y Mazas",
|
||||
"Spindles & Hubs": "Baleros y Mazas",
|
||||
# Fuel & emissions
|
||||
"Fuel Pumps & Tanks": "Combustible y Emisiones",
|
||||
"Fuel Injection Parts, Mass Air Flow Sensors": "Combustible y Emisiones",
|
||||
"Carburetors, Carburetor Kits & Components": "Combustible y Emisiones",
|
||||
"EGR & Emissions Valves": "Combustible y Emisiones",
|
||||
"Emission Sensors, Relays, Solenoids & Switches": "Combustible y Emisiones",
|
||||
"Fuel Injection Sensors, Relays & Switches": "Combustible y Emisiones",
|
||||
"Turbochargers & Superchargers": "Motor",
|
||||
# AC
|
||||
"AC Compressors, Kits & Parts": "Aire Acondicionado",
|
||||
"AC Condensers & Evaporators": "Aire Acondicionado",
|
||||
"AC Accumulators, Receiver Driers & Valves": "Aire Acondicionado",
|
||||
"AC Hose Assemblies & Fittings": "Aire Acondicionado",
|
||||
"Blower Motors & Parts": "Aire Acondicionado",
|
||||
"Heater Cores & Heater Control Valves": "Aire Acondicionado",
|
||||
# Engine
|
||||
"Cams, Lifters & Timing Parts": "Motor",
|
||||
"Crankshafts & Bearings": "Motor",
|
||||
"Pistons, Rings & Rods": "Motor",
|
||||
"Heads & Manifolds": "Motor",
|
||||
"Gasket Sets": "Motor",
|
||||
"Individual Gaskets & Seals": "Motor",
|
||||
"Intake & Exhaust Valves": "Motor",
|
||||
"Rockers & Push Rods": "Motor",
|
||||
"Vacuum & Oil Pumps": "Motor",
|
||||
"Engine Block Parts": "Motor",
|
||||
"Engines & Kits": "Motor",
|
||||
"Engine Mounts & Other Miscellaneous Engine Parts": "Motor",
|
||||
# Drivetrain
|
||||
"Driveshafts, U-Joints & CV (Constant Velocity) Parts": "Tren Motriz",
|
||||
"Axle & Differential Parts": "Tren Motriz",
|
||||
"Transmission & Parts": "Transmisión",
|
||||
"Transmission Kits & Gaskets": "Transmisión",
|
||||
"Automatic Transmission Seals": "Transmisión",
|
||||
"Manual Transmission Seals": "Transmisión",
|
||||
"Manual Transmission Bearings": "Transmisión",
|
||||
"Clutches & Clutch Kits": "Clutch",
|
||||
"Clutch Hydraulics": "Clutch",
|
||||
"Miscellaneous Clutch Parts": "Clutch",
|
||||
# Steering & suspension
|
||||
"Ball Joints & Control Arms": "Suspensión",
|
||||
"Shock Absorbers & Struts": "Suspensión",
|
||||
"Sway Bars, Stabilizer Bars, Strut Rods & Parts": "Suspensión",
|
||||
"Alignment Kits & Tools": "Suspensión",
|
||||
"Steering Linkages, Rods & Arms": "Dirección",
|
||||
"Rack & Pinion, Gear Box, Power Cylinder": "Dirección",
|
||||
"Power Steering Pumps, Hoses & Kits": "Dirección",
|
||||
"King Pins, Trailing Arms, Alignment & Other Chassis": "Dirección",
|
||||
# Exhaust
|
||||
"All Exhaust & Diagrams": "Escape",
|
||||
"Catalytic Converter": "Escape",
|
||||
"Manifolds & Headers": "Escape",
|
||||
"Individual Exhaust Parts": "Escape",
|
||||
# Wipers / lamps / fuses
|
||||
"Arms, Blades & Refills": "Limpiaparabrisas",
|
||||
"Wiper Motors & Washer Pumps": "Limpiaparabrisas",
|
||||
"Headlamps & Flashers": "Luces",
|
||||
"Exterior Lamps": "Luces",
|
||||
"Interior Lamps": "Luces",
|
||||
"Lighting Modules & Switches": "Luces",
|
||||
"Lighting Relays & Sensors": "Luces",
|
||||
# Body / electrical / misc
|
||||
"Bumpers & License Plates": "Carrocería",
|
||||
"Door, Window & Tailgate Parts": "Carrocería",
|
||||
"Glass": "Carrocería",
|
||||
"Hood & Tailgate Parts": "Carrocería",
|
||||
"Hoods Fenders & Body Parts": "Carrocería",
|
||||
"Hood, Fender & Body Parts": "Carrocería",
|
||||
"Interior & Steering Wheel": "Carrocería",
|
||||
"Electrical Motors": "Eléctrico",
|
||||
"Electronics, Sensors, Relays & Miscellaneous Parts": "Eléctrico",
|
||||
"Sensors, Switches & Relays": "Eléctrico",
|
||||
"Switches, Relays & Miscellaneous Parts": "Eléctrico",
|
||||
"Electronics Audio/Visual & Mirrors": "Carrocería",
|
||||
# Chemicals / tools
|
||||
"Engine & Transmission Lubricants & Additives": "Lubricantes y Químicos",
|
||||
"Tires & Wheels": "Llantas y Accesorios",
|
||||
"Tools, Jacks, Hardware & Manuals": "Llantas y Accesorios",
|
||||
"Wheel & Hardware": "Llantas y Accesorios",
|
||||
}
|
||||
|
||||
# Extra names a canonical category should be matched against in the tenant's
|
||||
# `categories` table (in addition to the canonical name itself).
|
||||
CATEGORY_ALIASES = {
|
||||
"Filtros y Encendido": ["Filtros", "Encendido", "Filtros y Encendido", "Afinacion y Encendido"],
|
||||
"Filtros": ["Filtros", "Filtro de aceite", "Filtros de aire", "Filtros de gasolina"],
|
||||
"Encendido": ["Encendido", "Ignición y Eléctrico", "Bujías"],
|
||||
"Enfriamiento": ["Enfriamiento", "Refrigeración", "Sistema de Enfriamiento", "Radiadores"],
|
||||
"Eléctrico": ["Eléctrico", "Eléctrico e Iluminación", "Ignición y Eléctrico", "Baterías y Eléctrico"],
|
||||
"Frenos": ["Frenos", "Frenos (Discos)", "Balatas", "Sistema de Frenos"],
|
||||
"Baleros y Mazas": ["Baleros", "Balero", "Mazas", "Baleros y Sellos", "Baleros de Rueda"],
|
||||
"Combustible y Emisiones": ["Combustible", "Combustible y Aire", "Inyección", "Emisiones"],
|
||||
"Aire Acondicionado": ["Aire Acondicionado", "Calefacción y Aire Acondicionado", "A/C", "Heat & AC"],
|
||||
"Motor": ["Motor", "Partes de Motor", "Engine", "Motor y Componentes"],
|
||||
"Tren Motriz": ["Tren Motriz", "Drivetrain", "Transmisión", "Flechas"],
|
||||
"Transmisión": ["Transmisión", "Transmission", "Clutch y Transmisión", "Caja"],
|
||||
"Clutch": ["Clutch", "Clutch y Transmisión", "Kits de Clutch"],
|
||||
"Suspensión": ["Suspensión", "Suspension", "Amortiguadores", "Dirección y Suspensión"],
|
||||
"Dirección": ["Dirección", "Steering", "Cremalleras", "Dirección y Rótulas"],
|
||||
"Escape": ["Escape", "Exhaust", "Mofles", "Sistema de Escape"],
|
||||
"Luces y Limpiaparabrisas": ["Luces", "Iluminación", "Limpiaparabrisas", "Faros y Calaveras"],
|
||||
"Luces": ["Luces", "Iluminación", "Faros", "Calaveras"],
|
||||
"Limpiaparabrisas": ["Limpiaparabrisas", "Plumas", "Wipers"],
|
||||
"Carrocería": ["Carrocería", "Body & Lamp Assembly", "Body", "Accesorios"],
|
||||
"Lubricantes y Químicos": ["Aceites y Lubricantes", "Lubricantes", "Químicos", "Químicos, Ceras y Lubricantes"],
|
||||
"Llantas y Accesorios": ["Llantas", "Llantas y Rines", "Herramienta", "Herramientas", "Accesorios", "Tires, Wheels, Tools & Accessory Parts"],
|
||||
}
|
||||
|
||||
# Supplemental Spanish rules for terms the Nexpart translations don't cover.
|
||||
# Applied only when the taxonomy matcher found nothing. Longest match wins.
|
||||
ES_RULES = [
|
||||
("disco de freno", "Frenos"),
|
||||
("discos de freno", "Frenos"),
|
||||
("tambor de freno", "Frenos"),
|
||||
("cinta de freno", "Frenos"),
|
||||
("tambor", "Frenos"),
|
||||
("balero de rueda", "Baleros y Mazas"),
|
||||
("balero", "Baleros y Mazas"),
|
||||
("maza de rueda", "Baleros y Mazas"),
|
||||
("maza", "Baleros y Mazas"),
|
||||
("husillo", "Baleros y Mazas"),
|
||||
("cremallera", "Dirección"),
|
||||
("terminal de direccion", "Dirección"),
|
||||
("bieleta", "Dirección"),
|
||||
("rotula", "Suspensión"),
|
||||
("horquilla", "Suspensión"),
|
||||
("birlo", "Frenos"),
|
||||
("motoventilador", "Enfriamiento"),
|
||||
("ventilador", "Enfriamiento"),
|
||||
("calavera", "Luces"),
|
||||
("faro", "Luces"),
|
||||
("direccional", "Luces"),
|
||||
("foco", "Luces"),
|
||||
("espejo", "Carrocería"),
|
||||
("salpicadera", "Carrocería"),
|
||||
("defensa", "Carrocería"),
|
||||
("cristal", "Carrocería"),
|
||||
("empaque", "Motor"),
|
||||
("junta", "Motor"),
|
||||
("sello", "Motor"),
|
||||
("sensor de oxigeno", "Combustible y Emisiones"),
|
||||
("inyector", "Combustible y Emisiones"),
|
||||
("bomba de gasolina", "Combustible y Emisiones"),
|
||||
("catalizador", "Escape"),
|
||||
("convertidor", "Escape"),
|
||||
("mofle", "Escape"),
|
||||
("silenciador", "Escape"),
|
||||
("clutch", "Clutch"),
|
||||
("embrague", "Clutch"),
|
||||
("banda", "Enfriamiento"),
|
||||
("manguera", "Enfriamiento"),
|
||||
("radiador", "Enfriamiento"),
|
||||
("termostato", "Enfriamiento"),
|
||||
("bomba de agua", "Enfriamiento"),
|
||||
("bomba de aceite", "Motor"),
|
||||
("alternador", "Eléctrico"),
|
||||
("arrancador", "Eléctrico"),
|
||||
("marcha", "Eléctrico"),
|
||||
("bateria", "Eléctrico"),
|
||||
("sensor", "Eléctrico"),
|
||||
("switch", "Eléctrico"),
|
||||
("relevador", "Eléctrico"),
|
||||
("fusible", "Eléctrico"),
|
||||
("filtro", "Filtros"),
|
||||
("bujia", "Encendido"),
|
||||
("balata", "Frenos"),
|
||||
("pastilla", "Frenos"),
|
||||
("amortiguador", "Suspensión"),
|
||||
("toe", "Suspensión"),
|
||||
# Drivetrain / CV
|
||||
("junta homocinetica", "Tren Motriz"),
|
||||
("homocinetica", "Tren Motriz"),
|
||||
("cubre polvo", "Tren Motriz"),
|
||||
("cubrepolvo", "Tren Motriz"),
|
||||
("guardapolvo", "Tren Motriz"),
|
||||
("fuelle", "Tren Motriz"),
|
||||
("tripode", "Tren Motriz"),
|
||||
("flecha", "Tren Motriz"),
|
||||
("reten", "Motor"),
|
||||
("o ring", "Motor"),
|
||||
# Consumables
|
||||
("aditivo", "Lubricantes y Químicos"),
|
||||
("aceite", "Lubricantes y Químicos"),
|
||||
("grasa", "Lubricantes y Químicos"),
|
||||
("liquido", "Lubricantes y Químicos"),
|
||||
("spray", "Lubricantes y Químicos"),
|
||||
("aerosol", "Lubricantes y Químicos"),
|
||||
("limpiador", "Lubricantes y Químicos"),
|
||||
("anticongelante", "Lubricantes y Químicos"),
|
||||
("refrigerante", "Enfriamiento"),
|
||||
("solvente", "Lubricantes y Químicos"),
|
||||
("silicon", "Lubricantes y Químicos"),
|
||||
("sellador", "Lubricantes y Químicos"),
|
||||
("pegamento", "Lubricantes y Químicos"),
|
||||
("acondicionador", "Lubricantes y Químicos"),
|
||||
("afloja todo", "Lubricantes y Químicos"),
|
||||
("pintura", "Carrocería"),
|
||||
# Electrical modules
|
||||
("modulo", "Eléctrico"),
|
||||
("regulador", "Eléctrico"),
|
||||
("solenoide", "Eléctrico"),
|
||||
# Tools / accessories
|
||||
("herramienta", "Llantas y Accesorios"),
|
||||
("inflallanta", "Llantas y Accesorios"),
|
||||
("desarmador", "Llantas y Accesorios"),
|
||||
("pinza", "Llantas y Accesorios"),
|
||||
("dado", "Llantas y Accesorios"),
|
||||
# Misc frequent terms seen in real tenant catalogs
|
||||
("barra de direccion", "Dirección"),
|
||||
("terminal de direccion", "Dirección"),
|
||||
("terminal", "Dirección"),
|
||||
("espiga", "Suspensión"),
|
||||
("resorte", "Suspensión"),
|
||||
("barra", "Suspensión"),
|
||||
("aspas", "Enfriamiento"),
|
||||
("aspa", "Enfriamiento"),
|
||||
("tulipa", "Luces"),
|
||||
("conector", "Eléctrico"),
|
||||
("bulbo", "Eléctrico"),
|
||||
("resistencia", "Eléctrico"),
|
||||
("tripoide", "Tren Motriz"),
|
||||
("cople", "Tren Motriz"),
|
||||
("motor", "Motor"),
|
||||
("dir", "Dirección"),
|
||||
]
|
||||
|
||||
# Weak brand-only hints, used when neither name rules nor the taxonomy hit.
|
||||
BRAND_HINTS = {
|
||||
"NGK": "Encendido", "DENSO": "Encendido", "ACDELCO": "Encendido",
|
||||
"BREMBO": "Frenos", "RAYBESTOS": "Frenos", "WAGNER": "Frenos",
|
||||
"MONROE": "Suspensión", "GABRIEL": "Suspensión", "MOOG": "Suspensión",
|
||||
"FRAM": "Filtros", "PUROLATOR": "Filtros", "WIX": "Filtros",
|
||||
"GATES": "Enfriamiento", "DAYCO": "Enfriamiento", "GMB": "Enfriamiento",
|
||||
"TRW": "Dirección", "EXEDY": "Clutch", "LUK": "Clutch",
|
||||
"FEL-PRO": "Motor", "MAHLE": "Motor", "VICTOR REINZ": "Motor",
|
||||
"BOSCH": "Eléctrico", "VALEO": "Eléctrico",
|
||||
}
|
||||
|
||||
|
||||
# ── Normalization / indexes ────────────────────────────────────────────────
|
||||
|
||||
def _norm(s):
|
||||
"""Uppercase, strip accents, keep alphanumerics, collapse whitespace."""
|
||||
s = unicodedata.normalize("NFD", str(s or ""))
|
||||
s = s.encode("ascii", "ignore").decode("ascii")
|
||||
s = re.sub(r"[^A-Za-z0-9]+", " ", s).upper().strip()
|
||||
return " ".join(s.split())
|
||||
|
||||
|
||||
def _build_indexes():
|
||||
pt_index = {} # normalized Spanish part-type -> English Nexpart part type
|
||||
node_index = {} # normalized Spanish group/subgroup -> (group, subgroup|None)
|
||||
|
||||
for en, es in TAXONOMY_OVERRIDES_ES.items():
|
||||
n = _norm(es)
|
||||
if not n:
|
||||
continue
|
||||
if en in NEXPART_TAXONOMY:
|
||||
node_index.setdefault(n, (en, None))
|
||||
continue
|
||||
found = None
|
||||
for g, subs in NEXPART_TAXONOMY.items():
|
||||
for sg in subs:
|
||||
if en == sg:
|
||||
found = (g, sg)
|
||||
break
|
||||
if found:
|
||||
break
|
||||
if found:
|
||||
node_index.setdefault(n, found)
|
||||
else:
|
||||
pt_index.setdefault(n, en)
|
||||
|
||||
# Invert the TecDoc→Spanish dictionary for extra coverage.
|
||||
for en, es in PART_TRANSLATIONS.items():
|
||||
for part in str(es).split("/"):
|
||||
n = _norm(part)
|
||||
if n and n not in pt_index and n not in node_index:
|
||||
pt_index[n] = en
|
||||
|
||||
return pt_index, node_index
|
||||
|
||||
|
||||
_PT_INDEX, _NODE_INDEX = _build_indexes()
|
||||
_ES_RULES_SORTED = sorted(
|
||||
((_norm(k), v) for k, v in ES_RULES if _norm(k)), key=lambda kv: -len(kv[0])
|
||||
)
|
||||
|
||||
|
||||
def _key_usable(key):
|
||||
"""Generic single tokens ("sensor", "kit") create false positives."""
|
||||
tokens = key.split()
|
||||
if len(tokens) >= 2:
|
||||
return True
|
||||
return len(key) >= 9
|
||||
|
||||
|
||||
# ── Matchers ───────────────────────────────────────────────────────────────
|
||||
|
||||
def match_taxonomy(name):
|
||||
"""Match an item name against the Nexpart taxonomy.
|
||||
|
||||
Returns (group, subgroup, part_type, kind, confidence) or None.
|
||||
kind ∈ {'part_type', 'subgroup', 'group'}.
|
||||
"""
|
||||
n = _norm(name)
|
||||
if not n:
|
||||
return None
|
||||
padded = " " + n + " "
|
||||
|
||||
best = None # (key_len, kind_rank, group, subgroup, part_type)
|
||||
|
||||
for key, en in _PT_INDEX.items():
|
||||
if not _key_usable(key) or (" " + key + " ") not in padded:
|
||||
continue
|
||||
node = nt.tecdoc_to_nexpart(en)
|
||||
if not node:
|
||||
continue
|
||||
cand = (len(key), 3, node[0], node[1], node[2])
|
||||
if best is None or cand[:2] > best[:2]:
|
||||
best = cand
|
||||
|
||||
for key, (g, sg) in _NODE_INDEX.items():
|
||||
if not _key_usable(key) or (" " + key + " ") not in padded:
|
||||
continue
|
||||
rank = 2 if sg else 1
|
||||
cand = (len(key), rank, g, sg, None)
|
||||
if best is None or cand[:2] > best[:2]:
|
||||
best = cand
|
||||
|
||||
if not best:
|
||||
return None
|
||||
if best[1] == 3:
|
||||
return (best[2], best[3], best[4], "part_type", 0.85)
|
||||
if best[1] == 2:
|
||||
return (best[2], best[3], None, "subgroup", 0.65)
|
||||
return (best[2], None, None, "group", 0.5)
|
||||
|
||||
|
||||
def canonical_category(group, subgroup):
|
||||
"""Map a Nexpart (group, subgroup) to a canonical Spanish category."""
|
||||
if subgroup and subgroup in CANONICAL_BY_SUBGROUP:
|
||||
return CANONICAL_BY_SUBGROUP[subgroup]
|
||||
if group and group in CANONICAL_BY_GROUP:
|
||||
return CANONICAL_BY_GROUP[group]
|
||||
return "Otros"
|
||||
|
||||
|
||||
def match_rules(name):
|
||||
"""Supplemental Spanish keyword match. Returns (canonical, confidence)."""
|
||||
n = _norm(name)
|
||||
if not n:
|
||||
return None
|
||||
padded = " " + n + " "
|
||||
for key, canonical in _ES_RULES_SORTED:
|
||||
if " " + key + " " in padded:
|
||||
return canonical, 0.6
|
||||
return None
|
||||
|
||||
|
||||
def classify_name(name, brand=None):
|
||||
"""Rule-only classification. Returns (canonical, confidence, source)."""
|
||||
hit = match_taxonomy(name)
|
||||
if hit:
|
||||
group, subgroup, _pt, _kind, conf = hit
|
||||
return canonical_category(group, subgroup), conf, "taxonomy"
|
||||
|
||||
rule = match_rules(name)
|
||||
if rule:
|
||||
return rule[0], rule[1], "keyword"
|
||||
|
||||
brand_key = _norm(brand)
|
||||
if brand_key and brand_key in BRAND_HINTS:
|
||||
return BRAND_HINTS[brand_key], 0.4, "brand"
|
||||
|
||||
return None, 0.0, "none"
|
||||
|
||||
|
||||
# ── Tenant category resolution ─────────────────────────────────────────────
|
||||
|
||||
def _similarity(a, b):
|
||||
if not a or not b:
|
||||
return 0.0
|
||||
if a == b:
|
||||
return 1.0
|
||||
ta, tb = a.split(), b.split()
|
||||
if len(ta) >= 1 and len(tb) >= 1:
|
||||
seta, setb = set(ta), set(tb)
|
||||
# whole-string containment (word aligned) is a strong signal
|
||||
if setb <= seta or seta <= setb:
|
||||
return 0.85
|
||||
return SequenceMatcher(None, a, b).ratio()
|
||||
|
||||
|
||||
class CategoryResolver:
|
||||
"""Resolve canonical category names to rows in the tenant `categories`."""
|
||||
|
||||
def __init__(self, conn, create=True):
|
||||
self.conn = conn
|
||||
self.create = create
|
||||
self._cache = {}
|
||||
self._existing = None # list of (id, name, norm)
|
||||
self.available = self._load()
|
||||
|
||||
def _load(self):
|
||||
cur = self.conn.cursor()
|
||||
try:
|
||||
cur.execute("SELECT to_regclass('public.categories')")
|
||||
if cur.fetchone()[0] is None:
|
||||
return False
|
||||
cur.execute(
|
||||
"SELECT id, name FROM categories "
|
||||
"WHERE is_active IS NULL OR is_active = true"
|
||||
)
|
||||
self._existing = [(r[0], r[1], _norm(r[1])) for r in cur.fetchall()]
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
finally:
|
||||
cur.close()
|
||||
|
||||
def _best_existing(self, canonical):
|
||||
target = _norm(canonical)
|
||||
candidates = [target] + [_norm(a) for a in CATEGORY_ALIASES.get(canonical, [])]
|
||||
best_id, best_name, best_score = None, None, 0.0
|
||||
for cid, name, norm_name in self._existing:
|
||||
score = max(_similarity(norm_name, c) for c in candidates if c)
|
||||
if score > best_score:
|
||||
best_id, best_name, best_score = cid, name, score
|
||||
if best_score >= 0.72:
|
||||
return best_id, best_name
|
||||
return None, None
|
||||
|
||||
def resolve(self, canonical):
|
||||
"""Return (category_id, category_name) for a canonical category."""
|
||||
if canonical in self._cache:
|
||||
return self._cache[canonical]
|
||||
if not self.available:
|
||||
self._cache[canonical] = (None, canonical)
|
||||
return self._cache[canonical]
|
||||
|
||||
cid, name = self._best_existing(canonical)
|
||||
if cid is not None:
|
||||
self._cache[canonical] = (cid, name)
|
||||
return self._cache[canonical]
|
||||
|
||||
if not self.create:
|
||||
self._cache[canonical] = (None, canonical)
|
||||
return self._cache[canonical]
|
||||
|
||||
cur = self.conn.cursor()
|
||||
try:
|
||||
cur.execute(
|
||||
"INSERT INTO categories (name) VALUES (%s) RETURNING id",
|
||||
(canonical,),
|
||||
)
|
||||
cid = cur.fetchone()[0]
|
||||
self.conn.commit()
|
||||
finally:
|
||||
cur.close()
|
||||
self._existing.append((cid, canonical, _norm(canonical)))
|
||||
self._cache[canonical] = (cid, canonical)
|
||||
return self._cache[canonical]
|
||||
|
||||
|
||||
# ── Public API ─────────────────────────────────────────────────────────────
|
||||
|
||||
def categorize_item(conn, part_number, name=None, brand=None,
|
||||
use_llm=False, persist=False, resolver=None, create_categories=True):
|
||||
"""Classify a single item and optionally persist it.
|
||||
|
||||
Returns {part_number, category_id, category_name, canonical_category,
|
||||
confidence, source, persisted}.
|
||||
"""
|
||||
if resolver is None:
|
||||
resolver = CategoryResolver(conn, create=create_categories)
|
||||
|
||||
canonical, confidence, source = classify_name(name or part_number, brand)
|
||||
|
||||
if canonical is None and use_llm:
|
||||
canonical, confidence, source = _llm_category(part_number, name)
|
||||
|
||||
if canonical is None:
|
||||
result = {
|
||||
"part_number": part_number, "category_id": None,
|
||||
"category_name": None, "canonical_category": None,
|
||||
"confidence": 0.0, "source": "none", "persisted": 0,
|
||||
}
|
||||
else:
|
||||
cid, cname = resolver.resolve(canonical)
|
||||
result = {
|
||||
"part_number": part_number, "category_id": cid,
|
||||
"category_name": cname, "canonical_category": canonical,
|
||||
"confidence": round(confidence, 2), "source": source, "persisted": 0,
|
||||
}
|
||||
|
||||
if persist and result["category_id"] is not None and part_number:
|
||||
result["persisted"] = _persist_item_category(conn, part_number, result["category_id"])
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _llm_category(part_number, name=None):
|
||||
"""Ask ai_chat.classify_part for a category. Returns (canonical, conf, src)."""
|
||||
if not part_number:
|
||||
return None, 0.0, "none"
|
||||
try:
|
||||
from services.ai_chat import classify_part
|
||||
res = classify_part(str(part_number))
|
||||
except Exception:
|
||||
return None, 0.0, "none"
|
||||
if not res:
|
||||
return None, 0.0, "none"
|
||||
raw = (res.get("category") or "").strip()
|
||||
if not raw:
|
||||
return None, 0.0, "none"
|
||||
canon = _map_llm_category(raw)
|
||||
return canon, 0.5, "llm"
|
||||
|
||||
|
||||
def map_llm_category(raw):
|
||||
"""Public alias: map a free-text Spanish category (LLM output)."""
|
||||
return _map_llm_category(raw)
|
||||
|
||||
|
||||
def _map_llm_category(raw):
|
||||
"""Map a free-text Spanish category (LLM output) to a canonical name."""
|
||||
n = _norm(raw)
|
||||
if not n:
|
||||
return None
|
||||
# direct canonical / alias hit
|
||||
for canonical, aliases in CATEGORY_ALIASES.items():
|
||||
for a in [canonical] + list(aliases):
|
||||
if _norm(a) == n:
|
||||
return canonical
|
||||
# fall back to the keyword rules over the LLM's own words
|
||||
rule = match_rules(raw)
|
||||
if rule:
|
||||
return rule[0]
|
||||
return raw.strip()
|
||||
|
||||
|
||||
def _persist_item_category(conn, part_number, category_id):
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
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),
|
||||
)
|
||||
n = cur.rowcount
|
||||
conn.commit()
|
||||
return n
|
||||
finally:
|
||||
cur.close()
|
||||
|
||||
|
||||
def categorize_items(conn, items, use_llm=False, persist=False,
|
||||
create_categories=True, sample_limit=20):
|
||||
"""Categorize a list of {part_number, name, brand} dicts.
|
||||
|
||||
Returns {count, persisted, by_source, categories, results, sample}.
|
||||
"""
|
||||
# A dry run must not mutate the tenant's categories table.
|
||||
create_categories = create_categories and persist
|
||||
resolver = CategoryResolver(conn, create=create_categories)
|
||||
results = []
|
||||
by_source = {}
|
||||
persisted = 0
|
||||
for it in items or []:
|
||||
r = categorize_item(
|
||||
conn, it.get("part_number"), it.get("name"), it.get("brand"),
|
||||
use_llm=use_llm, persist=persist, resolver=resolver,
|
||||
create_categories=create_categories,
|
||||
)
|
||||
persisted += r.get("persisted", 0)
|
||||
by_source[r["source"]] = by_source.get(r["source"], 0) + 1
|
||||
results.append(r)
|
||||
|
||||
categories = {}
|
||||
for r in results:
|
||||
if r["category_name"]:
|
||||
categories[r["category_name"]] = categories.get(r["category_name"], 0) + 1
|
||||
|
||||
return {
|
||||
"count": len(results),
|
||||
"persisted": persisted,
|
||||
"by_source": by_source,
|
||||
"categories": categories,
|
||||
"results": results[:sample_limit] if sample_limit else results,
|
||||
"sample": results[:sample_limit],
|
||||
}
|
||||
|
||||
|
||||
def categorize_inventory(conn, limit=200, only_uncategorized=True, use_llm=False,
|
||||
persist=True, create_categories=True, offset=0,
|
||||
after_id=None, sample_limit=20):
|
||||
"""Categorize inventory rows in bulk.
|
||||
|
||||
``after_id`` enables checkpointed backfills (process rows with id > after_id).
|
||||
Returns {scanned, persisted, by_source, categories, sample, last_id}.
|
||||
"""
|
||||
cur = conn.cursor()
|
||||
where = ["is_active = true"]
|
||||
params = []
|
||||
if only_uncategorized:
|
||||
where.append("(category_id IS NULL)")
|
||||
if after_id is not None:
|
||||
where.append("id > %s")
|
||||
params.append(after_id)
|
||||
sql = (
|
||||
"SELECT id, part_number, name, brand FROM inventory "
|
||||
"WHERE " + " AND ".join(where) +
|
||||
" ORDER BY id LIMIT %s OFFSET %s"
|
||||
)
|
||||
params.extend([int(limit), int(offset)])
|
||||
cur.execute(sql, params)
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
|
||||
# A dry run must not mutate the tenant's categories table.
|
||||
create_categories = create_categories and persist
|
||||
resolver = CategoryResolver(conn, create=create_categories)
|
||||
by_source = {}
|
||||
categories = {}
|
||||
persisted = 0
|
||||
sample = []
|
||||
last_id = after_id
|
||||
|
||||
for inv_id, part_number, name, brand in rows:
|
||||
canonical, confidence, source = classify_name(name or part_number, brand)
|
||||
if canonical is None and use_llm:
|
||||
canonical, confidence, source = _llm_category(part_number, name)
|
||||
|
||||
cid, cname = (None, None)
|
||||
if canonical:
|
||||
cid, cname = resolver.resolve(canonical)
|
||||
|
||||
n = 0
|
||||
if persist and cid is not None:
|
||||
c2 = conn.cursor()
|
||||
try:
|
||||
c2.execute(
|
||||
"UPDATE inventory SET category_id = %s "
|
||||
"WHERE id = %s AND (category_id IS NULL OR category_id <> %s)",
|
||||
(cid, inv_id, cid),
|
||||
)
|
||||
n = c2.rowcount
|
||||
conn.commit()
|
||||
finally:
|
||||
c2.close()
|
||||
persisted += n
|
||||
by_source[source] = by_source.get(source, 0) + 1
|
||||
if cname:
|
||||
categories[cname] = categories.get(cname, 0) + 1
|
||||
if len(sample) < sample_limit:
|
||||
sample.append({
|
||||
"inventory_id": inv_id, "part_number": part_number, "name": name,
|
||||
"category_id": cid, "category_name": cname,
|
||||
"canonical_category": canonical,
|
||||
"confidence": round(confidence, 2), "source": source,
|
||||
})
|
||||
last_id = inv_id
|
||||
|
||||
return {
|
||||
"scanned": len(rows),
|
||||
"persisted": persisted,
|
||||
"by_source": by_source,
|
||||
"categories": categories,
|
||||
"sample": sample,
|
||||
"last_id": last_id,
|
||||
}
|
||||
|
||||
|
||||
def stats():
|
||||
"""Diagnostic helper for the rule engine."""
|
||||
return {
|
||||
"part_type_keys": len(_PT_INDEX),
|
||||
"node_keys": len(_NODE_INDEX),
|
||||
"es_rules": len(_ES_RULES_SORTED),
|
||||
"brand_hints": len(BRAND_HINTS),
|
||||
"canonical_categories": sorted(set(CANONICAL_BY_GROUP.values())),
|
||||
}
|
||||
255
pos/services/prom_metrics.py
Normal file
255
pos/services/prom_metrics.py
Normal file
@@ -0,0 +1,255 @@
|
||||
"""Expositor Prometheus mínimo, sin dependencias externas.
|
||||
|
||||
El Python de producción no trae ``prometheus_client`` (y no se instala), así que
|
||||
el formato de texto de Prometheus 0.0.4 se arma a mano.
|
||||
|
||||
El registro vive en memoria del proceso y es thread-safe (``threading.Lock``);
|
||||
con gunicorn multiproceso cada worker lleva sus propios contadores y Prometheus
|
||||
scrapea al que responda. Para tasas y latencias eso es suficiente; si algún día
|
||||
se necesita un contador global exacto hay que habilitar el modo multiproceso con
|
||||
un directorio compartido.
|
||||
|
||||
Uso::
|
||||
|
||||
from services.prom_metrics import metrics
|
||||
|
||||
metrics.configure('pos')
|
||||
metrics.request_started() # before_request
|
||||
metrics.request_finished(method, path, status, duration) # after_request
|
||||
metrics.record_exception() # teardown_request (exc)
|
||||
metrics.request_aborted() # teardown si no hubo after_request
|
||||
body = metrics.render() # GET /metrics
|
||||
"""
|
||||
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
|
||||
CONTENT_TYPE = 'text/plain; version=0.0.4; charset=utf-8'
|
||||
|
||||
DEFAULT_BUCKETS = (0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0)
|
||||
|
||||
# Tope de series distintas por métrica; lo que se salga de aquí se agrupa en
|
||||
# path="/otras" para que una ruta inesperada no infle la memoria.
|
||||
MAX_SERIES = 400
|
||||
|
||||
# La consulta a pg_stat_activity es best-effort y se cachea unos segundos.
|
||||
DB_GAUGE_TTL = 5.0
|
||||
|
||||
|
||||
def escape_label(value):
|
||||
return str(value).replace('\\', '\\\\').replace('"', '\\"').replace('\n', '\\n')
|
||||
|
||||
|
||||
def format_labels(pairs):
|
||||
if not pairs:
|
||||
return ''
|
||||
body = ','.join(f'{k}="{escape_label(v)}"' for k, v in pairs)
|
||||
return '{' + body + '}'
|
||||
|
||||
|
||||
def normalize_path(rule, path):
|
||||
"""Colapsa una petición a un valor de label acotado.
|
||||
|
||||
Si Flask resolvió una regla (``request.url_rule.rule``) se usa tal cual, que
|
||||
ya viene en forma plantilla (``/pos/api/inventory/items/<int:item_id>``).
|
||||
Para 404 (sin regla) sólo se conserva el primer segmento.
|
||||
"""
|
||||
if rule:
|
||||
return rule
|
||||
segments = [s for s in (path or '/').split('/') if s]
|
||||
if not segments:
|
||||
return '/'
|
||||
if len(segments) == 1:
|
||||
return '/' + segments[0]
|
||||
return '/' + segments[0] + '/...'
|
||||
|
||||
|
||||
def _master_dsn():
|
||||
dsn = os.environ.get('MASTER_DB_URL') or os.environ.get('DATABASE_URL')
|
||||
if dsn:
|
||||
return dsn
|
||||
try:
|
||||
from config import MASTER_DB_URL
|
||||
return MASTER_DB_URL
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
class Metrics:
|
||||
def __init__(self, app='app', version=None, buckets=DEFAULT_BUCKETS):
|
||||
self._lock = threading.Lock()
|
||||
self._app = app
|
||||
self._version = version or os.environ.get('NEXUS_APP_VERSION', 'dev')
|
||||
self._buckets = tuple(sorted(buckets))
|
||||
self._start = time.time()
|
||||
self._requests = {} # (method, path, status_class) -> count
|
||||
self._errors = {} # (method, path, status_class) -> count (4xx/5xx)
|
||||
self._durations = {} # (method, path) -> [sum, count, [bucket counts]]
|
||||
self._exceptions = 0
|
||||
self._in_flight = 0
|
||||
self._db_cache = (0.0, None)
|
||||
|
||||
# ─── Instrumentación ────────────────────────────────────────────────
|
||||
def configure(self, app=None, version=None):
|
||||
with self._lock:
|
||||
if app:
|
||||
self._app = app
|
||||
if version:
|
||||
self._version = version
|
||||
|
||||
def request_started(self):
|
||||
with self._lock:
|
||||
self._in_flight += 1
|
||||
|
||||
def request_finished(self, method, path, status, duration):
|
||||
method = method or 'GET'
|
||||
path = path or '/'
|
||||
try:
|
||||
status_class = f'{int(status) // 100}xx'
|
||||
except (TypeError, ValueError):
|
||||
status_class = 'other'
|
||||
duration = max(0.0, float(duration or 0.0))
|
||||
|
||||
with self._lock:
|
||||
self._in_flight = max(0, self._in_flight - 1)
|
||||
|
||||
key = (method, path, status_class)
|
||||
if key not in self._requests and len(self._requests) >= MAX_SERIES:
|
||||
key = (method, '/otras', status_class)
|
||||
self._requests[key] = self._requests.get(key, 0) + 1
|
||||
|
||||
if status_class in ('4xx', '5xx'):
|
||||
ekey = (method, path, status_class)
|
||||
if ekey not in self._errors and len(self._errors) >= MAX_SERIES:
|
||||
ekey = (method, '/otras', status_class)
|
||||
self._errors[ekey] = self._errors.get(ekey, 0) + 1
|
||||
|
||||
dkey = (method, path)
|
||||
if dkey not in self._durations and len(self._durations) >= MAX_SERIES:
|
||||
dkey = (method, '/otras')
|
||||
entry = self._durations.get(dkey)
|
||||
if entry is None:
|
||||
entry = [0.0, 0, [0] * len(self._buckets)]
|
||||
self._durations[dkey] = entry
|
||||
entry[0] += duration
|
||||
entry[1] += 1
|
||||
for i, upper in enumerate(self._buckets):
|
||||
if duration <= upper:
|
||||
entry[2][i] += 1
|
||||
|
||||
def request_aborted(self):
|
||||
"""Petición que no pasó por after_request (excepción que no se convirtió en respuesta)."""
|
||||
with self._lock:
|
||||
self._in_flight = max(0, self._in_flight - 1)
|
||||
|
||||
def record_exception(self):
|
||||
with self._lock:
|
||||
self._exceptions += 1
|
||||
|
||||
# ─── Gauge de conexiones (consulta en el momento del scrape) ────────
|
||||
def _db_connections(self):
|
||||
now = time.time()
|
||||
cached_at, cached_value = self._db_cache
|
||||
if now - cached_at < DB_GAUGE_TTL:
|
||||
return cached_value
|
||||
|
||||
value = None
|
||||
dsn = _master_dsn()
|
||||
if dsn:
|
||||
try:
|
||||
import psycopg2
|
||||
conn = psycopg2.connect(dsn, connect_timeout=2)
|
||||
try:
|
||||
conn.autocommit = True
|
||||
cur = conn.cursor()
|
||||
cur.execute('SET statement_timeout = 2000')
|
||||
cur.execute('SELECT count(*) FROM pg_stat_activity')
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
if row:
|
||||
value = int(row[0])
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception:
|
||||
value = None
|
||||
|
||||
with self._lock:
|
||||
self._db_cache = (now, value)
|
||||
return value
|
||||
|
||||
# ─── Render ─────────────────────────────────────────────────────────
|
||||
def render(self):
|
||||
with self._lock:
|
||||
app = self._app
|
||||
version = self._version
|
||||
start = self._start
|
||||
requests = dict(self._requests)
|
||||
errors = dict(self._errors)
|
||||
durations = {k: (v[0], v[1], list(v[2])) for k, v in self._durations.items()}
|
||||
exceptions = self._exceptions
|
||||
in_flight = self._in_flight
|
||||
|
||||
db_connections = self._db_connections()
|
||||
now = time.time()
|
||||
out = []
|
||||
|
||||
def head(name, kind, help_text):
|
||||
out.append(f'# HELP {name} {help_text}')
|
||||
out.append(f'# TYPE {name} {kind}')
|
||||
|
||||
head('nexus_app_info', 'gauge', 'Application build information.')
|
||||
out.append(f'nexus_app_info{format_labels([("app", app), ("version", version)])} 1')
|
||||
|
||||
head('nexus_uptime_seconds', 'gauge', 'Seconds since the process started.')
|
||||
out.append(f'nexus_uptime_seconds{format_labels([("app", app)])} {now - start:.3f}')
|
||||
|
||||
head('nexus_process_start_time_seconds', 'gauge', 'Unix time the process started.')
|
||||
out.append(f'nexus_process_start_time_seconds{format_labels([("app", app)])} {start:.3f}')
|
||||
|
||||
head('nexus_http_requests_in_flight', 'gauge', 'Requests currently being processed.')
|
||||
out.append(f'nexus_http_requests_in_flight{format_labels([("app", app)])} {in_flight}')
|
||||
|
||||
head('nexus_http_requests_total', 'counter',
|
||||
'HTTP requests by method, normalized path and status class.')
|
||||
for method, path, status_class in sorted(requests):
|
||||
labels = format_labels([('app', app), ('method', method),
|
||||
('path', path), ('status', status_class)])
|
||||
out.append(f'nexus_http_requests_total{labels} {requests[(method, path, status_class)]}')
|
||||
|
||||
head('nexus_http_errors_total', 'counter',
|
||||
'HTTP responses with a 4xx or 5xx status class.')
|
||||
for method, path, status_class in sorted(errors):
|
||||
labels = format_labels([('app', app), ('method', method),
|
||||
('path', path), ('status', status_class)])
|
||||
out.append(f'nexus_http_errors_total{labels} {errors[(method, path, status_class)]}')
|
||||
|
||||
if durations:
|
||||
head('nexus_http_request_duration_seconds', 'histogram',
|
||||
'HTTP request latency by method and normalized path.')
|
||||
for method, path in sorted(durations):
|
||||
total, count, buckets = durations[(method, path)]
|
||||
base = [('app', app), ('method', method), ('path', path)]
|
||||
for i, upper in enumerate(self._buckets):
|
||||
labels = format_labels(base + [('le', str(upper))])
|
||||
out.append(f'nexus_http_request_duration_seconds_bucket{labels} {buckets[i]}')
|
||||
inf = format_labels(base + [('le', '+Inf')])
|
||||
out.append(f'nexus_http_request_duration_seconds_bucket{inf} {count}')
|
||||
labels = format_labels(base)
|
||||
out.append(f'nexus_http_request_duration_seconds_sum{labels} {total:.6f}')
|
||||
out.append(f'nexus_http_request_duration_seconds_count{labels} {count}')
|
||||
|
||||
head('nexus_app_exceptions_total', 'counter',
|
||||
'Unhandled exceptions raised while serving a request.')
|
||||
out.append(f'nexus_app_exceptions_total{format_labels([("app", app)])} {exceptions}')
|
||||
|
||||
if db_connections is not None:
|
||||
head('nexus_db_connections', 'gauge',
|
||||
'Open connections on the master database (pg_stat_activity).')
|
||||
labels = format_labels([('app', app), ('database', 'master')])
|
||||
out.append(f'nexus_db_connections{labels} {db_connections}')
|
||||
|
||||
return '\n'.join(out) + '\n'
|
||||
|
||||
|
||||
metrics = Metrics()
|
||||
@@ -45,11 +45,11 @@ def get_or_create_vapid_keys(conn):
|
||||
|
||||
# Check if keys already exist in config
|
||||
try:
|
||||
cur.execute("SELECT value FROM config WHERE key = 'vapid_private_key'")
|
||||
cur.execute("SELECT value FROM tenant_config WHERE key = 'vapid_private_key'")
|
||||
row = cur.fetchone()
|
||||
if row:
|
||||
private_key = row[0]
|
||||
cur.execute("SELECT value FROM config WHERE key = 'vapid_public_key'")
|
||||
cur.execute("SELECT value FROM tenant_config WHERE key = 'vapid_public_key'")
|
||||
pub_row = cur.fetchone()
|
||||
public_key = pub_row[0] if pub_row else None
|
||||
cur.close()
|
||||
@@ -68,11 +68,11 @@ def get_or_create_vapid_keys(conn):
|
||||
# Store in config table
|
||||
try:
|
||||
cur.execute("""
|
||||
INSERT INTO config (key, value) VALUES ('vapid_private_key', %s)
|
||||
INSERT INTO tenant_config (key, value) VALUES ('vapid_private_key', %s)
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
||||
""", (private_pem,))
|
||||
cur.execute("""
|
||||
INSERT INTO config (key, value) VALUES ('vapid_public_key', %s)
|
||||
INSERT INTO tenant_config (key, value) VALUES ('vapid_public_key', %s)
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
||||
""", (public_b64,))
|
||||
conn.commit()
|
||||
|
||||
325
pos/services/question_ai.py
Normal file
325
pos/services/question_ai.py
Normal file
@@ -0,0 +1,325 @@
|
||||
# /home/Autopartes/pos/services/question_ai.py
|
||||
"""A7 — Intent classification + suggested answers for MercadoLibre questions.
|
||||
|
||||
Given a buyer question (optionally tied to a listing/inventory item) this
|
||||
service:
|
||||
1. Classifies the intent: compatibility | price | shipping | availability | other.
|
||||
2. Builds a suggested answer from real data — ``inventory`` (name/brand/price),
|
||||
``inventory_vehicle_compat`` (fitment) and stock — and falls back to the
|
||||
LLM (``services.ai_chat``) when the question needs free text.
|
||||
|
||||
Public API:
|
||||
classify_intent(text) -> (intent, confidence)
|
||||
suggest_answer(tenant_conn, question_text, inventory_id=None, ...) -> dict
|
||||
get_question_suggestion(tenant_conn, question_id, ...) -> dict
|
||||
|
||||
Returned dict:
|
||||
{
|
||||
"intent": str,
|
||||
"suggested_answer": str,
|
||||
"confidence": float, # 0.0 - 1.0 (intent + data certainty)
|
||||
"source": str, # rules | inventory | compat_db | template | llm | none
|
||||
"auto_ok": bool, # safe to auto-send (high confidence + template)
|
||||
"evidence": dict # data used to build the answer
|
||||
}
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
INTENTS = ('compatibility', 'price', 'shipping', 'availability', 'other')
|
||||
|
||||
# Ordered by priority: fitment questions often also contain "tienen"/"hay",
|
||||
# so compatibility is matched first.
|
||||
_INTENT_PATTERNS = [
|
||||
('compatibility', [
|
||||
r'compatib', r'\bsirve\b', r'\bsirven\b', r'le queda', r'queda (a|en|para)',
|
||||
r'le queda a', r'es para (un|una|mi)', r'funciona (para|en)', r'aplica para',
|
||||
r'\bfits?\b', r'quedar[áa]', r'le sirve', r'modelo \d{4}',
|
||||
]),
|
||||
('price', [
|
||||
r'\bprecio\b', r'cu[áa]nto (cuesta|vale|es|sale)', r'\bcosto\b', r'\bvale\b',
|
||||
r'\bdescuento\b', r'\bfactura\b', r'\bmeses\b', r'\bmsi\b', r'\bbarato\b',
|
||||
r'\bpago\b', r'\bcuotas?\b',
|
||||
]),
|
||||
('shipping', [
|
||||
r'env[íi]o', r'env[íi]an', r'\benvias?\b', r'\bmandan\b', r'\bllega\b',
|
||||
r'\bentrega\b', r'paqueter[íi]a', r'\bgu[íi]a\b', r'mensajer[íi]a',
|
||||
r'\benvio\b',
|
||||
]),
|
||||
('availability', [
|
||||
r'\btienes\b', r'\btiene[n]?\b', r'\bhay\b', r'disponib', r'\bstock\b',
|
||||
r'existencia', r'inventario', r'cu[áa]ntas? (piezas|unidades|tienes)',
|
||||
r'tienen en',
|
||||
]),
|
||||
]
|
||||
|
||||
_YEAR_RE = re.compile(r'\b(19[89]\d|20[0-3]\d)\b')
|
||||
|
||||
|
||||
def classify_intent(text):
|
||||
"""Rule-based intent classifier. Returns (intent, confidence)."""
|
||||
if not text:
|
||||
return 'other', 0.0
|
||||
low = str(text).lower()
|
||||
|
||||
for intent, patterns in _INTENT_PATTERNS:
|
||||
for pat in patterns:
|
||||
if re.search(pat, low):
|
||||
return intent, 0.85
|
||||
|
||||
# A bare model-year usually means "does it fit?".
|
||||
if _YEAR_RE.search(low):
|
||||
return 'compatibility', 0.6
|
||||
|
||||
return 'other', 0.35
|
||||
|
||||
|
||||
# ─── data helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
def _inventory_row(conn, inventory_id):
|
||||
"""Return a small dict for the inventory item backing a listing."""
|
||||
if not inventory_id:
|
||||
return None
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
cur.execute("""
|
||||
SELECT i.id, i.part_number, i.name, i.brand, i.price_1, i.condition, c.name
|
||||
FROM inventory i
|
||||
LEFT JOIN part_categories c ON c.id = i.category_id
|
||||
WHERE i.id = %s
|
||||
""", (inventory_id,))
|
||||
row = cur.fetchone()
|
||||
except Exception:
|
||||
return None
|
||||
finally:
|
||||
cur.close()
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
'id': row[0], 'part_number': row[1] or '', 'name': row[2] or '',
|
||||
'brand': row[3] or '', 'price': float(row[4] or 0), 'condition': row[5] or '',
|
||||
'category': row[6] or '',
|
||||
}
|
||||
|
||||
|
||||
def _compat_summary(conn, inventory_id, limit=6):
|
||||
"""Human-readable fitment list from inventory_vehicle_compat (text rows)."""
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
cur.execute("""
|
||||
SELECT make, model, year
|
||||
FROM inventory_vehicle_compat
|
||||
WHERE inventory_id = %s AND (make IS NOT NULL OR model IS NOT NULL)
|
||||
GROUP BY make, model, year
|
||||
ORDER BY make, model, year
|
||||
LIMIT %s
|
||||
""", (inventory_id, limit))
|
||||
rows = cur.fetchall()
|
||||
except Exception:
|
||||
return [], 0
|
||||
finally:
|
||||
cur.close()
|
||||
|
||||
lines = []
|
||||
for make, model, year in rows:
|
||||
parts = [str(p).strip() for p in (make, model) if p]
|
||||
label = ' '.join(parts)
|
||||
if year:
|
||||
label = f"{label} {year}".strip()
|
||||
if label:
|
||||
lines.append(label)
|
||||
return lines, len(lines)
|
||||
|
||||
|
||||
def _trailing_confirm():
|
||||
return ("Si tu vehículo no aparece, compárteme marca, modelo y año "
|
||||
"(o los últimos 8 dígitos de tu VIN) y te confirmo la compatibilidad.")
|
||||
|
||||
|
||||
def _config_value(conn, key, default=None):
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
cur.execute("SELECT value FROM tenant_config WHERE key = %s", (key,))
|
||||
row = cur.fetchone()
|
||||
return row[0] if row and row[0] else default
|
||||
except Exception:
|
||||
return default
|
||||
finally:
|
||||
cur.close()
|
||||
|
||||
|
||||
def _stock(conn, inventory_id):
|
||||
try:
|
||||
from services.inventory_engine import get_stock
|
||||
return get_stock(conn, inventory_id)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# ai_chat returns these generic strings when every backend fails. They are not
|
||||
# real answers, so a question must fall back to the template instead.
|
||||
_LLM_ERROR_PREFIXES = (
|
||||
'El asistente está ocupado',
|
||||
'El asistente esta ocupado',
|
||||
'El asistente tardó mucho',
|
||||
'El asistente tardo mucho',
|
||||
'El asistente no está disponible',
|
||||
'El asistente no esta disponible',
|
||||
)
|
||||
|
||||
|
||||
def _llm_answer(tenant_conn, question_text, item):
|
||||
"""Free-text answer via ai_chat. Returns (text, error)."""
|
||||
try:
|
||||
from services import ai_chat
|
||||
except Exception as e:
|
||||
return None, f'ai_chat import: {e}'
|
||||
|
||||
context = None
|
||||
if item:
|
||||
context = (f"Producto publicado: {item['name']} (marca {item['brand'] or 'N/D'}, "
|
||||
f"código {item['part_number'] or 'N/D'}). Precio ${item['price']:.2f} MXN.")
|
||||
prompt = ("Eres un vendedor de autopartes que responde preguntas de compradores "
|
||||
f"en MercadoLibre. Responde breve (máximo 3 líneas), en español y con tono "
|
||||
f"amable. Nunca inventes compatibilidad que no puedas confirmar.\n\nPregunta: {question_text}")
|
||||
try:
|
||||
res = ai_chat.chat(prompt, inventory_context=context)
|
||||
except Exception as e:
|
||||
return None, str(e)
|
||||
if isinstance(res, dict):
|
||||
msg = (res.get('message') or '').strip()
|
||||
if msg and not msg.startswith(_LLM_ERROR_PREFIXES):
|
||||
return msg, None
|
||||
if msg:
|
||||
return None, f'backend sin respuesta: {msg}'
|
||||
return None, 'sin respuesta del modelo'
|
||||
|
||||
|
||||
# ─── answer builder ─────────────────────────────────────────────────────────
|
||||
|
||||
def suggest_answer(tenant_conn, question_text, inventory_id=None, listing_id=None,
|
||||
master_conn=None, use_llm=True):
|
||||
"""Classify a question and build a suggested answer.
|
||||
|
||||
``tenant_conn`` is an open tenant connection (not committed). The function
|
||||
never raises for backend/data failures: it degrades to a template answer.
|
||||
"""
|
||||
intent, conf = classify_intent(question_text)
|
||||
item = _inventory_row(tenant_conn, inventory_id) if inventory_id else None
|
||||
|
||||
out = {
|
||||
'intent': intent,
|
||||
'suggested_answer': '',
|
||||
'confidence': conf,
|
||||
'source': 'rules',
|
||||
'auto_ok': False,
|
||||
'evidence': {},
|
||||
'listing_id': listing_id,
|
||||
'inventory_id': inventory_id,
|
||||
}
|
||||
if item:
|
||||
out['evidence']['item'] = {
|
||||
'part_number': item['part_number'], 'name': item['name'],
|
||||
'brand': item['brand'], 'price': item['price'],
|
||||
}
|
||||
|
||||
if intent == 'compatibility' and inventory_id:
|
||||
lines, n = _compat_summary(tenant_conn, inventory_id)
|
||||
out['evidence']['compat_count'] = n
|
||||
if lines:
|
||||
out['suggested_answer'] = (
|
||||
"¡Sí! Esta pieza es compatible con: " + ", ".join(lines) + ". " + _trailing_confirm()
|
||||
)
|
||||
out['confidence'] = 0.9
|
||||
out['source'] = 'compat_db'
|
||||
# Fitment can still be model/trim specific -> human review, no auto-send.
|
||||
out['auto_ok'] = False
|
||||
return out
|
||||
|
||||
elif intent == 'price' and item and item['price'] > 0:
|
||||
out['suggested_answer'] = (
|
||||
f"El precio es ${item['price']:,.2f} MXN. "
|
||||
"El envío se calcula al finalizar la compra según tu código postal. ¿Te ayudo con algo más?"
|
||||
)
|
||||
out['confidence'] = 0.9
|
||||
out['source'] = 'inventory'
|
||||
out['auto_ok'] = True
|
||||
return out
|
||||
|
||||
elif intent == 'availability' and inventory_id:
|
||||
stock = _stock(tenant_conn, inventory_id)
|
||||
out['evidence']['stock'] = stock
|
||||
if stock is not None:
|
||||
if stock > 0:
|
||||
out['suggested_answer'] = (
|
||||
f"Sí, tenemos {stock} {'pieza' if stock == 1 else 'piezas'} disponibles. "
|
||||
"Puedes comprar directo desde la publicación."
|
||||
)
|
||||
out['confidence'] = 0.9
|
||||
else:
|
||||
out['suggested_answer'] = (
|
||||
"Por el momento no tenemos existencia de esta pieza. "
|
||||
"Déjame tu pregunta y te aviso en cuanto llegue."
|
||||
)
|
||||
out['confidence'] = 0.8
|
||||
out['source'] = 'inventory'
|
||||
out['auto_ok'] = True
|
||||
return out
|
||||
|
||||
elif intent == 'shipping':
|
||||
mode = _config_value(tenant_conn, 'meli_shipping_mode', '')
|
||||
extra = f" Modalidad: {mode}." if mode else ''
|
||||
out['suggested_answer'] = (
|
||||
"El costo y tiempo de envío se muestran al ingresar tu código postal en MercadoLibre." + extra +
|
||||
" Si estás en la ciudad, también puedes pasar a recogerlo a la tienda."
|
||||
)
|
||||
out['confidence'] = 0.75
|
||||
out['source'] = 'template'
|
||||
out['auto_ok'] = out['confidence'] >= 0.8
|
||||
return out
|
||||
|
||||
# Fallback: free text for "other", or when the data needed was missing.
|
||||
if use_llm:
|
||||
text, err = _llm_answer(tenant_conn, question_text, item)
|
||||
if text:
|
||||
out['suggested_answer'] = text
|
||||
out['source'] = 'llm'
|
||||
out['confidence'] = max(out['confidence'], 0.6)
|
||||
out['auto_ok'] = False
|
||||
return out
|
||||
out['evidence']['llm_error'] = err
|
||||
|
||||
if not out['suggested_answer']:
|
||||
if intent == 'compatibility':
|
||||
out['suggested_answer'] = ("Con gusto te confirmo la compatibilidad. " + _trailing_confirm())
|
||||
else:
|
||||
out['suggested_answer'] = (
|
||||
"¡Hola! Gracias por tu pregunta; en un momento te atendemos con el detalle."
|
||||
)
|
||||
out['source'] = 'template'
|
||||
out['confidence'] = min(out['confidence'], 0.5)
|
||||
out['auto_ok'] = False
|
||||
return out
|
||||
|
||||
|
||||
def get_question_suggestion(tenant_conn, question_id, master_conn=None, use_llm=True):
|
||||
"""Resolve a local marketplace question then build its suggested answer."""
|
||||
cur = tenant_conn.cursor()
|
||||
try:
|
||||
cur.execute("""
|
||||
SELECT q.question_text, q.listing_id, l.inventory_id
|
||||
FROM marketplace_questions q
|
||||
LEFT JOIN marketplace_listings l ON l.id = q.listing_id
|
||||
WHERE q.id = %s
|
||||
""", (question_id,))
|
||||
row = cur.fetchone()
|
||||
finally:
|
||||
cur.close()
|
||||
if not row:
|
||||
raise ValueError('Question not found')
|
||||
|
||||
q_text, listing_id, inventory_id = row
|
||||
return suggest_answer(
|
||||
tenant_conn, q_text or '', inventory_id=inventory_id,
|
||||
listing_id=listing_id, master_conn=master_conn, use_llm=use_llm,
|
||||
)
|
||||
338
pos/services/sat_keys.py
Normal file
338
pos/services/sat_keys.py
Normal file
@@ -0,0 +1,338 @@
|
||||
# /home/Autopartes/pos/services/sat_keys.py
|
||||
"""SAT (CFDI 4.0) product/unit key resolution for auto parts.
|
||||
|
||||
Assigns ``clave_prod_serv`` (``c_ClaveProdServ``) and ``clave_unidad``
|
||||
(``c_ClaveUnidad``) per item from the part name/brand/category using, in order:
|
||||
|
||||
1. ``RULES`` — longest keyword hit over the normalized Spanish name.
|
||||
2. ``CANONICAL_BY_CATEGORY`` — a canonical category (from
|
||||
``product_categorizer``) mapped to a code.
|
||||
3. an optional LLM fallback (``ai_chat.classify_part`` → category → rule).
|
||||
4. ``DEFAULT_*`` (``25174800`` / ``H87``) — the current hard-coded default.
|
||||
|
||||
Codes come from the public SAT group ``2517`` (autopartes) suggestions; see
|
||||
scripts/assign_sat_keys.py for provenance. **Never raises**: any missing
|
||||
template/LLM failure degrades to ``DEFAULT_*`` so the CFDI pipeline cannot
|
||||
break because of this module.
|
||||
|
||||
Public API:
|
||||
resolve_key(name=None, brand=None, category=None, canonical=None,
|
||||
part_number=None, use_llm=False) -> (clave_prod_serv, clave_unidad, source)
|
||||
"""
|
||||
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
DEFAULT_KEY = "25174800" # Componentes y sistemas de vehículos / autopartes
|
||||
DEFAULT_UNIT = "H87" # Pieza
|
||||
|
||||
# Canonical Spanish categories (product_categorizer.CANONICAL_*) -> SAT key.
|
||||
CANONICAL_BY_CATEGORY = {
|
||||
"Frenos": "25171700",
|
||||
"Baleros y Mazas": "25171900",
|
||||
"Suspensión": "25172000",
|
||||
"Dirección": "25172000",
|
||||
"Motor": "26101700",
|
||||
"Enfriamiento": "25174000",
|
||||
"Encendido": "25173900",
|
||||
"Filtros": "40161500",
|
||||
"Filtros y Encendido": "40161500",
|
||||
"Eléctrico": "25173900",
|
||||
"Combustible y Emisiones": "25172400",
|
||||
"Aire Acondicionado": "40101800",
|
||||
"Transmisión": "26111800",
|
||||
"Clutch": "26111900",
|
||||
"Tren Motriz": "25173800",
|
||||
"Escape": "25173700",
|
||||
"Luces": "25172900",
|
||||
"Limpiaparabrisas": "25171500",
|
||||
"Luces y Limpiaparabrisas": "25172900",
|
||||
"Carrocería": "25172600",
|
||||
"Lubricantes y Químicos": "15121500",
|
||||
"Llantas y Accesorios": "25172500",
|
||||
"Otros": DEFAULT_KEY,
|
||||
}
|
||||
|
||||
# Keyword rules over the normalized name. ORDER MATTERS (first hit wins), so
|
||||
# more specific terms go first. Keys are already normalized (uppercase,
|
||||
# accent-stripped, single-spaced).
|
||||
RULES = [
|
||||
# ── Exhaust / emissions ────────────────────────────────────────────────
|
||||
("catalizador", "12161600"),
|
||||
("convertidor catalitico", "12161600"),
|
||||
("mofle", "25173700"),
|
||||
("silenciador", "25173700"),
|
||||
("sistema de escape", "25173700"),
|
||||
("tubo de escape", "25173700"),
|
||||
("linea de escape", "25173700"),
|
||||
("multiple de escape", "25173700"),
|
||||
("sensor de oxigeno", "25173700"),
|
||||
# ── Clutch ─────────────────────────────────────────────────────────────
|
||||
("kit de clutch", "26111900"),
|
||||
("embrague", "26111900"),
|
||||
("clutch", "26111900"),
|
||||
("plato de clutch", "26112000"),
|
||||
("disco de clutch", "26112000"),
|
||||
# ── Batteries ──────────────────────────────────────────────────────────
|
||||
("bateria", "26111700"),
|
||||
("acumulador", "26111700"),
|
||||
# ── Tires / wheels ─────────────────────────────────────────────────────
|
||||
("llanta", "25172500"),
|
||||
("neumatico", "25172500"),
|
||||
("rin", "25172500"),
|
||||
# ── Wipers ─────────────────────────────────────────────────────────────
|
||||
("limpiaparabrisas", "25171500"),
|
||||
("pluma limpia", "25171500"),
|
||||
("wiper", "25171500"),
|
||||
("brazo limpiaparabrisas", "25171500"),
|
||||
# ── Suspension / steering ──────────────────────────────────────────────
|
||||
("amortiguador", "25172000"),
|
||||
("horquilla", "25172000"),
|
||||
("rotula", "25172000"),
|
||||
("bieleta", "25172000"),
|
||||
("espiga", "25172000"),
|
||||
("base amortiguador", "25172000"),
|
||||
("resorte", "25172000"),
|
||||
("suspension", "25172000"),
|
||||
("cremallera", "25172000"),
|
||||
("terminal de direccion", "25172000"),
|
||||
("barra de direccion", "25172000"),
|
||||
("direccion", "25172000"),
|
||||
# ── Brakes ─────────────────────────────────────────────────────────────
|
||||
("balata", "25171700"),
|
||||
("pastilla", "25171700"),
|
||||
("disco de freno", "25171700"),
|
||||
("tambor", "25171700"),
|
||||
("cilindro de freno", "25171700"),
|
||||
("freno", "25171700"),
|
||||
("birlo", "25171700"),
|
||||
# ── Wheel bearings / hubs ──────────────────────────────────────────────
|
||||
("maza", "25171900"),
|
||||
("balero", "25171900"),
|
||||
("husillo", "25171900"),
|
||||
# ── Cooling ────────────────────────────────────────────────────────────
|
||||
("radiador", "25174000"),
|
||||
("termostato", "25174000"),
|
||||
("bomba de agua", "25174000"),
|
||||
("manguera", "25174000"),
|
||||
("banda", "25174000"),
|
||||
("polea", "25174000"),
|
||||
("motoventilador", "25174000"),
|
||||
("ventilador", "25174000"),
|
||||
("refrigerante", "25174000"),
|
||||
("anticongelante", "25174000"),
|
||||
# ── Filters ────────────────────────────────────────────────────────────
|
||||
("filtro", "40161500"),
|
||||
# ── Electrical / ignition ──────────────────────────────────────────────
|
||||
("bujia", "25173900"),
|
||||
("cable bujia", "25173900"),
|
||||
("bobina", "25173900"),
|
||||
("alternador", "25173900"),
|
||||
("arrancador", "25173900"),
|
||||
("marcha", "25173900"),
|
||||
("modulo", "25173900"),
|
||||
("regulador", "25173900"),
|
||||
("relevador", "25173900"),
|
||||
("fusible", "25173900"),
|
||||
("sensor", "25173900"),
|
||||
("switch", "25173900"),
|
||||
("bomba de gasolina", "25172400"),
|
||||
("inyector", "25172400"),
|
||||
("cuerpo de aceleracion", "25172400"),
|
||||
("carburador", "25172400"),
|
||||
# ── Engine internals / gaskets ─────────────────────────────────────────
|
||||
("empaque", "26101700"),
|
||||
("junta", "26101700"),
|
||||
("sello", "26101700"),
|
||||
("reten", "26101700"),
|
||||
("piston", "26101700"),
|
||||
("valvula", "26101700"),
|
||||
("bomba de aceite", "26101700"),
|
||||
("arbol de levas", "26101700"),
|
||||
("cigue", "26101700"),
|
||||
# ── Lighting ───────────────────────────────────────────────────────────
|
||||
("faro", "25172900"),
|
||||
("calavera", "25172900"),
|
||||
("direccional", "25172900"),
|
||||
("tulipa", "25172900"),
|
||||
("foco", "25172900"),
|
||||
("luces", "25172900"),
|
||||
("luz", "25172900"),
|
||||
# ── Body / glass ───────────────────────────────────────────────────────
|
||||
("espejo", "25172600"),
|
||||
("defensa", "25172600"),
|
||||
("salpicadera", "25172600"),
|
||||
("cofre", "25172600"),
|
||||
("puerta", "25172200"),
|
||||
("parabrisas", "25172300"),
|
||||
("cristal", "25172300"),
|
||||
# ── Drivetrain ─────────────────────────────────────────────────────────
|
||||
("homocinetica", "25173800"),
|
||||
("tripode", "25173800"),
|
||||
("flecha", "25173800"),
|
||||
("transmision", "26111800"),
|
||||
("diferencial", "25173800"),
|
||||
("cople", "25173800"),
|
||||
# ── Consumables ────────────────────────────────────────────────────────
|
||||
("aceite", "15121500"),
|
||||
("grasa", "15121500"),
|
||||
("aditivo", "15121500"),
|
||||
("lubricante", "15121500"),
|
||||
("silicon", "15121500"),
|
||||
("sellador", "15121500"),
|
||||
("spray", "15121500"),
|
||||
("limpiador", "15121500"),
|
||||
# ── Tools / accessories ────────────────────────────────────────────────
|
||||
("herramienta", "25191700"),
|
||||
("desarmador", "25191700"),
|
||||
("dado", "25191700"),
|
||||
("gato hidraul", "25191700"),
|
||||
# ── English aliases (supplier catalogs ship English names) ─────────────
|
||||
("air filter", "40161500"),
|
||||
("oil filter", "40161500"),
|
||||
("fuel filter", "40161500"),
|
||||
("cabin filter", "40161500"),
|
||||
("filter", "40161500"),
|
||||
("water pump", "25174000"),
|
||||
("fuel pump", "25172400"),
|
||||
("power steering pump", "25172000"),
|
||||
("oil pump", "26101700"),
|
||||
("spark plug", "25173900"),
|
||||
("ignition", "25173900"),
|
||||
("muffler", "25173700"),
|
||||
("exhaust", "25173700"),
|
||||
("catalytic", "12161600"),
|
||||
("brake", "25171700"),
|
||||
("caliper", "25171700"),
|
||||
("rotor", "25171700"),
|
||||
("shoe", "25171700"),
|
||||
("drum", "25171700"),
|
||||
("pad", "25171700"),
|
||||
("alternator", "25173900"),
|
||||
("starter", "25173900"),
|
||||
("relay", "25173900"),
|
||||
("fuse", "25173900"),
|
||||
("cable", "25173900"),
|
||||
("injector", "25172400"),
|
||||
("battery", "26111700"),
|
||||
("radiator", "25174000"),
|
||||
("thermostat", "25174000"),
|
||||
("hose", "25174000"),
|
||||
("belt", "25174000"),
|
||||
("shock", "25172000"),
|
||||
("strut", "25172000"),
|
||||
("control arm", "25172000"),
|
||||
("ball joint", "25172000"),
|
||||
("tie rod", "25172000"),
|
||||
("bearing", "25171900"),
|
||||
("hub", "25171900"),
|
||||
("cv joint", "25173800"),
|
||||
("axle", "25173800"),
|
||||
("driveshaft", "25173800"),
|
||||
("transmission", "26111800"),
|
||||
("gasket", "26101700"),
|
||||
("valve", "26101700"),
|
||||
("piston", "26101700"),
|
||||
("engine mount", "26101700"),
|
||||
("motor mount", "26101700"),
|
||||
("mirror", "25172600"),
|
||||
("bumper", "25172600"),
|
||||
("fender", "25172600"),
|
||||
("door", "25172200"),
|
||||
("windshield", "25172300"),
|
||||
("glass", "25172300"),
|
||||
("lamp", "25172900"),
|
||||
("light", "25172900"),
|
||||
("wiper", "25171500"),
|
||||
("tire", "25172500"),
|
||||
("wheel", "25172500"),
|
||||
("rim", "25172500"),
|
||||
# ── Generic fallbacks (kept last) ──────────────────────────────────────
|
||||
("motor", "26101700"),
|
||||
("bomba", "26101700"),
|
||||
]
|
||||
|
||||
|
||||
def _norm(s):
|
||||
s = unicodedata.normalize("NFD", str(s or ""))
|
||||
s = s.encode("ascii", "ignore").decode("ascii")
|
||||
s = re.sub(r"[^A-Za-z0-9]+", " ", s).upper().strip()
|
||||
return " ".join(s.split())
|
||||
|
||||
|
||||
# RULES keys normalized once (uppercase) so they compare against _norm(name).
|
||||
_RULES_NORM = [(_norm(k), code) for k, code in RULES]
|
||||
|
||||
|
||||
def resolve_by_rules(name):
|
||||
"""Return a SAT key from the keyword rules, or ``None``.
|
||||
|
||||
Single-token keys match on token prefix (so ``freno`` also catches
|
||||
``frenos``); multi-token keys match as a contiguous substring.
|
||||
"""
|
||||
n = _norm(name)
|
||||
if not n:
|
||||
return None
|
||||
padded = " " + n + " "
|
||||
tokens = n.split()
|
||||
for key, code in _RULES_NORM:
|
||||
if " " in key:
|
||||
if " " + key + " " in padded:
|
||||
return code
|
||||
elif any(
|
||||
t == key or t.startswith(key)
|
||||
or (len(t) >= 5 and key.startswith(t))
|
||||
for t in tokens
|
||||
):
|
||||
return code
|
||||
return None
|
||||
|
||||
|
||||
def resolve_key(name=None, brand=None, canonical=None, category=None,
|
||||
part_number=None, use_llm=False, timeout=None):
|
||||
"""Resolve ``(clave_prod_serv, clave_unidad, source)`` for one item.
|
||||
|
||||
``source`` ∈ {'rules', 'category', 'llm', 'default'}. Never raises.
|
||||
"""
|
||||
# 1. keyword rules over name (fall back to part_number if no name)
|
||||
code = resolve_by_rules(name) or resolve_by_rules(part_number)
|
||||
if code:
|
||||
return code, DEFAULT_UNIT, "rules"
|
||||
|
||||
# 2. canonical category (either passed directly or as free text)
|
||||
for cat in (canonical, category):
|
||||
if not cat:
|
||||
continue
|
||||
hit = CANONICAL_BY_CATEGORY.get(str(cat).strip())
|
||||
if hit:
|
||||
return hit, DEFAULT_UNIT, "category"
|
||||
|
||||
# 3. optional LLM fallback: classify_part -> category -> rule/map
|
||||
if use_llm:
|
||||
llm_code = _llm_key(part_number or name, brand, timeout=timeout)
|
||||
if llm_code:
|
||||
return llm_code, DEFAULT_UNIT, "llm"
|
||||
|
||||
return DEFAULT_KEY, DEFAULT_UNIT, "default"
|
||||
|
||||
|
||||
def _llm_key(part_number, brand=None, timeout=None):
|
||||
"""Ask ai_chat.classify_part for the category and map it. Never raises."""
|
||||
if not part_number:
|
||||
return None
|
||||
try:
|
||||
from services.ai_chat import classify_part
|
||||
res = classify_part(str(part_number))
|
||||
except Exception:
|
||||
return None
|
||||
if not res:
|
||||
return None
|
||||
raw = (res.get("category") or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
hit = CANONICAL_BY_CATEGORY.get(raw)
|
||||
if hit:
|
||||
return hit
|
||||
# The LLM may answer in free text ("Sistema de Frenos"): try the name rules
|
||||
# and then the canonical map over its own words.
|
||||
return resolve_by_rules(raw)
|
||||
304
pos/services/semantic_search.py
Normal file
304
pos/services/semantic_search.py
Normal file
@@ -0,0 +1,304 @@
|
||||
# /home/Autopartes/pos/services/semantic_search.py
|
||||
"""Búsqueda semántica sobre el catálogo: rerank + embeddings vía API QWEN.
|
||||
|
||||
El proveedor QWEN (``QWEN_API_URL``) expone, además de chat, dos endpoints
|
||||
compatibles con OpenAI/Cohere y ya verificados en producción:
|
||||
|
||||
POST {QWEN_API_URL}/rerank
|
||||
{"model": "rerank", "query": "...", "documents": ["a", "b", ...]}
|
||||
→ {"results": [{"index": 1, "relevance_score": 0.70, "document": {...}}, ...]}
|
||||
(ordenado por relevancia descendente)
|
||||
|
||||
POST {QWEN_API_URL}/embeddings
|
||||
{"model": "qwen3-embedding", "input": "texto"}
|
||||
→ {"data": [{"embedding": [4096 floats]}, ...]} (OpenAI-compatible)
|
||||
|
||||
Regla de oro: **ninguna función lanza excepción**. Si falta configuración, hay
|
||||
timeout o cualquier error de red/API, ``rerank()`` devuelve el orden original y
|
||||
``embed()``/``find_similar()`` devuelven ``None``. Así el buscador del POS
|
||||
nunca se rompe ni se cae por culpa del rerank; en el peor caso la búsqueda es
|
||||
la de siempre.
|
||||
|
||||
Variables de entorno (ver ``pos/config.py``):
|
||||
QWEN_API_URL URL base del proveedor (igual que ``ai_chat``)
|
||||
QWEN_API_KEY API key (igual que ``ai_chat``)
|
||||
QWEN_RERANK_MODEL default ``rerank``
|
||||
QWEN_EMBED_MODEL default ``qwen3-embedding``
|
||||
SEARCH_RERANK_ENABLED default ``true`` — scope ``catalog`` (público)
|
||||
SEARCH_RERANK_POS_ENABLED default ``false`` — scope ``pos`` (caja/POS)
|
||||
SEARCH_RERANK_TIMEOUT default ``2`` segundos (ambos scopes)
|
||||
SEARCH_RERANK_MAX_CANDIDATES default ``50`` documentos por llamada
|
||||
|
||||
Nota de rendimiento (medido 2026-10-11 contra api.nan.builders, RTT ~40 ms):
|
||||
el endpoint /rerank tarda ~0.6–1.8 s independientemente del número de
|
||||
documentos (5 a 51). Por eso el POS va apagado por defecto: su búsqueda en
|
||||
Meilisearch ya responde en ~18 ms y el rerank la encarecería 30–80×.
|
||||
"""
|
||||
|
||||
import math
|
||||
import os
|
||||
|
||||
import requests
|
||||
|
||||
try: # config del POS; con sys.path del dashboard puede resolver el config raíz
|
||||
from config import QWEN_API_URL, QWEN_API_KEY
|
||||
from config import QWEN_RERANK_MODEL, QWEN_EMBED_MODEL
|
||||
from config import SEARCH_RERANK_ENABLED, SEARCH_RERANK_POS_ENABLED
|
||||
from config import SEARCH_RERANK_TIMEOUT, SEARCH_RERANK_MAX_CANDIDATES
|
||||
except ImportError:
|
||||
QWEN_API_URL = os.environ.get('QWEN_API_URL', '')
|
||||
QWEN_API_KEY = os.environ.get('QWEN_API_KEY', '')
|
||||
QWEN_RERANK_MODEL = os.environ.get('QWEN_RERANK_MODEL', 'rerank')
|
||||
QWEN_EMBED_MODEL = os.environ.get('QWEN_EMBED_MODEL', 'qwen3-embedding')
|
||||
SEARCH_RERANK_ENABLED = os.environ.get('SEARCH_RERANK_ENABLED', 'true').lower() == 'true'
|
||||
SEARCH_RERANK_POS_ENABLED = os.environ.get('SEARCH_RERANK_POS_ENABLED', 'false').lower() == 'true'
|
||||
SEARCH_RERANK_TIMEOUT = float(os.environ.get('SEARCH_RERANK_TIMEOUT', '2'))
|
||||
SEARCH_RERANK_MAX_CANDIDATES = int(os.environ.get('SEARCH_RERANK_MAX_CANDIDATES', '50'))
|
||||
|
||||
DEFAULT_RERANK_MODEL = 'rerank'
|
||||
DEFAULT_EMBED_MODEL = 'qwen3-embedding'
|
||||
DEFAULT_TIMEOUT = 2.0
|
||||
DEFAULT_MAX_CANDIDATES = 50
|
||||
|
||||
# Scopes del flag: 'catalog' = catálogo público / API / dashboard;
|
||||
# 'pos' = caja y chat del POS, donde la latencia es crítica.
|
||||
SCOPE_CATALOG = 'catalog'
|
||||
SCOPE_POS = 'pos'
|
||||
|
||||
|
||||
def is_configured():
|
||||
"""True si hay URL y API key (condición previa a cualquier llamada)."""
|
||||
return bool((QWEN_API_URL or '').strip() and (QWEN_API_KEY or '').strip())
|
||||
|
||||
|
||||
def is_rerank_enabled(scope=SCOPE_CATALOG):
|
||||
"""Flag de rerank para un scope.
|
||||
|
||||
``SEARCH_RERANK_ENABLED`` es el interruptor maestro (default ``true``) y
|
||||
aplica al scope ``catalog``. El POS requiere además
|
||||
``SEARCH_RERANK_POS_ENABLED=true`` (default ``false``) porque el rerank
|
||||
agrega cientos de ms a una búsqueda que hoy es de ~18 ms.
|
||||
"""
|
||||
if not SEARCH_RERANK_ENABLED:
|
||||
return False
|
||||
if (scope or SCOPE_CATALOG).lower() == SCOPE_POS:
|
||||
return bool(SEARCH_RERANK_POS_ENABLED)
|
||||
return True
|
||||
|
||||
|
||||
def rerank_model():
|
||||
return QWEN_RERANK_MODEL or DEFAULT_RERANK_MODEL
|
||||
|
||||
|
||||
def embed_model():
|
||||
return QWEN_EMBED_MODEL or DEFAULT_EMBED_MODEL
|
||||
|
||||
|
||||
def max_candidates():
|
||||
"""Tope de documentos por llamada (acota payload y latencia)."""
|
||||
try:
|
||||
value = int(SEARCH_RERANK_MAX_CANDIDATES)
|
||||
except (TypeError, ValueError):
|
||||
return DEFAULT_MAX_CANDIDATES
|
||||
return value if value > 0 else DEFAULT_MAX_CANDIDATES
|
||||
|
||||
|
||||
def _timeout(timeout=None):
|
||||
if timeout is not None:
|
||||
return timeout
|
||||
try:
|
||||
return float(SEARCH_RERANK_TIMEOUT)
|
||||
except (TypeError, ValueError):
|
||||
return DEFAULT_TIMEOUT
|
||||
|
||||
|
||||
def _endpoint(path):
|
||||
url = (QWEN_API_URL or '').strip()
|
||||
if not url:
|
||||
return None
|
||||
return url.rstrip('/') + path
|
||||
|
||||
|
||||
def _headers():
|
||||
return {
|
||||
'Authorization': f"Bearer {QWEN_API_KEY}",
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
|
||||
def _identity_order(count):
|
||||
return [{'index': i, 'relevance_score': None} for i in range(count)]
|
||||
|
||||
|
||||
def rerank(query, documents, top_n=None, timeout=None):
|
||||
"""Reordena ``documents`` por relevancia semántica respecto a ``query``.
|
||||
|
||||
Args:
|
||||
query: texto de búsqueda del usuario.
|
||||
documents: lista de textos candidatos.
|
||||
top_n: opcional, trunca el resultado a los N más relevantes. Por
|
||||
defecto devuelve **todos** los documentos: el caller pagina/muestra
|
||||
y no debe perder items.
|
||||
timeout: segundos (default ``SEARCH_RERANK_TIMEOUT``).
|
||||
|
||||
Returns:
|
||||
list[dict]: ``[{'index': <int>, 'relevance_score': <float|None>}, ...]``
|
||||
en orden de relevancia descendente. En degradación (sin URL/key,
|
||||
timeout, HTTP error, respuesta vacía o índice inválido) devuelve el
|
||||
orden original con ``relevance_score=None``. Nunca lanza.
|
||||
"""
|
||||
docs = list(documents or [])
|
||||
identity = _identity_order(len(docs))
|
||||
if not docs or not query:
|
||||
return identity[:top_n] if top_n else identity
|
||||
|
||||
url = _endpoint('/rerank')
|
||||
if not url or not (QWEN_API_KEY or '').strip():
|
||||
return identity[:top_n] if top_n else identity
|
||||
|
||||
try:
|
||||
resp = requests.post(
|
||||
url,
|
||||
headers=_headers(),
|
||||
json={
|
||||
'model': rerank_model(),
|
||||
'query': query,
|
||||
'documents': docs,
|
||||
},
|
||||
timeout=_timeout(timeout),
|
||||
)
|
||||
if resp.status_code >= 400:
|
||||
print(f"[semantic_search] rerank HTTP {resp.status_code}: {resp.text[:200]}")
|
||||
return identity[:top_n] if top_n else identity
|
||||
results = (resp.json() or {}).get('results') or []
|
||||
except Exception as e:
|
||||
print(f"[semantic_search] rerank error: {e}")
|
||||
return identity[:top_n] if top_n else identity
|
||||
|
||||
ordered = []
|
||||
seen = set()
|
||||
for item in results:
|
||||
try:
|
||||
idx = int(item.get('index'))
|
||||
except (TypeError, ValueError, AttributeError):
|
||||
continue
|
||||
if idx < 0 or idx >= len(docs) or idx in seen:
|
||||
continue
|
||||
seen.add(idx)
|
||||
score = item.get('relevance_score')
|
||||
ordered.append({
|
||||
'index': idx,
|
||||
'relevance_score': float(score) if isinstance(score, (int, float)) else None,
|
||||
})
|
||||
|
||||
if not ordered:
|
||||
return identity[:top_n] if top_n else identity
|
||||
|
||||
# Documentos que la API no devolvió (p.ej. si aplicó su propio tope)
|
||||
# conservan su orden original al final — nunca se pierden.
|
||||
for i in range(len(docs)):
|
||||
if i not in seen:
|
||||
ordered.append({'index': i, 'relevance_score': None})
|
||||
|
||||
return ordered[:top_n] if top_n else ordered
|
||||
|
||||
|
||||
def rerank_order(query, documents, top_n=None, timeout=None):
|
||||
"""Igual que ``rerank()`` pero devuelve solo los índices reordenados."""
|
||||
return [r['index'] for r in rerank(query, documents, top_n=top_n, timeout=timeout)]
|
||||
|
||||
|
||||
def embed(texts, timeout=None):
|
||||
"""Devuelve los embeddings de ``texts`` vía el endpoint /embeddings.
|
||||
|
||||
Args:
|
||||
texts: str o iterable de str.
|
||||
timeout: segundos (default ``SEARCH_RERANK_TIMEOUT``).
|
||||
|
||||
Returns:
|
||||
list[list[float]] en el mismo orden que la entrada, o ``None`` si no
|
||||
hay configuración, hay timeout, error HTTP o respuesta malformada.
|
||||
Nunca lanza.
|
||||
"""
|
||||
if isinstance(texts, str):
|
||||
items = [texts]
|
||||
else:
|
||||
items = list(texts or [])
|
||||
if not items:
|
||||
return []
|
||||
|
||||
url = _endpoint('/embeddings')
|
||||
if not url or not (QWEN_API_KEY or '').strip():
|
||||
return None
|
||||
|
||||
try:
|
||||
resp = requests.post(
|
||||
url,
|
||||
headers=_headers(),
|
||||
json={'model': embed_model(), 'input': items},
|
||||
timeout=_timeout(timeout),
|
||||
)
|
||||
if resp.status_code >= 400:
|
||||
print(f"[semantic_search] embeddings HTTP {resp.status_code}: {resp.text[:200]}")
|
||||
return None
|
||||
data = (resp.json() or {}).get('data') or []
|
||||
vectors = [row.get('embedding') for row in data]
|
||||
if len(vectors) != len(items) or any(not isinstance(v, list) or not v for v in vectors):
|
||||
print(f"[semantic_search] embeddings respuesta inesperada ({len(vectors)} de {len(items)})")
|
||||
return None
|
||||
return vectors
|
||||
except Exception as e:
|
||||
print(f"[semantic_search] embeddings error: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def cosine_similarity(vec_a, vec_b):
|
||||
"""Similitud coseno entre dos vectores (None si no es calculable)."""
|
||||
if not vec_a or not vec_b or len(vec_a) != len(vec_b):
|
||||
return None
|
||||
try:
|
||||
dot = 0.0
|
||||
norm_a = 0.0
|
||||
norm_b = 0.0
|
||||
for a, b in zip(vec_a, vec_b):
|
||||
dot += a * b
|
||||
norm_a += a * a
|
||||
norm_b += b * b
|
||||
if norm_a <= 0 or norm_b <= 0:
|
||||
return None
|
||||
return dot / (math.sqrt(norm_a) * math.sqrt(norm_b))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def similar_texts(text_a, text_b, timeout=None):
|
||||
"""Similitud coseno entre dos textos (None si no hay embeddings)."""
|
||||
vectors = embed([text_a, text_b], timeout=timeout)
|
||||
if not vectors:
|
||||
return None
|
||||
return cosine_similarity(vectors[0], vectors[1])
|
||||
|
||||
|
||||
def find_similar(query, candidates, top_n=None, timeout=None):
|
||||
"""Ordena ``candidates`` por similitud coseno con ``query``.
|
||||
|
||||
Returns:
|
||||
list[dict] ``[{'index', 'similarity'}, ...]`` ordenado desc, o ``None``
|
||||
si no hay embeddings disponibles. Nunca lanza.
|
||||
"""
|
||||
items = list(candidates or [])
|
||||
if not items or not query:
|
||||
return None
|
||||
vectors = embed([query] + items, timeout=timeout)
|
||||
if not vectors:
|
||||
return None
|
||||
query_vec = vectors[0]
|
||||
scored = []
|
||||
for i, vec in enumerate(vectors[1:]):
|
||||
sim = cosine_similarity(query_vec, vec)
|
||||
if sim is not None:
|
||||
scored.append({'index': i, 'similarity': sim})
|
||||
if not scored:
|
||||
return None
|
||||
scored.sort(key=lambda r: r['similarity'], reverse=True)
|
||||
return scored[:top_n] if top_n else scored
|
||||
448
pos/services/sku_matcher.py
Normal file
448
pos/services/sku_matcher.py
Normal file
@@ -0,0 +1,448 @@
|
||||
# /home/Autopartes/pos/services/sku_matcher.py
|
||||
"""Inventory <-> supplier-catalog SKU matching.
|
||||
|
||||
Tries to populate ``inventory.catalog_part_id`` (today 0% linked) and register
|
||||
the matched catalog SKU as an alias in ``inventory_sku_aliases``.
|
||||
|
||||
Matching strategy (first hit wins), all over a *normalized* key (uppercase,
|
||||
dashes/spaces/dots stripped):
|
||||
|
||||
1. exact — inventory.part_number == supplier_catalog.sku
|
||||
2. xref — inventory.part_number == supplier_catalog_interchange.part_number
|
||||
3. name — fuzzy name similarity (difflib, no sklearn available) against the
|
||||
supplier-catalog names, blocked by first significant token.
|
||||
4. semantic — optional (opt-in) cosine similarity over QWEN embeddings
|
||||
(``semantic_search.embed``), used to fill the gaps difflib
|
||||
leaves behind (same part, different wording/abbreviations).
|
||||
Catalog-name embeddings are cached in-memory per ``CatalogIndex``
|
||||
so a long run embeds each catalog name at most once. With no
|
||||
network/provider the step degrades to a no-op.
|
||||
|
||||
``supplier_catalog`` / ``supplier_catalog_interchange`` live in the MASTER DB;
|
||||
``inventory`` / ``inventory_sku_aliases`` live in the tenant DB. Because the
|
||||
two databases are queried with separate connections, the join happens in
|
||||
Python (the catalog is small: ~17k items / ~18k distinct cross-refs).
|
||||
|
||||
Public API:
|
||||
match_inventory(tenant_conn, master_conn, limit=None, ...) -> summary dict
|
||||
load_catalog_index(master_conn) -> CatalogIndex
|
||||
"""
|
||||
|
||||
import re
|
||||
import time
|
||||
import unicodedata
|
||||
from difflib import SequenceMatcher
|
||||
|
||||
_SKU_BLOCK_RE = re.compile(r"[^A-Z0-9]+")
|
||||
|
||||
|
||||
def norm_sku(s):
|
||||
"""Normalize a SKU/part number for exact matching."""
|
||||
s = unicodedata.normalize("NFD", str(s or ""))
|
||||
s = s.encode("ascii", "ignore").decode("ascii")
|
||||
return _SKU_BLOCK_RE.sub("", s.upper())
|
||||
|
||||
|
||||
def _name_tokens(s):
|
||||
s = unicodedata.normalize("NFD", str(s or ""))
|
||||
s = s.encode("ascii", "ignore").decode("ascii")
|
||||
return [t for t in re.split(r"[^A-Za-z0-9]+", s.upper()) if t]
|
||||
|
||||
|
||||
def _name_norm(s):
|
||||
return " ".join(_name_tokens(s))
|
||||
|
||||
|
||||
def _load_semantic_search():
|
||||
"""Import ``semantic_search`` whether pos/ is on sys.path or not."""
|
||||
try:
|
||||
from services import semantic_search as ss
|
||||
except Exception:
|
||||
try:
|
||||
import semantic_search as ss
|
||||
except Exception:
|
||||
return None
|
||||
return ss
|
||||
|
||||
|
||||
def _embed_texts(texts, batch=64, timeout=None, retries=1):
|
||||
"""Embed ``texts`` in batches via ``semantic_search.embed``.
|
||||
|
||||
Returns the list of vectors in input order, or ``None`` when the provider
|
||||
is unavailable / errors out (never raises). A single retry covers the
|
||||
transient 429/timeout seen on the shared QWEN provider.
|
||||
"""
|
||||
items = list(texts or [])
|
||||
if not items:
|
||||
return []
|
||||
ss = _load_semantic_search()
|
||||
if ss is None:
|
||||
return None
|
||||
vectors = []
|
||||
for i in range(0, len(items), max(int(batch), 1)):
|
||||
chunk = items[i:i + max(int(batch), 1)]
|
||||
out = None
|
||||
for attempt in range(int(retries) + 1):
|
||||
try:
|
||||
out = ss.embed(chunk, timeout=timeout)
|
||||
except Exception:
|
||||
out = None
|
||||
if out and len(out) == len(chunk):
|
||||
break
|
||||
if attempt < int(retries):
|
||||
time.sleep(2.0)
|
||||
if not out or len(out) != len(chunk):
|
||||
return None
|
||||
vectors.extend(out)
|
||||
return vectors
|
||||
|
||||
|
||||
class CatalogIndex:
|
||||
"""In-memory index over supplier_catalog (+ interchange)."""
|
||||
|
||||
def __init__(self):
|
||||
self.by_sku = {} # norm sku -> list of catalog dicts
|
||||
self.by_xref = {} # norm part_number -> list of catalog dicts
|
||||
self.by_token = {} # first significant name token -> list of dicts
|
||||
self.total = 0
|
||||
self._emb_cache = {} # name_norm -> embedding vector (lazy)
|
||||
self.emb_requests = 0 # provider calls used by the semantic matcher
|
||||
|
||||
def add(self, cat_id, supplier, sku, name, category):
|
||||
rec = {
|
||||
"catalog_id": cat_id, "supplier": supplier, "sku": sku,
|
||||
"name": name, "category": category, "name_norm": _name_norm(name),
|
||||
}
|
||||
self.total += 1
|
||||
ns = norm_sku(sku)
|
||||
if ns:
|
||||
self.by_sku.setdefault(ns, []).append(rec)
|
||||
tokens = _name_tokens(name)
|
||||
if tokens:
|
||||
self.by_token.setdefault(tokens[0], []).append(rec)
|
||||
return rec
|
||||
|
||||
def add_xref(self, norm_pn, rec):
|
||||
if norm_pn:
|
||||
self.by_xref.setdefault(norm_pn, []).append(rec)
|
||||
|
||||
def match_sku(self, norm_pn):
|
||||
return self.by_sku.get(norm_pn)
|
||||
|
||||
def match_xref(self, norm_pn):
|
||||
return self.by_xref.get(norm_pn)
|
||||
|
||||
def match_name(self, name, threshold=0.82):
|
||||
"""Best fuzzy name match (blocked by first token). Returns (rec, score)."""
|
||||
nt = _name_norm(name)
|
||||
tokens = _name_tokens(name)
|
||||
if not tokens or len(nt) < 4:
|
||||
return None, 0.0
|
||||
candidates = self.by_token.get(tokens[0])
|
||||
if not candidates:
|
||||
return None, 0.0
|
||||
best, best_score = None, 0.0
|
||||
for rec in candidates:
|
||||
if not rec["name_norm"]:
|
||||
continue
|
||||
score = SequenceMatcher(None, nt, rec["name_norm"]).ratio()
|
||||
if score > best_score:
|
||||
best, best_score = rec, score
|
||||
if best and best_score >= threshold:
|
||||
return best, best_score
|
||||
return None, best_score
|
||||
|
||||
def _embed_names(self, names, batch=64, timeout=None):
|
||||
"""Return {name_norm: vector}, embedding only the uncached names.
|
||||
|
||||
Catalog-side cache: each distinct ``name_norm`` is embedded once per
|
||||
index. Returns ``None`` (degraded) when the provider is unavailable.
|
||||
"""
|
||||
missing = [n for n in dict.fromkeys(names) if n and n not in self._emb_cache]
|
||||
if missing:
|
||||
self.emb_requests += 1
|
||||
vectors = _embed_texts(missing, batch=batch, timeout=timeout)
|
||||
if vectors is None:
|
||||
return None
|
||||
for n, v in zip(missing, vectors):
|
||||
self._emb_cache[n] = v
|
||||
return self._emb_cache
|
||||
|
||||
def match_name_semantic(self, name, threshold=0.86, top_k=200,
|
||||
batch=64, timeout=None):
|
||||
"""Best semantic name match (QWEN embeddings). Returns (rec, score).
|
||||
|
||||
Only the closest ``top_k`` candidates (blocked by first token, then
|
||||
pre-ranked with difflib) are embedded, keeping cost bounded. Degrades
|
||||
to ``(None, 0.0)`` when embeddings are unavailable.
|
||||
"""
|
||||
nt = _name_norm(name)
|
||||
tokens = _name_tokens(name)
|
||||
if not tokens or len(nt) < 4:
|
||||
return None, 0.0
|
||||
candidates = self.by_token.get(tokens[0]) or []
|
||||
if not candidates:
|
||||
return None, 0.0
|
||||
|
||||
ss = _load_semantic_search()
|
||||
if ss is None:
|
||||
return None, 0.0
|
||||
try:
|
||||
if hasattr(ss, 'is_configured') and not ss.is_configured():
|
||||
return None, 0.0
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
pool = [(SequenceMatcher(None, nt, r["name_norm"]).ratio(), r)
|
||||
for r in candidates if r["name_norm"]]
|
||||
pool.sort(key=lambda t: t[0], reverse=True)
|
||||
pool = [r for _, r in pool[:max(int(top_k), 1)]]
|
||||
if not pool:
|
||||
return None, 0.0
|
||||
|
||||
vecs = self._embed_names([r["name_norm"] for r in pool],
|
||||
batch=batch, timeout=timeout)
|
||||
if not vecs:
|
||||
return None, 0.0
|
||||
query_vecs = _embed_texts([nt], batch=1, timeout=timeout)
|
||||
if not query_vecs:
|
||||
return None, 0.0
|
||||
query_vec = query_vecs[0]
|
||||
|
||||
best, best_sim = None, 0.0
|
||||
for rec in pool:
|
||||
vec = vecs.get(rec["name_norm"])
|
||||
if not vec:
|
||||
continue
|
||||
try:
|
||||
sim = ss.cosine_similarity(query_vec, vec)
|
||||
except Exception:
|
||||
sim = None
|
||||
if sim is not None and sim > best_sim:
|
||||
best, best_sim = rec, sim
|
||||
if best and best_sim >= threshold:
|
||||
return best, best_sim
|
||||
return None, best_sim
|
||||
|
||||
|
||||
def load_catalog_index(master_conn, supplier=None):
|
||||
"""Build a CatalogIndex from the master supplier_catalog tables."""
|
||||
idx = CatalogIndex()
|
||||
cur = master_conn.cursor()
|
||||
try:
|
||||
if supplier:
|
||||
cur.execute(
|
||||
"SELECT id, supplier_name, sku, name, category FROM supplier_catalog "
|
||||
"WHERE is_active = true AND supplier_name = %s",
|
||||
(supplier,),
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"SELECT id, supplier_name, sku, name, category FROM supplier_catalog "
|
||||
"WHERE is_active = true"
|
||||
)
|
||||
recs = {}
|
||||
for cat_id, sup, sku, name, category in cur.fetchall():
|
||||
recs[cat_id] = idx.add(cat_id, sup, sku, name, category)
|
||||
|
||||
# Interchange cross-refs, deduped (406k rows / ~18k distinct part numbers)
|
||||
cur.execute(
|
||||
"SELECT MIN(catalog_id), UPPER(part_number) FROM supplier_catalog_interchange "
|
||||
"GROUP BY UPPER(part_number)"
|
||||
)
|
||||
for catalog_id, part_number in cur.fetchall():
|
||||
rec = recs.get(catalog_id)
|
||||
if rec is None:
|
||||
continue
|
||||
idx.add_xref(norm_sku(part_number), rec)
|
||||
finally:
|
||||
cur.close()
|
||||
return idx
|
||||
|
||||
|
||||
def match_inventory(tenant_conn, master_conn, limit=None, offset=0,
|
||||
only_unmatched=True, create_aliases=True, use_names=True,
|
||||
name_threshold=0.82, apply=True, supplier=None,
|
||||
after_id=None, sample_limit=20, use_semantic=False,
|
||||
semantic_threshold=0.86, semantic_top_k=200,
|
||||
semantic_batch=64, semantic_timeout=20.0,
|
||||
semantic_prefer_below=None, collect=False, index=None,
|
||||
only_linked=False):
|
||||
"""Match tenant inventory items against the supplier catalog.
|
||||
|
||||
Args:
|
||||
only_unmatched: only consider items whose catalog_part_id is NULL/0.
|
||||
create_aliases: insert matched catalog SKUs into inventory_sku_aliases.
|
||||
use_names: enable the fuzzy-name fallback.
|
||||
apply: persist changes (False = dry run).
|
||||
use_semantic: enable the embedding-based name fallback (opt-in; it
|
||||
needs the QWEN provider and is slower, so pair it with ``limit``).
|
||||
semantic_threshold: minimum cosine similarity (qwen3-embedding scores
|
||||
unrelated names low and near-synonyms high; 0.86 is conservative).
|
||||
semantic_prefer_below: when set, also run the semantic matcher for
|
||||
items whose fuzzy score is below this value and keep whichever
|
||||
scores higher (lets semantic *correct* weak difflib hits).
|
||||
|
||||
Returns a summary dict with counts by method and a sample of matches.
|
||||
"""
|
||||
idx = index if index is not None else load_catalog_index(master_conn, supplier=supplier)
|
||||
|
||||
cur = tenant_conn.cursor()
|
||||
where = ["is_active = true"]
|
||||
params = []
|
||||
if only_linked:
|
||||
where.append("(catalog_part_id IS NOT NULL AND catalog_part_id <> 0)")
|
||||
elif only_unmatched:
|
||||
where.append("(catalog_part_id IS NULL OR catalog_part_id = 0)")
|
||||
if after_id is not None:
|
||||
where.append("id > %s")
|
||||
params.append(after_id)
|
||||
sql = (
|
||||
"SELECT id, part_number, name, catalog_part_id FROM inventory "
|
||||
"WHERE " + " AND ".join(where) + " ORDER BY id"
|
||||
)
|
||||
if limit is not None:
|
||||
sql += " LIMIT %s OFFSET %s"
|
||||
params.extend([int(limit), int(offset)])
|
||||
cur.execute(sql, params)
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
|
||||
by_method = {"sku": 0, "sku_prefix": 0, "interchange": 0,
|
||||
"interchange_prefix": 0, "name": 0, "name_semantic": 0}
|
||||
updated = 0
|
||||
aliases = 0
|
||||
skipped_existing = 0
|
||||
sample = []
|
||||
all_results = []
|
||||
last_id = after_id
|
||||
|
||||
def _lookup(norm_pn):
|
||||
"""(rec, method, score) for an exact/cross-ref key, else (None, None, 0).
|
||||
|
||||
Cross-refs on very short numeric keys are skipped: a bare "3341" can
|
||||
collide with unrelated candidates in the 406k interchange table.
|
||||
"""
|
||||
if not norm_pn:
|
||||
return None, None, 0.0
|
||||
hit = idx.match_sku(norm_pn)
|
||||
if hit:
|
||||
return hit[0], "sku", 1.0
|
||||
if len(norm_pn) >= 5 or any(c.isalpha() for c in norm_pn):
|
||||
hit = idx.match_xref(norm_pn)
|
||||
if hit:
|
||||
return hit[0], "interchange", 0.95
|
||||
return None, None, 0.0
|
||||
|
||||
for inv_id, part_number, name, current_cpid in rows:
|
||||
last_id = inv_id
|
||||
norm_pn = norm_sku(part_number)
|
||||
|
||||
match, method, score = _lookup(norm_pn)
|
||||
|
||||
# Many tenant SKUs are "<vendor>-<manufacturer_code>"; retry stripped.
|
||||
if match is None and part_number and "-" in str(part_number):
|
||||
head, rest = str(part_number).split("-", 1)
|
||||
if head.isalpha() and 2 <= len(head) <= 4 and rest.strip():
|
||||
match, method, score = _lookup(norm_sku(rest))
|
||||
if match:
|
||||
method = "sku_prefix" if method == "sku" else "interchange_prefix"
|
||||
score = 0.96 if method == "sku_prefix" else 0.9
|
||||
|
||||
if match is None and use_names and name:
|
||||
match, score = idx.match_name(name, threshold=name_threshold)
|
||||
if match:
|
||||
method = "name"
|
||||
|
||||
needs_semantic = (
|
||||
use_semantic and name and
|
||||
(match is None or
|
||||
(semantic_prefer_below is not None and score < semantic_prefer_below))
|
||||
)
|
||||
if needs_semantic:
|
||||
s_rec, s_score = idx.match_name_semantic(
|
||||
name, threshold=semantic_threshold, top_k=semantic_top_k,
|
||||
batch=semantic_batch, timeout=semantic_timeout,
|
||||
)
|
||||
if s_rec and (match is None or s_score > score):
|
||||
match, method, score = s_rec, "name_semantic", s_score
|
||||
|
||||
if match is None:
|
||||
continue
|
||||
|
||||
by_method[method] += 1
|
||||
|
||||
if apply:
|
||||
cur = tenant_conn.cursor()
|
||||
try:
|
||||
cur.execute(
|
||||
"UPDATE inventory SET catalog_part_id = %s "
|
||||
"WHERE id = %s AND (catalog_part_id IS NULL OR catalog_part_id = 0)",
|
||||
(match["catalog_id"], inv_id),
|
||||
)
|
||||
if cur.rowcount:
|
||||
updated += 1
|
||||
else:
|
||||
skipped_existing += 1
|
||||
|
||||
if create_aliases and match["sku"]:
|
||||
cur.execute(
|
||||
"INSERT INTO inventory_sku_aliases (inventory_id, sku, label) "
|
||||
"VALUES (%s, %s, %s) ON CONFLICT (inventory_id, sku) DO NOTHING",
|
||||
(inv_id, match["sku"], f"Catálogo:{match['supplier'] or 'proveedor'}"),
|
||||
)
|
||||
aliases += cur.rowcount
|
||||
tenant_conn.commit()
|
||||
finally:
|
||||
cur.close()
|
||||
|
||||
if collect:
|
||||
all_results.append({
|
||||
"inventory_id": inv_id, "part_number": part_number, "name": name,
|
||||
"existing_catalog_id": current_cpid,
|
||||
"matched_catalog_id": match["catalog_id"],
|
||||
"matched_sku": match["sku"], "method": method,
|
||||
"score": round(score, 3),
|
||||
})
|
||||
|
||||
if len(sample) < sample_limit:
|
||||
sample.append({
|
||||
"inventory_id": inv_id, "part_number": part_number, "name": name,
|
||||
"matched_catalog_id": match["catalog_id"],
|
||||
"matched_sku": match["sku"], "matched_name": match["name"],
|
||||
"supplier": match["supplier"], "method": method,
|
||||
"score": round(score, 3),
|
||||
})
|
||||
|
||||
return {
|
||||
"scanned": len(rows),
|
||||
"matched": sum(by_method.values()),
|
||||
"by_method": by_method,
|
||||
"updated": updated,
|
||||
"aliases_created": aliases,
|
||||
"skipped_existing": skipped_existing,
|
||||
"catalog_size": idx.total,
|
||||
"sample": sample,
|
||||
"last_id": last_id,
|
||||
"results": all_results,
|
||||
"semantic": {
|
||||
"enabled": bool(use_semantic),
|
||||
"threshold": semantic_threshold,
|
||||
"prefer_below": semantic_prefer_below,
|
||||
"catalog_embeddings_cached": len(idx._emb_cache),
|
||||
"embed_requests": idx.emb_requests,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def count_links(tenant_conn):
|
||||
"""Return (total_items, linked_items) for progress reporting."""
|
||||
cur = tenant_conn.cursor()
|
||||
try:
|
||||
cur.execute(
|
||||
"SELECT count(*), count(*) FILTER "
|
||||
"(WHERE catalog_part_id IS NOT NULL AND catalog_part_id <> 0) FROM inventory"
|
||||
)
|
||||
return cur.fetchone()
|
||||
finally:
|
||||
cur.close()
|
||||
@@ -10,11 +10,14 @@ On receive, automatically:
|
||||
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
from datetime import date
|
||||
import logging
|
||||
|
||||
from services.inventory_engine import record_purchase
|
||||
from services.accounting_engine import record_purchase_entry
|
||||
from services.audit import log_action
|
||||
|
||||
logger = logging.getLogger('pos')
|
||||
|
||||
TWO = Decimal('0.01')
|
||||
|
||||
|
||||
@@ -320,8 +323,9 @@ def receive_po(conn, po_id, received_items, supplier_invoice=None, notes=None):
|
||||
'total': float(total_mxn),
|
||||
'supplier_name': _get_supplier_name(conn, po_supplier_id),
|
||||
})
|
||||
except Exception:
|
||||
pass # Accounting errors never block receiving
|
||||
except Exception as acc_err:
|
||||
# Accounting errors never block receiving, but must be logged
|
||||
logger.error(f'Accounting entry for received PO #{po_id} failed: {acc_err}')
|
||||
|
||||
log_action(conn, 'PO_RECEIVE', 'purchase_order', po_id,
|
||||
new_value={'received_qty': received_total, 'status': new_status})
|
||||
|
||||
@@ -200,9 +200,11 @@ def provision_tenant(name, rfc=None, owner_name="Admin", owner_email=None, owner
|
||||
|
||||
# Apply pending migrations post-v1.0
|
||||
from migrations.runner import MIGRATIONS, apply_migration
|
||||
sorted_versions = sorted(MIGRATIONS.keys())
|
||||
def _version_key(v):
|
||||
return tuple(int(x) for x in v.lstrip('v').split('.'))
|
||||
sorted_versions = sorted(MIGRATIONS.keys(), key=_version_key)
|
||||
for version in sorted_versions:
|
||||
if version <= 'v1.0':
|
||||
if _version_key(version) <= _version_key('v1.0'):
|
||||
continue
|
||||
success = apply_migration(db_name, version)
|
||||
if not success:
|
||||
|
||||
@@ -96,17 +96,106 @@ def detect_quote_intent(text, has_open_quote=False):
|
||||
return None, None
|
||||
|
||||
|
||||
def confirm_quotation(tenant_conn, phone):
|
||||
"""Mark the open quotation as confirmed/accepted."""
|
||||
def confirm_quotation(tenant_conn, phone, customer_id=None, tenant_id=None, branch_id=None):
|
||||
"""Confirm the open quotation and convert it into a pending-payment sale.
|
||||
|
||||
- Validates stock for every line before touching anything.
|
||||
- Creates the sale via pos_engine.process_sale (status 'pending_payment',
|
||||
stock reserved, no cash movement — payable later at the counter).
|
||||
- Notifies owners via the notification engine (best effort).
|
||||
|
||||
Returns dict {'quotation_id', 'sale_id', 'total', 'items'} on success,
|
||||
dict {'error': ..., ...} on failure, or None if there was no open quote.
|
||||
"""
|
||||
qid = get_open_quotation(tenant_conn, phone)
|
||||
if not qid:
|
||||
return None
|
||||
|
||||
detail = get_quotation_detail(tenant_conn, qid)
|
||||
if not detail or not detail.get('items'):
|
||||
return None
|
||||
|
||||
# Resolve target branch (first active)
|
||||
if not branch_id:
|
||||
cur = tenant_conn.cursor()
|
||||
cur.execute("SELECT id FROM branches ORDER BY id LIMIT 1")
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
if not row:
|
||||
return {'error': 'no_branch', 'quotation_id': qid}
|
||||
branch_id = row[0]
|
||||
|
||||
# Resolve inventory ids and validate stock
|
||||
cur = tenant_conn.cursor()
|
||||
resolved = []
|
||||
for it in detail['items']:
|
||||
cur.execute("""
|
||||
SELECT i.id, COALESCE(s.stock, 0) 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.part_number = %s AND i.is_active LIMIT 1
|
||||
""", (it['part_number'],))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
cur.close()
|
||||
return {'error': 'item_not_found', 'part_number': it['part_number'],
|
||||
'quotation_id': qid}
|
||||
inv_id, stock = row
|
||||
if (stock or 0) < it['quantity']:
|
||||
cur.close()
|
||||
return {'error': 'insufficient_stock', 'part_number': it['part_number'],
|
||||
'available': int(stock or 0), 'requested': it['quantity'],
|
||||
'quotation_id': qid}
|
||||
resolved.append({
|
||||
'inventory_id': inv_id,
|
||||
'quantity': it['quantity'],
|
||||
'unit_price': it['unit_price'],
|
||||
'tax_rate': it['tax_rate'],
|
||||
'discount_pct': 0,
|
||||
})
|
||||
cur.close()
|
||||
|
||||
from services.pos_engine import process_sale
|
||||
sale_data = {
|
||||
'items': resolved,
|
||||
'customer_id': customer_id,
|
||||
'payment_method': 'pendiente',
|
||||
'sale_type': 'cash',
|
||||
'branch_id': branch_id,
|
||||
'amount_paid': 0,
|
||||
'notes': f'Pedido por WhatsApp (cotizacion #{qid})',
|
||||
}
|
||||
try:
|
||||
sale = process_sale(tenant_conn, sale_data)
|
||||
except ValueError as e:
|
||||
tenant_conn.rollback()
|
||||
return {'error': 'sale_failed', 'message': str(e), 'quotation_id': qid}
|
||||
except Exception:
|
||||
tenant_conn.rollback()
|
||||
raise
|
||||
|
||||
cur = tenant_conn.cursor()
|
||||
cur.execute("UPDATE quotations SET status = 'converted' WHERE id = %s", (qid,))
|
||||
tenant_conn.commit()
|
||||
cur.close()
|
||||
tenant_conn.commit()
|
||||
clear_last_shown(tenant_conn, phone)
|
||||
return qid
|
||||
|
||||
# Notify owners (best effort — never block the reply)
|
||||
try:
|
||||
if tenant_id:
|
||||
from services.notification_engine import notify_new_sale
|
||||
notify_new_sale(tenant_conn, tenant_id, sale['id'], sale['total'], 'whatsapp')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
'quotation_id': qid,
|
||||
'sale_id': sale['id'],
|
||||
'total': sale['total'],
|
||||
'items': detail['items'],
|
||||
}
|
||||
|
||||
|
||||
# ─── Persistent last-shown-part per phone ────────────────────────────
|
||||
|
||||
@@ -378,17 +378,32 @@ def handle_quote_search(text, state_data, ctx):
|
||||
search_text = search_text.strip()
|
||||
state_data['search_description'] = search_text
|
||||
|
||||
# Memoria conversacional + historial de compras + persona por tenant
|
||||
conv_history = []
|
||||
try:
|
||||
from blueprints.whatsapp_bp import _get_conversation_history
|
||||
conv_history = _get_conversation_history(ctx.phone, ctx.tenant_conn, limit=4)
|
||||
except Exception:
|
||||
conv_history = []
|
||||
purchase_ctx = None
|
||||
try:
|
||||
from services.part_kits import get_purchase_history
|
||||
purchase_ctx = get_purchase_history(ctx.phone, ctx.tenant_conn) or None
|
||||
except Exception:
|
||||
purchase_ctx = None
|
||||
persona = (ctx.wa_config or {}).get('ai_persona') or None
|
||||
|
||||
try:
|
||||
from services.ai_chat import chat, chat_with_image
|
||||
if ctx.media_kind == 'image' and ctx.media_base64:
|
||||
ai_resp = chat_with_image(
|
||||
user_message=search_text if search_text != '(imagen)' else 'Identifica esta parte automotriz y sugiere terminos de busqueda.',
|
||||
image_base64=ctx.media_base64,
|
||||
conversation_history=[],
|
||||
inventory_context=None,
|
||||
conversation_history=conv_history,
|
||||
inventory_context=purchase_ctx,
|
||||
)
|
||||
else:
|
||||
ai_resp = chat(search_text, conversation_history=[], inventory_context=None)
|
||||
ai_resp = chat(search_text, conversation_history=conv_history, inventory_context=purchase_ctx, persona=persona)
|
||||
search_query = ai_resp.get('search_query')
|
||||
vehicle = ai_resp.get('vehicle')
|
||||
|
||||
@@ -669,7 +684,7 @@ def handle_quote_command_created(text, state_data, ctx):
|
||||
try:
|
||||
from services.wa_quotation import get_open_quotation, get_quotation_detail
|
||||
from services.quote_image import generate_quote_image
|
||||
from services.whatsapp_service import send_image
|
||||
from services.whatsapp_service import wa_send_image
|
||||
|
||||
qid = get_open_quotation(ctx.tenant_conn, ctx.phone)
|
||||
if qid:
|
||||
@@ -688,11 +703,11 @@ def handle_quote_command_created(text, state_data, ctx):
|
||||
}
|
||||
tenant_name = _get_tenant_business_name(ctx.tenant_conn)
|
||||
b64_img = generate_quote_image(quote_items, totals, tenant_name=tenant_name)
|
||||
img_result = send_image(
|
||||
img_result = wa_send_image(
|
||||
ctx.wa_config,
|
||||
ctx.phone,
|
||||
caption="Aquí está tu cotización 👇",
|
||||
base64_image=b64_img,
|
||||
bridge_url=ctx.wa_config.get('bridge_url')
|
||||
)
|
||||
if img_result.get('success'):
|
||||
return (
|
||||
@@ -715,19 +730,45 @@ def handle_quote_command_created(text, state_data, ctx):
|
||||
|
||||
if t in ('confirmar', 'confirmo', 'acepto', 'si', 'sí', 'va', 'ordenar', 'pedir'):
|
||||
from services.wa_quotation import confirm_quotation
|
||||
qid = confirm_quotation(ctx.tenant_conn, ctx.phone)
|
||||
if qid:
|
||||
reply = (
|
||||
"✅ *¡Pedido confirmado!*\n\n"
|
||||
"Tu cotización fue registrada.\n"
|
||||
"Nos pondremos en contacto contigo para coordinar la entrega.\n\n"
|
||||
"¡Gracias por tu compra! 🙏"
|
||||
)
|
||||
clean_data = {
|
||||
'customer_id': state_data.get('customer_id'),
|
||||
'branch_name': state_data.get('branch_name'),
|
||||
}
|
||||
return reply, 'support_done', clean_data
|
||||
result = confirm_quotation(ctx.tenant_conn, ctx.phone,
|
||||
customer_id=ctx.customer_id,
|
||||
tenant_id=ctx.tenant_id,
|
||||
branch_id=ctx.branch_id)
|
||||
clean_data = {
|
||||
'customer_id': state_data.get('customer_id'),
|
||||
'branch_name': state_data.get('branch_name'),
|
||||
}
|
||||
if result and result.get('sale_id'):
|
||||
lines = []
|
||||
lines.append("✅ *¡Pedido confirmado!*")
|
||||
lines.append("")
|
||||
lines.append(f"Tu pedido *#{result['sale_id']}* quedó registrado y separado.")
|
||||
lines.append("Pasa por tu refaccionaria oco coordina la entrega al confirmar el pago.\n")
|
||||
lines.append("*Detalle:*")
|
||||
for it in result.get('items', []):
|
||||
lines.append(f"• {it['quantity']} x {it['part_number']}")
|
||||
lines.append("")
|
||||
lines.append(f"*Total:* ${result['total']:,.2f}")
|
||||
lines.append("")
|
||||
lines.append("¡Gracias por tu compra! 🙏")
|
||||
return "\n".join(lines), 'support_done', clean_data
|
||||
if result and result.get('error') == 'insufficient_stock':
|
||||
return (
|
||||
f"⚠️ No hay stock suficiente de *{result['part_number']}*\n"
|
||||
f"Disponible: {result['available']} — Solicitado: {result['requested']}\n\n"
|
||||
"Tu cotización sigue activa. ¿Quieres *confirmar* el resto, "
|
||||
"*agregar* otra pieza o *limpiar* el pedido?",
|
||||
'quote_command_created', state_data)
|
||||
if result and result.get('error') == 'item_not_found':
|
||||
return (
|
||||
f"⚠️ La pieza *{result['part_number']}* ya no está disponible.\n\n"
|
||||
"¿Quieres *agregar* otra pieza o *limpiar* el pedido?",
|
||||
'quote_command_created', state_data)
|
||||
if result:
|
||||
return (
|
||||
"⚠️ No pude registrar tu pedido en este momento. "
|
||||
"Intenta de nuevo en unos minutos o contáctanos directamente.",
|
||||
'quote_command_created', state_data)
|
||||
|
||||
if t in ('limpiar', 'borrar', 'nueva', 'cancelar', 'reset'):
|
||||
from services.wa_quotation import clear_quotation
|
||||
|
||||
@@ -13,36 +13,46 @@ from services.audit import log_action
|
||||
|
||||
def register_warranty(conn, sale_id, sale_item_id, inventory_id,
|
||||
customer_id, warranty_months, supplier_id=None,
|
||||
part_number=None, name=None, notes=None):
|
||||
part_number=None, name=None, notes=None,
|
||||
warranty_days=None):
|
||||
"""Register a warranty for a sold item.
|
||||
|
||||
Args:
|
||||
warranty_months: int (e.g., 12, 24, 36)
|
||||
warranty_days: optional int. When given, end_date = today + warranty_days
|
||||
(instead of 30 * warranty_months) and it is stored in the
|
||||
warranty_days column. Used by the Yonke (used parts) module.
|
||||
Returns:
|
||||
int: warranty_id
|
||||
"""
|
||||
if not warranty_months or warranty_months <= 0:
|
||||
if (not warranty_months or warranty_months <= 0) and not warranty_days:
|
||||
raise ValueError("warranty_months must be a positive integer")
|
||||
|
||||
start = date.today()
|
||||
end = start + timedelta(days=30 * warranty_months)
|
||||
if warranty_days:
|
||||
end = start + timedelta(days=int(warranty_days))
|
||||
else:
|
||||
end = start + timedelta(days=30 * warranty_months)
|
||||
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
INSERT INTO warranties
|
||||
(sale_id, sale_item_id, inventory_id, customer_id, supplier_id,
|
||||
part_number, name, warranty_months, start_date, end_date, notes)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
part_number, name, warranty_months, start_date, end_date, notes,
|
||||
warranty_days)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""", (
|
||||
sale_id, sale_item_id, inventory_id, customer_id, supplier_id,
|
||||
part_number, name, warranty_months, start, end, notes
|
||||
part_number, name, warranty_months, start, end, notes,
|
||||
int(warranty_days) if warranty_days else None
|
||||
))
|
||||
w_id = cur.fetchone()[0]
|
||||
cur.close()
|
||||
|
||||
log_action(conn, 'WARRANTY_REGISTER', 'warranty', w_id,
|
||||
new_value={'months': warranty_months, 'end_date': str(end)})
|
||||
new_value={'months': warranty_months, 'days': warranty_days,
|
||||
'end_date': str(end)})
|
||||
return w_id
|
||||
|
||||
|
||||
@@ -141,7 +151,8 @@ def get_warranty(conn, warranty_id):
|
||||
SELECT w.id, w.sale_id, w.sale_item_id, w.inventory_id, w.customer_id,
|
||||
w.supplier_id, w.part_number, w.name, w.warranty_months,
|
||||
w.start_date, w.end_date, w.status, w.notes, w.created_at,
|
||||
c.name as customer_name, s.name as supplier_name
|
||||
c.name as customer_name, s.name as supplier_name,
|
||||
w.warranty_days
|
||||
FROM warranties w
|
||||
LEFT JOIN customers c ON w.customer_id = c.id
|
||||
LEFT JOIN suppliers s ON w.supplier_id = s.id
|
||||
@@ -158,6 +169,7 @@ def get_warranty(conn, warranty_id):
|
||||
'start_date': str(row[9]), 'end_date': str(row[10]),
|
||||
'status': row[11], 'notes': row[12], 'created_at': str(row[13]),
|
||||
'customer_name': row[14], 'supplier_name': row[15],
|
||||
'warranty_days': row[16],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -158,3 +158,302 @@ def send_image(phone, caption, base64_image, bridge_url=None):
|
||||
).json()
|
||||
except Exception as e:
|
||||
return {'error': str(e)}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Meta Cloud API provider (WA CRM / self-hosted, 1 número por tenant)
|
||||
#
|
||||
# cfg keys (tenant_config):
|
||||
# whatsapp_provider = 'meta' | 'baileys' (default: baileys)
|
||||
# wa_phone_number_id — Meta phone number ID
|
||||
# wa_access_token — permanent/long-lived access token
|
||||
# wa_app_secret — App secret (valida X-Hub-Signature-256 en webhooks)
|
||||
# wa_verify_token — token del GET verify del webhook
|
||||
# wa_graph_url — (opcional) override de graph.facebook.com/v21.0
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
import base64 as _b64
|
||||
import secrets as _secrets
|
||||
|
||||
|
||||
def _meta_is_meta(cfg):
|
||||
return str(cfg.get('provider') or '').lower() == 'meta' and cfg.get('phone_number_id')
|
||||
|
||||
|
||||
def _meta_digits(phone):
|
||||
return ''.join(ch for ch in str(phone or '') if ch.isdigit())
|
||||
|
||||
|
||||
def _meta_graph(cfg, method, path, json_payload=None, files=None, data=None, params=None):
|
||||
base = (cfg.get('graph_url') or 'https://graph.facebook.com/v21.0').rstrip('/')
|
||||
url = f"{base}/{path}"
|
||||
headers = {'Authorization': f"Bearer {cfg.get('access_token')}"}
|
||||
if method == 'GET':
|
||||
return requests.get(url, headers=headers, params=params or {}, timeout=20).json()
|
||||
if files:
|
||||
return requests.post(url, headers={'Authorization': headers['Authorization']},
|
||||
files=files, data=data or {}, timeout=60).json()
|
||||
return requests.post(url, headers=headers, json=json_payload, timeout=30).json()
|
||||
|
||||
|
||||
def meta_send_text(cfg, phone, text):
|
||||
try:
|
||||
r = _meta_graph(cfg, 'POST', f"{cfg['phone_number_id']}/messages", json_payload={
|
||||
'messaging_product': 'whatsapp',
|
||||
'recipient_type': 'individual',
|
||||
'to': _meta_digits(phone),
|
||||
'type': 'text',
|
||||
'text': {'preview_url': True, 'body': text},
|
||||
})
|
||||
if r.get('error'):
|
||||
return {'error': r['error'].get('message', 'Meta error')}
|
||||
return {'ok': True, 'id': (r.get('messages') or [{}])[0].get('id')}
|
||||
except Exception as e:
|
||||
return {'error': str(e)}
|
||||
|
||||
|
||||
def meta_send_image(cfg, phone, caption, base64_image):
|
||||
"""Upload media then send image message."""
|
||||
try:
|
||||
raw = base64_image
|
||||
if ',' in raw and raw.strip().startswith('data:'):
|
||||
raw = raw.split(',', 1)[1]
|
||||
img_bytes = _b64.b64decode(raw)
|
||||
up = _meta_graph(cfg, 'POST', f"{cfg['phone_number_id']}/media",
|
||||
files={'file': ('image.jpg', img_bytes, 'image/jpeg')},
|
||||
data={'messaging_product': 'whatsapp'})
|
||||
media_id = up.get('id')
|
||||
if not media_id:
|
||||
return {'error': up.get('error', {}).get('message', 'media upload failed')}
|
||||
r = _meta_graph(cfg, 'POST', f"{cfg['phone_number_id']}/messages", json_payload={
|
||||
'messaging_product': 'whatsapp',
|
||||
'recipient_type': 'individual',
|
||||
'to': _meta_digits(phone),
|
||||
'type': 'image',
|
||||
'image': {'id': media_id, 'caption': caption or ''},
|
||||
})
|
||||
if r.get('error'):
|
||||
return {'error': r['error'].get('message', 'Meta error')}
|
||||
return {'ok': True, 'id': (r.get('messages') or [{}])[0].get('id')}
|
||||
except Exception as e:
|
||||
return {'error': str(e)}
|
||||
|
||||
|
||||
def meta_get_status(cfg):
|
||||
try:
|
||||
r = _meta_graph(cfg, 'GET', f"{cfg['phone_number_id']}",
|
||||
params={'fields': 'display_phone_number,verified_name,quality_rating'})
|
||||
if r.get('error'):
|
||||
return {'state': 'error', 'error': r['error'].get('message')}
|
||||
return {'state': 'connected', 'phone': r.get('display_phone_number'),
|
||||
'name': r.get('verified_name'), 'quality': r.get('quality_rating')}
|
||||
except Exception as e:
|
||||
return {'state': 'error', 'error': str(e)}
|
||||
|
||||
|
||||
def meta_verify_signature(app_secret, payload_bytes, signature_header):
|
||||
"""Validate X-Hub-Signature-256: 'sha256=<hex>'."""
|
||||
if not app_secret or not signature_header:
|
||||
return True # not configured -> do not enforce
|
||||
import hmac as _hmac
|
||||
import hashlib as _hashlib
|
||||
expected = 'sha256=' + _hmac.new(
|
||||
app_secret.encode(), payload_bytes, _hashlib.sha256).hexdigest()
|
||||
try:
|
||||
return _hmac.compare_digest(expected, signature_header)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _provider_cfg_error(cfg, provider):
|
||||
"""Error claro cuando el proveedor elegido está incompleto (sin fallback silencioso)."""
|
||||
if provider == 'wacrm':
|
||||
return {'error': 'WA CRM no configurado: faltan api_base y/o api_key (Configuración → WhatsApp)'}
|
||||
if provider == 'meta':
|
||||
return {'error': 'Meta Cloud API no configurada: faltan phone_number_id y/o access_token'}
|
||||
if provider == 'baileys':
|
||||
return {'error': 'Bridge de WhatsApp no configurado o retirado (WhatsApp pasa por WA CRM)'}
|
||||
return {'error': f'Proveedor de WhatsApp desconocido: {provider}'}
|
||||
|
||||
|
||||
def wa_send_text(cfg, phone, text):
|
||||
"""Router: WA CRM, Meta directo o Baileys según config del tenant.
|
||||
|
||||
Si el proveedor elegido está incompleto, devuelve un error explícito en
|
||||
lugar de degradar silenciosamente a otro proveedor.
|
||||
"""
|
||||
cfg = cfg or {}
|
||||
provider = str(cfg.get('provider') or 'baileys').lower()
|
||||
if provider == 'wacrm':
|
||||
if not _wacrm_is_wacrm(cfg):
|
||||
return _provider_cfg_error(cfg, 'wacrm')
|
||||
return wacrm_send_text(cfg, phone, text)
|
||||
if provider == 'meta':
|
||||
if not _meta_is_meta(cfg):
|
||||
return _provider_cfg_error(cfg, 'meta')
|
||||
return meta_send_text(cfg, phone, text)
|
||||
if not cfg.get('bridge_url'):
|
||||
return _provider_cfg_error(cfg, 'baileys')
|
||||
return send_message(phone, text, bridge_url=cfg.get('bridge_url'))
|
||||
|
||||
|
||||
def wa_send_image(cfg, phone, caption, base64_image):
|
||||
cfg = cfg or {}
|
||||
provider = str(cfg.get('provider') or 'baileys').lower()
|
||||
if provider == 'wacrm' and not _wacrm_is_wacrm(cfg):
|
||||
return _provider_cfg_error(cfg, 'wacrm')
|
||||
if provider == 'meta' and not _meta_is_meta(cfg):
|
||||
return _provider_cfg_error(cfg, 'meta')
|
||||
if provider == 'baileys' and not cfg.get('bridge_url'):
|
||||
return _provider_cfg_error(cfg, 'baileys')
|
||||
if _wacrm_is_wacrm(cfg):
|
||||
# WA CRM exige URL pública para media; sin ella degradamos a texto.
|
||||
public_base = (cfg.get('public_base') or '').rstrip('/')
|
||||
if public_base:
|
||||
import uuid as _uuid
|
||||
import os as _os
|
||||
token = _uuid.uuid4().hex
|
||||
media_dir = '/tmp/wa_media'
|
||||
_os.makedirs(media_dir, exist_ok=True)
|
||||
raw = base64_image.split(',', 1)[1] if base64_image.strip().startswith('data:') else base64_image
|
||||
path = _os.path.join(media_dir, token + '.png')
|
||||
try:
|
||||
with open(path, 'wb') as fh:
|
||||
fh.write(_b64.b64decode(raw))
|
||||
except Exception:
|
||||
return {'error': 'no se pudo preparar la imagen'}
|
||||
return wacrm_send_media(cfg, phone, 'image',
|
||||
f'{public_base}/pos/api/public/media/{token}.png', caption)
|
||||
return wa_send_text(cfg, phone, (caption or '') + '\n(imagen no disponible: configura wa_public_base)')
|
||||
if _meta_is_meta(cfg or {}):
|
||||
return meta_send_image(cfg, phone, caption, base64_image)
|
||||
return send_image(phone, caption, base64_image, bridge_url=(cfg or {}).get('bridge_url'))
|
||||
|
||||
|
||||
def wa_send_document(cfg, phone, base64_data, filename, caption=''):
|
||||
"""Envía PDF/documento por el proveedor configurado (WA CRM, Meta o Baileys)."""
|
||||
cfg = cfg or {}
|
||||
provider = str(cfg.get('provider') or 'baileys').lower()
|
||||
if provider == 'wacrm':
|
||||
if not _wacrm_is_wacrm(cfg):
|
||||
return _provider_cfg_error(cfg, 'wacrm')
|
||||
if not (cfg.get('public_base') or '').strip():
|
||||
return {'error': 'wa_public_base requerido para enviar documentos vía WA CRM'}
|
||||
if _wacrm_is_wacrm(cfg):
|
||||
public_base = (cfg.get('public_base') or '').rstrip('/')
|
||||
if not public_base:
|
||||
return {'error': 'wa_public_base requerido para enviar documentos vía WA CRM'}
|
||||
import uuid as _uuid
|
||||
import os as _os
|
||||
token = _uuid.uuid4().hex
|
||||
media_dir = '/tmp/wa_media'
|
||||
_os.makedirs(media_dir, exist_ok=True)
|
||||
raw = base64_data.split(',', 1)[1] if base64_data.strip().startswith('data:') else base64_data
|
||||
path = _os.path.join(media_dir, token + '_' + filename)
|
||||
with open(path, 'wb') as fh:
|
||||
fh.write(_b64.b64decode(raw))
|
||||
return wacrm_send_media(cfg, phone, 'document',
|
||||
f'{public_base}/pos/api/public/media/{token}_{filename}',
|
||||
caption, filename)
|
||||
try:
|
||||
raw = base64_data
|
||||
if ',' in raw and raw.strip().startswith('data:'):
|
||||
raw = raw.split(',', 1)[1]
|
||||
if _meta_is_meta(cfg):
|
||||
img_bytes = _b64.b64decode(raw)
|
||||
up = _meta_graph(cfg, 'POST', f"{cfg['phone_number_id']}/media",
|
||||
files={'file': (filename, img_bytes, 'application/pdf')},
|
||||
data={'messaging_product': 'whatsapp'})
|
||||
media_id = up.get('id')
|
||||
if not media_id:
|
||||
return {'error': up.get('error', {}).get('message', 'media upload failed')}
|
||||
r = _meta_graph(cfg, 'POST', f"{cfg['phone_number_id']}/messages", json_payload={
|
||||
'messaging_product': 'whatsapp',
|
||||
'recipient_type': 'individual',
|
||||
'to': _meta_digits(phone),
|
||||
'type': 'document',
|
||||
'document': {'id': media_id, 'filename': filename, 'caption': caption or ''},
|
||||
})
|
||||
if r.get('error'):
|
||||
return {'error': r['error'].get('message', 'Meta error')}
|
||||
return {'ok': True}
|
||||
# Baileys bridge: endpoint genérico send-file (best effort)
|
||||
url = _get_url(cfg.get('bridge_url'))
|
||||
r = requests.post(
|
||||
f'{url}/send-file',
|
||||
headers=HEADERS,
|
||||
json={'phone': phone, 'base64': raw, 'filename': filename, 'caption': caption or ''},
|
||||
timeout=30,
|
||||
).json()
|
||||
return r if isinstance(r, dict) else {'ok': True}
|
||||
except Exception as e:
|
||||
return {'error': str(e)}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Proveedor WA CRM (self-hosted) — API pública /api/v1
|
||||
# cfg: provider='wacrm', api_base, api_key (+ public_base opcional)
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def _wacrm_is_wacrm(cfg):
|
||||
return str((cfg or {}).get('provider') or '').lower() == 'wacrm' and (cfg or {}).get('api_base') and (cfg or {}).get('api_key')
|
||||
|
||||
|
||||
def _wacrm_e164(phone):
|
||||
digits = ''.join(ch for ch in str(phone or '') if ch.isdigit())
|
||||
if not digits:
|
||||
return ''
|
||||
if digits.startswith('52'):
|
||||
return '+' + digits
|
||||
if len(digits) == 10:
|
||||
return '+52' + digits
|
||||
return '+' + digits
|
||||
|
||||
|
||||
def wacrm_send_text(cfg, phone, text):
|
||||
base = (cfg.get('api_base') or '').rstrip('/')
|
||||
try:
|
||||
r = requests.post(
|
||||
f'{base}/api/v1/messages',
|
||||
headers={'Authorization': f"Bearer {cfg.get('api_key')}", 'Content-Type': 'application/json'},
|
||||
json={'to': _wacrm_e164(phone), 'type': 'text', 'text': text},
|
||||
timeout=30,
|
||||
)
|
||||
data = r.json() if r.content else {}
|
||||
if r.status_code >= 400 or data.get('error'):
|
||||
return {'error': (data.get('error') or {}).get('message') or f'wacrm HTTP {r.status_code}'}
|
||||
return {'ok': True, 'id': (data.get('data') or {}).get('message_id')}
|
||||
except Exception as e:
|
||||
return {'error': str(e)}
|
||||
|
||||
|
||||
def wacrm_send_media(cfg, phone, kind, media_url, caption='', filename=None):
|
||||
base = (cfg.get('api_base') or '').rstrip('/')
|
||||
payload = {'to': _wacrm_e164(phone), 'type': kind, 'media_url': media_url, 'text': caption or ''}
|
||||
if filename:
|
||||
payload['filename'] = filename
|
||||
try:
|
||||
r = requests.post(
|
||||
f'{base}/api/v1/messages',
|
||||
headers={'Authorization': f"Bearer {cfg.get('api_key')}", 'Content-Type': 'application/json'},
|
||||
json=payload,
|
||||
timeout=45,
|
||||
)
|
||||
data = r.json() if r.content else {}
|
||||
if r.status_code >= 400 or data.get('error'):
|
||||
return {'error': (data.get('error') or {}).get('message') or f'wacrm HTTP {r.status_code}'}
|
||||
return {'ok': True, 'id': (data.get('data') or {}).get('message_id')}
|
||||
except Exception as e:
|
||||
return {'error': str(e)}
|
||||
|
||||
|
||||
def wacrm_get_status(cfg):
|
||||
base = (cfg.get('api_base') or '').rstrip('/')
|
||||
try:
|
||||
r = requests.get(f'{base}/api/v1/me',
|
||||
headers={'Authorization': f"Bearer {cfg.get('api_key')}"}, timeout=15)
|
||||
if r.status_code != 200:
|
||||
return {'state': 'error', 'error': f'HTTP {r.status_code}'}
|
||||
return {'state': 'connected', 'data': r.json().get('data', {})}
|
||||
except Exception as e:
|
||||
return {'state': 'error', 'error': str(e)}
|
||||
|
||||
336
pos/services/yonke_ai.py
Normal file
336
pos/services/yonke_ai.py
Normal file
@@ -0,0 +1,336 @@
|
||||
# /home/Autopartes/pos/services/yonke_ai.py
|
||||
"""Non-conversational AI helpers for the Nexus Yonke module.
|
||||
|
||||
Four capabilities, each one isolated and best-effort:
|
||||
|
||||
* ``identify_piece`` — vision: name + category of a photo
|
||||
* ``suggest_price`` — rule engine over cost/grade/category + local comparables
|
||||
* ``find_duplicate_groups`` — semantic grouping of used-piece names
|
||||
* ``validate_photo`` — vision: is it an auto part? is the photo usable?
|
||||
|
||||
Golden rule (same as ``semantic_search``): **no function raises**. When the AI
|
||||
provider is not configured or does not answer, each function returns a
|
||||
well-formed dict with ``available=False`` so the caller can keep going.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
import statistics
|
||||
|
||||
# Multipliers applied to a reference price depending on the piece grade.
|
||||
GRADE_FACTOR = {'A': 1.15, 'B': 1.0, 'C': 0.8, 'D': 0.6}
|
||||
# Target markup over acquisition cost for a grade-B used piece.
|
||||
TARGET_MARKUP = 1.6
|
||||
# Weight of the local market reference vs. the cost-based reference.
|
||||
MARKET_WEIGHT = 0.6
|
||||
|
||||
|
||||
def _coerce_float(val, default=0.0):
|
||||
try:
|
||||
return float(val)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _b64(image_bytes):
|
||||
return base64.b64encode(image_bytes).decode('ascii')
|
||||
|
||||
|
||||
def _extract_json(text):
|
||||
"""Return the first JSON object found in ``text``, or None."""
|
||||
if not text:
|
||||
return None
|
||||
stripped = str(text).strip()
|
||||
if stripped.startswith('```'):
|
||||
lines = stripped.split('\n')
|
||||
stripped = '\n'.join(lines[1:-1] if len(lines) > 2 else lines)
|
||||
try:
|
||||
parsed = json.loads(stripped)
|
||||
return parsed if isinstance(parsed, dict) else None
|
||||
except Exception:
|
||||
pass
|
||||
match = re.search(r'\{.*\}', stripped, re.DOTALL)
|
||||
if match:
|
||||
try:
|
||||
parsed = json.loads(match.group(0))
|
||||
return parsed if isinstance(parsed, dict) else None
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_confidence(value):
|
||||
conf = _coerce_float(value, 0.0)
|
||||
if conf > 1:
|
||||
conf = conf / 100.0
|
||||
return round(max(0.0, min(1.0, conf)), 2)
|
||||
|
||||
|
||||
def _vision_json(user_message, image_bytes=None, image_base64=None):
|
||||
"""Ask the vision model for a JSON object. Returns (dict|None, error|None)."""
|
||||
image = image_base64 or (_b64(image_bytes) if image_bytes else None)
|
||||
if not image:
|
||||
return None, 'no image'
|
||||
try:
|
||||
from services import ai_chat
|
||||
except Exception as e: # pragma: no cover
|
||||
return None, f'ai_chat unavailable: {e}'
|
||||
try:
|
||||
result = ai_chat.chat_with_image(user_message, image)
|
||||
except Exception as e:
|
||||
return None, str(e)
|
||||
if not isinstance(result, dict):
|
||||
return None, 'unexpected AI response'
|
||||
if 'name' in result or 'category' in result or 'is_part' in result:
|
||||
return result, None
|
||||
if result.get('message'):
|
||||
parsed = _extract_json(result['message'])
|
||||
if parsed:
|
||||
return parsed, None
|
||||
return None, result.get('message') or 'AI did not return JSON'
|
||||
|
||||
|
||||
def identify_piece(image_bytes=None, image_base64=None, hint_name=None):
|
||||
"""Identify an auto part from a photo.
|
||||
|
||||
Returns ``{name, category, confidence, available, source}``; ``available``
|
||||
is False when the provider is unreachable (the endpoint then answers 503).
|
||||
"""
|
||||
empty = {'name': None, 'category': None, 'confidence': 0.0,
|
||||
'available': False, 'source': 'vision'}
|
||||
if not image_bytes and not image_base64:
|
||||
return dict(empty, error='no image')
|
||||
|
||||
prompt = (
|
||||
'Eres un experto en autopartes (partes usadas de deshuesadero). '
|
||||
'Identifica la pieza de la imagen. Responde SOLO con JSON válido, sin texto extra: '
|
||||
'{"name": "nombre en español", "category": "categoría (Frenos, Motor, Eléctrico, '
|
||||
'Suspensión, Carrocería, Enfriamiento, Luces, Transmisión, etc.)", '
|
||||
'"confidence": 0.0}'
|
||||
)
|
||||
if hint_name:
|
||||
prompt += f' Pista del usuario: "{hint_name}".'
|
||||
|
||||
parsed, error = _vision_json(prompt, image_bytes, image_base64)
|
||||
if not parsed:
|
||||
return dict(empty, error=error or 'AI unavailable')
|
||||
|
||||
name = (parsed.get('name') or '').strip() or None
|
||||
category = (parsed.get('category') or '').strip() or None
|
||||
if isinstance(name, str) and name.lower() in ('null', 'none', 'desconocido'):
|
||||
name = None
|
||||
return {
|
||||
'name': name,
|
||||
'category': category,
|
||||
'confidence': _normalize_confidence(parsed.get('confidence')),
|
||||
'available': bool(name or category),
|
||||
'source': 'vision',
|
||||
}
|
||||
|
||||
|
||||
def validate_photo(image_bytes=None, image_base64=None):
|
||||
"""Validate that a photo shows an auto part with usable quality.
|
||||
|
||||
Returns ``{ok, issues, quality, available}``. When the provider is down the
|
||||
photo is NOT validated: ``available=False`` and ``ok=True`` (do not block).
|
||||
"""
|
||||
if not image_bytes and not image_base64:
|
||||
return {'ok': False, 'issues': ['No se recibió imagen'], 'available': False}
|
||||
|
||||
prompt = (
|
||||
'Evalúa esta foto destinada a un catálogo de autopartes usadas. Responde SOLO '
|
||||
'con JSON válido: {"is_part": true, "quality": "good", '
|
||||
'"issues": [], "confidence": 0.0}. '
|
||||
'"quality" debe ser "good", "fair" o "poor". En "issues" enumera problemas '
|
||||
'(borrosa, oscura, no se ve la pieza, fondo con ruido, etc.).'
|
||||
)
|
||||
parsed, error = _vision_json(prompt, image_bytes, image_base64)
|
||||
if not parsed:
|
||||
return {'ok': True, 'issues': [], 'available': False,
|
||||
'error': error or 'AI unavailable'}
|
||||
|
||||
is_part = parsed.get('is_part')
|
||||
if isinstance(is_part, str):
|
||||
is_part = is_part.strip().lower() in ('true', '1', 'yes', 'sí', 'si')
|
||||
quality = (parsed.get('quality') or '').strip().lower() or None
|
||||
issues = parsed.get('issues') or []
|
||||
if isinstance(issues, str):
|
||||
issues = [issues]
|
||||
issues = [str(i).strip() for i in issues if str(i).strip()]
|
||||
|
||||
if is_part is False:
|
||||
issues.append('No parece una autoparte')
|
||||
if quality == 'poor':
|
||||
issues.append('Calidad de imagen deficiente')
|
||||
|
||||
return {
|
||||
'ok': bool(is_part) and quality != 'poor',
|
||||
'issues': issues,
|
||||
'quality': quality,
|
||||
'confidence': _normalize_confidence(parsed.get('confidence')),
|
||||
'available': True,
|
||||
}
|
||||
|
||||
|
||||
def _local_comparables(conn, piece, limit=100):
|
||||
"""Median price_1 of active local items sharing category or name tokens."""
|
||||
name = (piece.get('name') or '').strip()
|
||||
tokens = [t for t in re.split(r'\W+', name.lower()) if len(t) >= 4][:3]
|
||||
patterns = [f'%{t}%' for t in tokens]
|
||||
category_id = piece.get('category_id')
|
||||
piece_id = piece.get('id') or 0
|
||||
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
if category_id is None:
|
||||
cur.execute("SELECT category_id FROM inventory WHERE id = %s", (piece_id,))
|
||||
row = cur.fetchone()
|
||||
category_id = row[0] if row else None
|
||||
cur.execute("""
|
||||
SELECT i.price_1
|
||||
FROM inventory i
|
||||
WHERE i.is_active = true AND i.price_1 > 0
|
||||
AND i.id <> %s
|
||||
AND (i.category_id = %s OR i.name ILIKE ANY(%s))
|
||||
LIMIT %s
|
||||
""", (piece_id, category_id, patterns, limit))
|
||||
prices = [float(r[0]) for r in cur.fetchall() if r[0] is not None]
|
||||
except Exception:
|
||||
try:
|
||||
conn.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
prices = []
|
||||
finally:
|
||||
cur.close()
|
||||
return prices
|
||||
|
||||
|
||||
def suggest_price(conn, piece):
|
||||
"""Suggest a sale price for a used piece.
|
||||
|
||||
Rule engine: local comparables (median price of active items in the same
|
||||
category / with a shared name token) scaled by grade, blended with a
|
||||
cost-based reference (acquisition cost × grade factor × target markup).
|
||||
|
||||
Returns ``{suggested_price, low, high, basis, available}``.
|
||||
"""
|
||||
piece = piece or {}
|
||||
grade = (piece.get('condition_grade') or 'B').upper()
|
||||
grade = grade if grade in GRADE_FACTOR else 'B'
|
||||
factor = GRADE_FACTOR[grade]
|
||||
|
||||
cost = _coerce_float(piece.get('acquisition_cost') or piece.get('cost') or 0)
|
||||
current_price = _coerce_float(piece.get('price_1') or 0)
|
||||
|
||||
comparables = _local_comparables(conn, piece)
|
||||
market_ref = statistics.median(comparables) if comparables else None
|
||||
market_price = market_ref * factor if market_ref else None
|
||||
cost_price = cost * factor * TARGET_MARKUP if cost > 0 else None
|
||||
|
||||
parts = []
|
||||
if market_price is not None:
|
||||
parts.append((MARKET_WEIGHT, market_price))
|
||||
parts.append((1 - MARKET_WEIGHT, cost_price if cost_price else market_price))
|
||||
elif cost_price is not None:
|
||||
parts = [(1.0, cost_price)]
|
||||
|
||||
if parts:
|
||||
total_w = sum(w for w, _ in parts)
|
||||
suggested = sum(w * v for w, v in parts) / total_w
|
||||
available = True
|
||||
else:
|
||||
suggested = current_price
|
||||
available = bool(current_price)
|
||||
|
||||
suggested = round(suggested, 2)
|
||||
low = round(suggested * 0.85, 2)
|
||||
high = round(suggested * 1.2, 2)
|
||||
|
||||
basis_bits = [f'grado {grade}', f'costo {cost:.2f}']
|
||||
if comparables:
|
||||
basis_bits.append(
|
||||
f'{len(comparables)} comparables locales (mediana {market_ref:.2f})'
|
||||
)
|
||||
else:
|
||||
basis_bits.append('sin comparables locales')
|
||||
if not available:
|
||||
basis_bits.append('sin costo ni comparables: se usó el precio actual')
|
||||
|
||||
return {
|
||||
'suggested_price': suggested,
|
||||
'low': low,
|
||||
'high': high,
|
||||
'basis': ', '.join(basis_bits),
|
||||
'available': available,
|
||||
'grade': grade,
|
||||
'comparables': len(comparables),
|
||||
}
|
||||
|
||||
|
||||
def find_duplicate_groups(conn, threshold=0.85, limit=300):
|
||||
"""Group used pieces whose names are semantically almost identical.
|
||||
|
||||
Uses ``semantic_search.embed`` (one call for every name) and cosine
|
||||
similarity, then union-find. Returns ``{'groups': [[{id,part_number,name,
|
||||
donor_vin}]]}``. Degrades to ``groups=[]`` + ``available=False`` when
|
||||
embeddings are not available.
|
||||
"""
|
||||
try:
|
||||
threshold = float(threshold)
|
||||
except (TypeError, ValueError):
|
||||
threshold = 0.85
|
||||
threshold = max(0.0, min(1.0, threshold))
|
||||
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT id, part_number, name, donor_vin
|
||||
FROM inventory
|
||||
WHERE is_active = true AND condition IN ('usado', 'reconstruido')
|
||||
AND name IS NOT NULL AND name <> ''
|
||||
ORDER BY id
|
||||
LIMIT %s
|
||||
""", (limit,))
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
|
||||
items = [{'id': r[0], 'part_number': r[1], 'name': r[2], 'donor_vin': r[3]}
|
||||
for r in rows]
|
||||
if len(items) < 2:
|
||||
return {'groups': [], 'available': True, 'threshold': threshold}
|
||||
|
||||
try:
|
||||
from services import semantic_search
|
||||
vectors = semantic_search.embed([it['name'] for it in items])
|
||||
except Exception:
|
||||
vectors = None
|
||||
if not vectors or len(vectors) != len(items):
|
||||
return {'groups': [], 'available': False, 'threshold': threshold,
|
||||
'error': 'embeddings no disponibles'}
|
||||
|
||||
parent = list(range(len(items)))
|
||||
|
||||
def find(x):
|
||||
while parent[x] != x:
|
||||
parent[x] = parent[parent[x]]
|
||||
x = parent[x]
|
||||
return x
|
||||
|
||||
def union(a, b):
|
||||
ra, rb = find(a), find(b)
|
||||
if ra != rb:
|
||||
parent[rb] = ra
|
||||
|
||||
for i in range(len(items)):
|
||||
for j in range(i + 1, len(items)):
|
||||
sim = semantic_search.cosine_similarity(vectors[i], vectors[j])
|
||||
if sim is not None and sim >= threshold:
|
||||
union(i, j)
|
||||
|
||||
grouped = {}
|
||||
for idx in range(len(items)):
|
||||
grouped.setdefault(find(idx), []).append(items[idx])
|
||||
groups = [g for g in grouped.values() if len(g) >= 2]
|
||||
groups.sort(key=len, reverse=True)
|
||||
return {'groups': groups, 'available': True, 'threshold': threshold}
|
||||
1698
pos/services/yonke_service.py
Normal file
1698
pos/services/yonke_service.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -44,7 +44,7 @@
|
||||
var roleLabels = {
|
||||
'owner': _t('role_owner'), 'admin': _t('role_admin'), 'cashier': _t('role_cashier'),
|
||||
'warehouse': _t('role_warehouse'), 'accountant': _t('role_accountant'),
|
||||
'workshop': 'Taller', 'mechanic': 'Mecanico'
|
||||
'workshop': 'Taller', 'mechanic': 'Mecanico', 'driver': 'Repartidor'
|
||||
};
|
||||
var roleLabel = roleLabels[role] || role;
|
||||
var initials = name.split(' ').map(function(p) { return p[0]; }).join('').toUpperCase().substring(0, 2);
|
||||
@@ -199,6 +199,8 @@
|
||||
homeHref = '/pos/sale';
|
||||
} else if (userRole === 'workshop' && userPerms.indexOf('workshop.view') !== -1 && moduleEnabled('workshop')) {
|
||||
homeHref = '/pos/workshop';
|
||||
} else if (userRole === 'driver' && userPerms.indexOf('pos.remission') !== -1) {
|
||||
homeHref = '/pos/remission-notes';
|
||||
}
|
||||
document.querySelectorAll('a[href="/pos/dashboard"]').forEach(function(a) {
|
||||
a.setAttribute('href', homeHref);
|
||||
@@ -208,8 +210,8 @@
|
||||
function isPageAllowed(pagePath, userRole, userPerms) {
|
||||
if (userRole === 'owner' || userRole === 'admin') return true;
|
||||
|
||||
// Restricted roles (workshop/mechanic/counter/cashier) see modules based on permissions.
|
||||
if (['workshop', 'mechanic', 'counter', 'cashier'].indexOf(userRole) !== -1) {
|
||||
// Restricted roles (workshop/mechanic/counter/cashier/driver) see modules based on permissions.
|
||||
if (['workshop', 'mechanic', 'counter', 'cashier', 'driver'].indexOf(userRole) !== -1) {
|
||||
var allowed = [];
|
||||
if ((userRole === 'workshop' || userRole === 'mechanic') && moduleEnabled('workshop')) {
|
||||
allowed = ['/pos/workshop'];
|
||||
@@ -226,7 +228,8 @@
|
||||
'quotations.view': '/pos/quotations',
|
||||
'accounting.view': '/pos/accounting',
|
||||
'reports.view': '/pos/reports',
|
||||
'dashboard.view': '/pos/dashboard'
|
||||
'dashboard.view': '/pos/dashboard',
|
||||
'yonke.view': '/pos/yonke'
|
||||
};
|
||||
for (var p in permMap) {
|
||||
if (userPerms.indexOf(p) !== -1 && allowed.indexOf(permMap[p]) === -1) {
|
||||
@@ -251,7 +254,7 @@
|
||||
var fallback = null;
|
||||
if (userRole === 'owner' || userRole === 'admin') {
|
||||
fallback = '/pos/dashboard';
|
||||
} else if (['workshop', 'mechanic', 'counter', 'cashier'].indexOf(userRole) !== -1) {
|
||||
} else if (['workshop', 'mechanic', 'counter', 'cashier', 'driver'].indexOf(userRole) !== -1) {
|
||||
var allowed = [];
|
||||
if ((userRole === 'workshop' || userRole === 'mechanic') && moduleEnabled('workshop')) {
|
||||
allowed = ['/pos/workshop'];
|
||||
@@ -268,7 +271,8 @@
|
||||
'quotations.view': '/pos/quotations',
|
||||
'accounting.view': '/pos/accounting',
|
||||
'reports.view': '/pos/reports',
|
||||
'dashboard.view': '/pos/dashboard'
|
||||
'dashboard.view': '/pos/dashboard',
|
||||
'yonke.view': '/pos/yonke'
|
||||
};
|
||||
for (var p in permMap) {
|
||||
if (userPerms.indexOf(p) !== -1 && allowed.indexOf(permMap[p]) === -1) {
|
||||
@@ -350,13 +354,18 @@
|
||||
backBtn.style.display = 'none';
|
||||
})();
|
||||
|
||||
// ─── Service Worker update handler ───
|
||||
// ─── Service Worker update handler (patrón controllerchange) ───
|
||||
// Recarga SOLO cuando el SW que controlaba esta página fue reemplazado
|
||||
// (deploy con assets nuevos). La primera activación (sin controlador
|
||||
// previo) NO recarga — así se elimina el doble refresh al cambiar de
|
||||
// pestaña tras cada deploy.
|
||||
var _hadSwController = !!navigator.serviceWorker.controller;
|
||||
var _swRefreshing = false;
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.addEventListener('message', function (event) {
|
||||
if (event.data && event.data.type === 'SW_UPDATED') {
|
||||
console.log('[AppInit] SW updated to', event.data.cacheName, '— reloading...');
|
||||
window.location.reload();
|
||||
}
|
||||
navigator.serviceWorker.addEventListener('controllerchange', function () {
|
||||
if (!_hadSwController || _swRefreshing) return;
|
||||
_swRefreshing = true;
|
||||
window.location.reload();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -320,7 +320,7 @@
|
||||
this.setSearch('');
|
||||
this.buildBreadcrumb();
|
||||
var self = this;
|
||||
fetch('/pos/api/catalog/categories?mye_id=' + encodeURIComponent(myeId) + '&mode=oem', { headers: this._headers() })
|
||||
fetch('/pos/api/catalog/categories?mye_id=' + encodeURIComponent(myeId) + '&mode=local', { headers: this._headers() })
|
||||
.then(function(r) {
|
||||
if (!self._checkAuth(r)) return null;
|
||||
return r.json();
|
||||
|
||||
@@ -112,7 +112,8 @@ const Config = (() => {
|
||||
warehouse: 'Almacenista',
|
||||
accountant: 'Contador',
|
||||
workshop: 'Taller',
|
||||
mechanic: 'Mecanico'
|
||||
mechanic: 'Mecanico',
|
||||
driver: 'Repartidor'
|
||||
};
|
||||
|
||||
var ROLE_BADGE = {
|
||||
@@ -123,7 +124,8 @@ const Config = (() => {
|
||||
warehouse: 'badge--yellow',
|
||||
accountant: 'badge--purple',
|
||||
workshop: 'badge--orange',
|
||||
mechanic: 'badge--teal'
|
||||
mechanic: 'badge--teal',
|
||||
driver: 'badge--gray'
|
||||
};
|
||||
|
||||
function roleBadge(role) {
|
||||
@@ -513,6 +515,15 @@ const Config = (() => {
|
||||
setChecked('receipt-show-iva', d.show_iva_breakdown);
|
||||
setChecked('receipt-show-payment', d.show_payment_details);
|
||||
setChecked('receipt-show-employee', d.show_employee);
|
||||
setChecked('receipt-auto-print', d.auto_print);
|
||||
fetch(API + '/invoice-settings', { headers: headers() }).then(function (r) { return r.json(); }).then(function (s) {
|
||||
var a = document.getElementById('cfg-invoice-auto');
|
||||
var b = document.getElementById('cfg-invoice-email');
|
||||
var c = document.getElementById('cfg-invoice-global');
|
||||
if (a) a.checked = !!s.invoice_auto;
|
||||
if (b) b.checked = !!s.invoice_email;
|
||||
if (c) c.checked = !!s.invoice_global;
|
||||
}).catch(function () {});
|
||||
renderReceiptLogoThumb();
|
||||
} catch (e) {
|
||||
console.error('Config.loadReceiptConfig:', e);
|
||||
@@ -599,13 +610,26 @@ const Config = (() => {
|
||||
show_iva_breakdown: getChecked('receipt-show-iva'),
|
||||
show_payment_details: getChecked('receipt-show-payment'),
|
||||
show_employee: getChecked('receipt-show-employee'),
|
||||
invoice_auto: document.getElementById('cfg-invoice-auto') ? document.getElementById('cfg-invoice-auto').checked : undefined,
|
||||
invoice_email: document.getElementById('cfg-invoice-email') ? document.getElementById('cfg-invoice-email').checked : undefined,
|
||||
invoice_global: document.getElementById('cfg-invoice-global') ? document.getElementById('cfg-invoice-global').checked : undefined,
|
||||
auto_print: getChecked('receipt-auto-print'),
|
||||
};
|
||||
try {
|
||||
var inv = {};
|
||||
['invoice_auto', 'invoice_email', 'invoice_global'].forEach(function (k) {
|
||||
if (data[k] !== undefined) { inv[k] = data[k]; delete data[k]; }
|
||||
});
|
||||
var res = await fetch(API + '/receipt', {
|
||||
method: 'PUT',
|
||||
headers: headers(),
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (Object.keys(inv).length) {
|
||||
await fetch(API + '/invoice-settings', {
|
||||
method: 'PUT', headers: headers(), body: JSON.stringify(inv),
|
||||
});
|
||||
}
|
||||
if (!res.ok) {
|
||||
var err = await res.json().catch(function() { return { error: res.statusText }; });
|
||||
throw new Error(err.error || 'Error al guardar');
|
||||
@@ -628,6 +652,7 @@ const Config = (() => {
|
||||
await saveAllowedBrands();
|
||||
await saveModules();
|
||||
await saveSalesSettings();
|
||||
await saveAlertContacts();
|
||||
await saveReceiptConfig();
|
||||
toast('Configuración guardada', 'ok');
|
||||
} catch (e) {
|
||||
@@ -842,6 +867,53 @@ const Config = (() => {
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Tier Discounts (Taller / Mayoreo)
|
||||
// -------------------------------------------------------------------------
|
||||
async function loadTierDiscounts() {
|
||||
try {
|
||||
var res = await fetch('/pos/api/inventory/tier-discounts', { headers: headers() });
|
||||
if (!res.ok) return;
|
||||
var d = await res.json();
|
||||
(d.data || []).forEach(function(t) {
|
||||
var el = document.getElementById('cfg-tier-disc-' + t.tier_id);
|
||||
if (el) el.value = t.discount_pct;
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Config.loadTierDiscounts:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveTierDiscounts() {
|
||||
var el2 = document.getElementById('cfg-tier-disc-2');
|
||||
var el3 = document.getElementById('cfg-tier-disc-3');
|
||||
var statusEl = document.getElementById('tier-discount-status');
|
||||
var btn = document.getElementById('btn-save-tier-discounts');
|
||||
if (!el2 || !el3) return;
|
||||
|
||||
var d2 = Math.max(0, Math.min(100, parseFloat(el2.value) || 0));
|
||||
var d3 = Math.max(0, Math.min(100, parseFloat(el3.value) || 0));
|
||||
|
||||
if (btn) { btn.disabled = true; btn.textContent = 'Guardando...'; }
|
||||
try {
|
||||
var res = await fetch('/pos/api/inventory/tier-discounts', {
|
||||
method: 'PUT',
|
||||
headers: headers(),
|
||||
body: JSON.stringify({ discount_pct_2: d2, discount_pct_3: d3 })
|
||||
});
|
||||
if (!res.ok) {
|
||||
var err = await res.json().catch(function() { return { error: res.statusText }; });
|
||||
throw new Error(err.error || 'Save failed');
|
||||
}
|
||||
toast('Descuentos por tipo de cliente guardados');
|
||||
if (statusEl) statusEl.textContent = 'Taller -' + d2 + '% · Mayoreo -' + d3 + '%';
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
} finally {
|
||||
if (btn) { btn.disabled = false; btn.textContent = 'Guardar Descuentos'; }
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Vehicle Compatibility Source
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -1059,6 +1131,50 @@ const Config = (() => {
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Alert contacts (per-business WhatsApp alerts)
|
||||
// -------------------------------------------------------------------------
|
||||
async function loadAlertContacts() {
|
||||
try {
|
||||
var res = await fetch(API + '/alert-contacts', { headers: headers() });
|
||||
if (!res.ok) return;
|
||||
var data = await res.json();
|
||||
var input = document.getElementById('alert-phones');
|
||||
if (input) input.value = (data.alert_phones || []).join(', ');
|
||||
var cb = document.getElementById('cfg-alert-whatsapp-enabled');
|
||||
if (cb) cb.checked = data.alert_whatsapp_enabled === true;
|
||||
} catch (e) {
|
||||
console.error('Config.loadAlertContacts:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAlertContacts() {
|
||||
if (!checkAuth()) return;
|
||||
var input = document.getElementById('alert-phones');
|
||||
var cb = document.getElementById('cfg-alert-whatsapp-enabled');
|
||||
if (!input && !cb) return;
|
||||
var btn = document.getElementById('btn-save-alert-contacts');
|
||||
if (btn) { btn.disabled = true; btn.textContent = 'Guardando...'; }
|
||||
try {
|
||||
var res = await fetch(API + '/alert-contacts', {
|
||||
method: 'PUT',
|
||||
headers: headers(),
|
||||
body: JSON.stringify({
|
||||
alert_phones: input ? input.value : '',
|
||||
alert_whatsapp_enabled: cb ? cb.checked : false,
|
||||
}),
|
||||
});
|
||||
var data = await res.json().catch(function() { return { error: res.statusText }; });
|
||||
if (!res.ok) throw new Error(data.error || 'Error al guardar');
|
||||
if (input) input.value = (data.alert_phones || []).join(', ');
|
||||
toast('Contactos de alerta guardados', 'ok');
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
} finally {
|
||||
if (btn) { btn.disabled = false; btn.textContent = 'Guardar alertas'; }
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Tab navigation
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -1293,6 +1409,12 @@ const Config = (() => {
|
||||
btnBrands.addEventListener('click', saveAllowedBrands);
|
||||
}
|
||||
|
||||
// Alert contacts save button (per-business WhatsApp alerts)
|
||||
var btnAlertContacts = document.getElementById('btn-save-alert-contacts');
|
||||
if (btnAlertContacts) {
|
||||
btnAlertContacts.addEventListener('click', saveAlertContacts);
|
||||
}
|
||||
|
||||
// Kiosk mode toggle
|
||||
var kioskToggle = document.getElementById('cfg-kiosk-mode');
|
||||
if (kioskToggle && window.NexusKiosk) {
|
||||
@@ -1322,7 +1444,9 @@ const Config = (() => {
|
||||
loadAllowedBrands();
|
||||
loadModules();
|
||||
loadSalesSettings();
|
||||
loadAlertContacts();
|
||||
loadReceiptConfig();
|
||||
loadTierDiscounts();
|
||||
if (isAdmin) {
|
||||
loadRolePermissions();
|
||||
loadWorkshopPermissions();
|
||||
@@ -1353,8 +1477,10 @@ const Config = (() => {
|
||||
deleteBranch,
|
||||
loadBusiness, saveBusiness, saveTaxParams, saveAll,
|
||||
loadCurrency, saveCurrency,
|
||||
loadTierDiscounts, saveTierDiscounts,
|
||||
loadVehicleCompatSource, saveVehicleCompatSource,
|
||||
loadModules, saveModules,
|
||||
loadAlertContacts, saveAlertContacts,
|
||||
loadReceiptConfig, saveReceiptConfig, handleReceiptLogo, removeReceiptLogo,
|
||||
openModal, closeModal, openBranchModal, editBranch,
|
||||
switchTab, loadRolePermissions, renderRolePermissions, saveRolePermissions,
|
||||
|
||||
@@ -30,6 +30,10 @@ const Customers = (() => {
|
||||
return '$' + n.toFixed(0);
|
||||
};
|
||||
|
||||
const esc = (s) => String(s == null ? '' : s)
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
|
||||
function headers() {
|
||||
return { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + getToken() };
|
||||
}
|
||||
@@ -281,6 +285,7 @@ const Customers = (() => {
|
||||
set('detailEmail', c.email);
|
||||
set('detailSince', formatDate(c.created_at));
|
||||
set('detailLastPurchase', formatDate(c.last_purchase));
|
||||
if (typeof loadCustomerCRM === 'function') loadCustomerCRM(c.id);
|
||||
|
||||
// Credit
|
||||
const limit = parseFloat(c.credit_limit || 0);
|
||||
@@ -733,6 +738,137 @@ const Customers = (() => {
|
||||
if (typeof openSlidePanel === 'function') openSlidePanel();
|
||||
}
|
||||
|
||||
|
||||
// ─── CRM & Loyalty ───────────────────────
|
||||
async function loadCustomerCRM(customerId) {
|
||||
try {
|
||||
const [loy, acts, analytics, creditScore] = await Promise.all([
|
||||
api(`/pos/api/customers/${customerId}/loyalty`),
|
||||
api(`/pos/api/customers/${customerId}/activities`).catch(() => ({ data: [] })),
|
||||
api(`/pos/api/customers/${customerId}/analytics`).catch(() => null),
|
||||
api(`/pos/api/customers/${customerId}/credit-score`).catch(() => null),
|
||||
]);
|
||||
const balEl = document.getElementById('crmLoyaltyBalance');
|
||||
if (balEl) balEl.textContent = (loy.balance != null ? loy.balance : 0);
|
||||
const actsEl = document.getElementById('crmActivities');
|
||||
if (actsEl) {
|
||||
const list = acts.data || acts.activities || [];
|
||||
if (!list.length) {
|
||||
actsEl.innerHTML = '<span style="color:var(--color-text-muted);font-size:var(--text-caption);">Sin actividad registrada</span>';
|
||||
} else {
|
||||
actsEl.innerHTML = list.slice(0, 20).map(a =>
|
||||
`<div style="border:1px solid var(--color-border);border-radius:var(--radius-md);padding:var(--space-2);">
|
||||
<div style="font-size:var(--text-caption);color:var(--color-text-muted);">${esc(a.created_at || '')} · ${esc(a.activity_type || '')}</div>
|
||||
<div style="font-size:var(--text-body-sm);">${esc(a.description || a.title || '')}</div>
|
||||
</div>`).join('');
|
||||
}
|
||||
}
|
||||
renderCustomerInsights(analytics, creditScore);
|
||||
} catch (e) {
|
||||
console.warn('CRM load failed:', e);
|
||||
}
|
||||
}
|
||||
window.loadCustomerCRM = loadCustomerCRM;
|
||||
|
||||
// Renders analytics (LTV, next purchase, churn) + credit risk score into
|
||||
// the CRM section of the customer detail panel. All values come from the
|
||||
// backend; missing data is shown as "—" rather than guessed.
|
||||
function renderCustomerInsights(analytics, creditScore) {
|
||||
const section = document.getElementById('crmSection');
|
||||
if (!section) return;
|
||||
let el = document.getElementById('crmInsights');
|
||||
if (!el) {
|
||||
el = document.createElement('div');
|
||||
el.id = 'crmInsights';
|
||||
el.style.cssText = 'display:grid;grid-template-columns:repeat(2,1fr);gap:var(--space-2);' +
|
||||
'margin-bottom:var(--space-3);';
|
||||
const actsEl = document.getElementById('crmActivities');
|
||||
section.insertBefore(el, actsEl || null);
|
||||
}
|
||||
if (!analytics && !creditScore) { el.innerHTML = ''; return; }
|
||||
|
||||
const card = (label, value, sub, color) =>
|
||||
`<div style="border:1px solid var(--color-border);border-radius:var(--radius-md);padding:var(--space-2);">
|
||||
<div style="font-size:var(--text-caption);color:var(--color-text-muted);text-transform:uppercase;letter-spacing:var(--tracking-wider);">${esc(label)}</div>
|
||||
<div style="font-size:var(--text-body-sm);font-weight:700;color:${color || 'var(--color-text-primary)'};">${value}</div>
|
||||
${sub ? `<div style="font-size:var(--text-caption);color:var(--color-text-muted);">${sub}</div>` : ''}
|
||||
</div>`;
|
||||
|
||||
const cards = [];
|
||||
const a = analytics || {};
|
||||
|
||||
cards.push(card('Valor de vida (LTV)', fmt(a.ltv),
|
||||
`${(a.total_orders || 0)} compras · ticket ${fmt(a.avg_order_value)}`));
|
||||
|
||||
cards.push(card('Intervalo de compra',
|
||||
(a.avg_days_between_purchases != null && a.avg_days_between_purchases > 0)
|
||||
? `${a.avg_days_between_purchases} días` : '—',
|
||||
(a.days_since_last_purchase != null)
|
||||
? `Última hace ${a.days_since_last_purchase} días` : 'Sin compras'));
|
||||
|
||||
let nextSub = '';
|
||||
if (a.days_until_next_purchase != null) {
|
||||
nextSub = a.days_until_next_purchase >= 0
|
||||
? `En ${a.days_until_next_purchase} días`
|
||||
: `Vencida hace ${Math.abs(a.days_until_next_purchase)} días`;
|
||||
}
|
||||
cards.push(card('Próxima compra probable',
|
||||
a.predicted_next_purchase_date ? formatDate(a.predicted_next_purchase_date) : '—',
|
||||
nextSub));
|
||||
|
||||
const riskColor = a.churn_risk === 'high' ? 'var(--color-error)'
|
||||
: a.churn_risk === 'medium' ? '#d97706' : 'var(--color-success, #16a34a)';
|
||||
const riskLabel = a.churn_risk === 'high' ? 'Alto' : a.churn_risk === 'medium' ? 'Medio' : 'Bajo';
|
||||
const probTxt = (a.churn_probability != null) ? ` (${Math.round(a.churn_probability * 100)}%)` : '';
|
||||
cards.push(card('Riesgo de abandono (churn)',
|
||||
`<span style="color:${riskColor};">${riskLabel}${probTxt}</span>`,
|
||||
esc(a.churn_reason || '')));
|
||||
|
||||
if (creditScore && creditScore.score != null) {
|
||||
const tierMap = { good: 'Bueno', medium: 'Medio', risk: 'Riesgo' };
|
||||
const tierColor = creditScore.tier === 'good' ? 'var(--color-success, #16a34a)'
|
||||
: creditScore.tier === 'medium' ? '#d97706' : 'var(--color-error)';
|
||||
const reasons = (creditScore.reasons || []).slice(0, 2).map(esc).join(' · ');
|
||||
cards.push(card('Score de crédito',
|
||||
`<span style="color:${tierColor};">${creditScore.score}/100 · ${esc(tierMap[creditScore.tier] || creditScore.tier)}</span>`,
|
||||
`Límite sugerido ${fmt(creditScore.recommended_limit)} · ${esc(creditScore.data_points)} señales` +
|
||||
(reasons ? `<br>${reasons}` : '')));
|
||||
}
|
||||
|
||||
el.innerHTML = cards.join('');
|
||||
}
|
||||
|
||||
async function crmRedeem() {
|
||||
if (!currentCustomer) return;
|
||||
const pts = prompt('Puntos a canjear:');
|
||||
if (!pts) return;
|
||||
try {
|
||||
await api(`/pos/api/customers/${currentCustomer.id}/loyalty/redeem`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ points: parseInt(pts, 10) }),
|
||||
});
|
||||
alert('Canje registrado');
|
||||
loadCustomerCRM(currentCustomer.id);
|
||||
} catch (e) {
|
||||
alert('Error: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function crmAddNote() {
|
||||
if (!currentCustomer) return;
|
||||
const note = prompt('Nota sobre el cliente:');
|
||||
if (!note) return;
|
||||
try {
|
||||
await api(`/pos/api/customers/${currentCustomer.id}/activities`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ activity_type: 'note', description: note }),
|
||||
});
|
||||
loadCustomerCRM(currentCustomer.id);
|
||||
} catch (e) {
|
||||
alert('Error: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Init ────────────────────────────
|
||||
function init() {
|
||||
// Auth check
|
||||
@@ -1011,6 +1147,8 @@ const Customers = (() => {
|
||||
}
|
||||
};
|
||||
|
||||
publicApi.crmRedeem = crmRedeem;
|
||||
publicApi.crmAddNote = crmAddNote;
|
||||
// Expose globally for inline HTML onclick handlers
|
||||
window.Customers = publicApi;
|
||||
return publicApi;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
497
pos/static/js/inventory.js.meli
Normal file
497
pos/static/js/inventory.js.meli
Normal file
@@ -0,0 +1,497 @@
|
||||
// ─── MercadoLibre Bulk Publish Modal ───────────────────────────────────
|
||||
|
||||
var meliPreviewData = {};
|
||||
var meliCategoryAttrs = [];
|
||||
var meliCategorySearchTimeout;
|
||||
var meliCatItems = [];
|
||||
var meliCatActiveIndex = -1;
|
||||
|
||||
window.openMeliPublishModal = function() {
|
||||
if (selectedItems.size === 0) { showToast('Selecciona al menos un producto', 'warn'); return; }
|
||||
document.getElementById('meliPublishModal').classList.add('is-open');
|
||||
document.getElementById('meliPublishResult').innerHTML = '';
|
||||
document.getElementById('meliCategoryId').value = '';
|
||||
document.getElementById('meliCategorySearch').value = '';
|
||||
document.getElementById('meliCategoryResults').innerHTML = '';
|
||||
document.getElementById('meliAttrsSection').style.display = 'none';
|
||||
document.getElementById('meliAttrsGrid').innerHTML = '';
|
||||
meliCategoryAttrs = [];
|
||||
refreshMeliPublishPreview();
|
||||
};
|
||||
|
||||
window.closeMeliPublishModal = function() {
|
||||
document.getElementById('meliPublishModal').classList.remove('is-open');
|
||||
};
|
||||
|
||||
// ==== RESTAURADAS v4 (MELI + descuentos) ====
|
||||
|
||||
window.searchMeliCategories = function() {
|
||||
var q = document.getElementById('meliCategorySearch').value.trim();
|
||||
var resultsDiv = document.getElementById('meliCategoryResults');
|
||||
if (q.length < 2) { resultsDiv.innerHTML = ''; meliCatItems = []; meliCatActiveIndex = -1; return; }
|
||||
clearTimeout(meliCategorySearchTimeout);
|
||||
resultsDiv.innerHTML = '<div class="meli-cat-dropdown"><div class="meli-cat-loading">Buscando...</div></div>';
|
||||
meliCategorySearchTimeout = setTimeout(function() {
|
||||
fetch('/pos/api/marketplace-ext/categories?q=' + encodeURIComponent(q), { headers: { 'Authorization': 'Bearer ' + getToken(), 'Content-Type': 'application/json' } })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
var cats = data.categories || [];
|
||||
meliCatItems = cats.slice(0, 10);
|
||||
meliCatActiveIndex = -1;
|
||||
if (!meliCatItems.length) { resultsDiv.innerHTML = '<div class="meli-cat-dropdown"><div class="meli-cat-empty">Sin resultados</div></div>'; return; }
|
||||
var html = '<div class="meli-cat-dropdown">';
|
||||
meliCatItems.forEach(function(c, idx) {
|
||||
html += '<div class="meli-cat-item" data-idx="' + idx + '" onmouseenter="highlightMeliCat(' + idx + ')" onmousedown="selectMeliCategoryIdx(' + idx + ')">' +
|
||||
'<span>' + esc(c.category_name || c.category_id) + '</span>' +
|
||||
'<span class="cat-id">' + esc(c.category_id) + '</span>' +
|
||||
'</div>';
|
||||
});
|
||||
html += '</div>';
|
||||
resultsDiv.innerHTML = html;
|
||||
})
|
||||
.catch(function() { resultsDiv.innerHTML = ''; meliCatItems = []; meliCatActiveIndex = -1; });
|
||||
}, 300);
|
||||
};
|
||||
|
||||
window.handleMeliCatKeydown = function(e) {
|
||||
if (!meliCatItems.length) return;
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
meliCatActiveIndex = Math.min(meliCatActiveIndex + 1, meliCatItems.length - 1);
|
||||
highlightMeliCat(meliCatActiveIndex);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
meliCatActiveIndex = Math.max(meliCatActiveIndex - 1, -1);
|
||||
highlightMeliCat(meliCatActiveIndex);
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (meliCatActiveIndex >= 0) selectMeliCategoryIdx(meliCatActiveIndex);
|
||||
} else if (e.key === 'Escape') {
|
||||
document.getElementById('meliCategoryResults').innerHTML = '';
|
||||
meliCatItems = [];
|
||||
meliCatActiveIndex = -1;
|
||||
}
|
||||
};
|
||||
|
||||
/* Cerrar dropdown al hacer click fuera */
|
||||
document.addEventListener('click', function(e) {
|
||||
var field = document.getElementById('meliCategorySearch');
|
||||
var results = document.getElementById('meliCategoryResults');
|
||||
if (field && results && !field.contains(e.target) && !results.contains(e.target)) {
|
||||
results.innerHTML = '';
|
||||
meliCatItems = [];
|
||||
meliCatActiveIndex = -1;
|
||||
}
|
||||
});
|
||||
|
||||
window.onMeliShippingChange = function() {
|
||||
var mode = document.getElementById('meliShippingMode').value;
|
||||
var costField = document.getElementById('meliShippingCostField');
|
||||
if (costField) costField.style.display = (mode === 'custom') ? 'block' : 'none';
|
||||
};
|
||||
|
||||
// ─── MELI publish helpers ──────────────────────────────────────────────
|
||||
// Contrato de `custom_data` esperado por validate_items / publish_items
|
||||
// (pos/services/marketplace_external_service.py):
|
||||
// {
|
||||
// titles: { "<inventory_id>": "Título (<=60)" },
|
||||
// prices: { "<inventory_id>": 123.45 },
|
||||
// stocks: { "<inventory_id>": 3 },
|
||||
// attributes: { "<inventory_id>": [{ id: "BRAND", value_name: "Nissan" }] },
|
||||
// shipping_cost: 150 // solo se usa si shipping_mode === 'custom'
|
||||
// }
|
||||
// Las claves son el id de inventario como string (el backend consulta con str(inv_id)).
|
||||
function _meliAttrValue(attr) {
|
||||
// Los atributos con lista cerrada se pintan como <select> + input "Otra (escribir)";
|
||||
// el resto como un input de texto libre.
|
||||
var selEl = document.getElementById('meliAttrSel-' + attr.id);
|
||||
if (selEl) {
|
||||
if (selEl.value === '__other__') {
|
||||
var otherEl = document.getElementById('meliAttrOther-' + attr.id);
|
||||
return otherEl ? otherEl.value.trim() : '';
|
||||
}
|
||||
return (selEl.value || '').trim();
|
||||
}
|
||||
var el = document.getElementById('meliAttr-' + attr.id);
|
||||
return el ? (el.value || '').trim() : '';
|
||||
}
|
||||
|
||||
function _collectMeliCustomData() {
|
||||
var ids = Array.from(selectedItems);
|
||||
var customData = { titles: {}, prices: {}, stocks: {}, attributes: {} };
|
||||
ids.forEach(function(id) {
|
||||
var titleEl = document.getElementById('meliTitle-' + id);
|
||||
var priceEl = document.getElementById('meliPrice-' + id);
|
||||
var stockEl = document.getElementById('meliStock-' + id);
|
||||
if (titleEl && titleEl.value.trim()) customData.titles[id] = titleEl.value.trim();
|
||||
if (priceEl) {
|
||||
var price = parseFloat(priceEl.value);
|
||||
if (!isNaN(price)) customData.prices[id] = price;
|
||||
}
|
||||
if (stockEl) {
|
||||
var stock = parseInt(stockEl.value, 10);
|
||||
if (!isNaN(stock)) customData.stocks[id] = stock;
|
||||
}
|
||||
var attrs = [];
|
||||
(meliCategoryAttrs || []).forEach(function(attr) {
|
||||
var value = _meliAttrValue(attr);
|
||||
if (attr.id && value) attrs.push({ id: attr.id, value_name: value });
|
||||
});
|
||||
if (attrs.length) customData.attributes[id] = attrs;
|
||||
});
|
||||
// El backend solo lo aplica con shipping_mode === 'custom'; si el campo no
|
||||
// existe o está vacío simplemente se omite.
|
||||
var shippingCostEl = document.getElementById('meliShippingCost');
|
||||
var shippingCost = shippingCostEl ? parseFloat(shippingCostEl.value) : NaN;
|
||||
if (!isNaN(shippingCost)) customData.shipping_cost = shippingCost;
|
||||
return customData;
|
||||
}
|
||||
|
||||
function _renderPublishResult(data, resultEl) {
|
||||
if (!resultEl) return;
|
||||
data = data || {};
|
||||
var successList = data.success || [];
|
||||
var failedList = data.failed || [];
|
||||
var html = '<div style="margin-bottom:var(--space-2);"><span style="color:var(--color-success);">✅ ' + successList.length + ' publicado(s)</span> · <span style="color:var(--color-error);">❌ ' + failedList.length + ' fallo(s)</span></div>';
|
||||
if (successList.length) {
|
||||
html += '<ul style="margin:0 0 var(--space-2);padding-left:var(--space-4);font-size:var(--text-caption);color:var(--color-text-secondary);">';
|
||||
successList.forEach(function(s) {
|
||||
var itemId = s.external_item_id || s.item_id;
|
||||
var label = 'Item #' + esc(s.inventory_id);
|
||||
if (itemId) {
|
||||
label += ' → <a href="' + esc(s.permalink || '#') + '" target="_blank" rel="noopener" style="color:var(--color-primary);">' + esc(itemId) + '</a>';
|
||||
}
|
||||
html += '<li>' + label + '</li>';
|
||||
});
|
||||
html += '</ul>';
|
||||
}
|
||||
if (failedList.length) {
|
||||
html += '<ul style="margin:0;padding-left:var(--space-4);font-size:var(--text-caption);color:var(--color-text-secondary);">';
|
||||
failedList.forEach(function(f) {
|
||||
html += '<li>Item #' + esc(f.inventory_id) + ': ' + esc(f.error) + '</li>';
|
||||
});
|
||||
html += '</ul>';
|
||||
}
|
||||
resultEl.innerHTML = html;
|
||||
if (successList.length > 0) {
|
||||
selectedItems.clear();
|
||||
updateSelectionUI();
|
||||
if (inventoryVS) inventoryVS.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
function _pollMeliAsync(taskId, resultEl, btn) {
|
||||
var attempts = 0;
|
||||
var maxAttempts = 60; // ~2 min (una consulta cada 2s)
|
||||
var interval = setInterval(function() {
|
||||
attempts++;
|
||||
fetch('/pos/api/marketplace-ext/listings/async/' + encodeURIComponent(taskId), { headers: { 'Authorization': 'Bearer ' + getToken() } })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.error) {
|
||||
clearInterval(interval);
|
||||
if (btn) btn.disabled = false;
|
||||
resultEl.innerHTML = '<span style="color:var(--color-error);">Error: ' + esc(data.error) + '</span>';
|
||||
return;
|
||||
}
|
||||
if (data.status === 'done') {
|
||||
clearInterval(interval);
|
||||
if (btn) btn.disabled = false;
|
||||
_renderPublishResult(data.result || {}, resultEl);
|
||||
setTimeout(function() { closeMeliPublishModal(); }, 3000);
|
||||
} else if (attempts >= maxAttempts) {
|
||||
clearInterval(interval);
|
||||
if (btn) btn.disabled = false;
|
||||
resultEl.innerHTML = '<span style="color:var(--color-error);">Timeout esperando resultado. Revisa la pestaña Publicaciones más tarde.</span>';
|
||||
} else {
|
||||
resultEl.innerHTML = '<span style="color:var(--color-text-muted);">Publicando en segundo plano... (' + (attempts * 2) + 's)</span>';
|
||||
}
|
||||
}).catch(function() {
|
||||
clearInterval(interval);
|
||||
if (btn) btn.disabled = false;
|
||||
resultEl.innerHTML = '<span style="color:var(--color-error);">Error consultando progreso</span>';
|
||||
});
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
window.validateMeliPublish = function() {
|
||||
var categoryId = document.getElementById('meliCategoryId').value.trim();
|
||||
if (!categoryId) { document.getElementById('meliPublishResult').innerHTML = '<span style="color:var(--color-error);">Selecciona una categoría de MercadoLibre</span>'; return; }
|
||||
var listingType = document.getElementById('meliListingType').value;
|
||||
var shippingMode = document.getElementById('meliShippingMode').value;
|
||||
var ids = Array.from(selectedItems);
|
||||
var resultEl = document.getElementById('meliPublishResult');
|
||||
var btn = document.getElementById('meliValidateBtn');
|
||||
btn.disabled = true;
|
||||
resultEl.innerHTML = '<span style="color:var(--color-text-muted);">Validando con MercadoLibre...</span>';
|
||||
fetch('/pos/api/marketplace-ext/listings/validate', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': 'Bearer ' + getToken(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
inventory_ids: ids,
|
||||
category_id: categoryId,
|
||||
listing_type: listingType,
|
||||
shipping_mode: shippingMode,
|
||||
custom_data: _collectMeliCustomData()
|
||||
})
|
||||
}).then(function(r){ return r.json(); })
|
||||
.then(function(data) {
|
||||
btn.disabled = false;
|
||||
if (data.error) { resultEl.innerHTML = '<span style="color:var(--color-error);">Error: ' + esc(data.error) + '</span>'; return; }
|
||||
var valid = (data.valid || []);
|
||||
var invalid = (data.invalid || []);
|
||||
var html = '<div style="margin-bottom:var(--space-2);"><span style="color:var(--color-success);">✅ ' + valid.length + ' válido(s)</span> · <span style="color:var(--color-error);">❌ ' + invalid.length + ' inválido(s)</span></div>';
|
||||
|
||||
// Show earnings estimate table for valid items
|
||||
if (valid.length > 0) {
|
||||
html += '<div style="margin:var(--space-3) 0;border:1px solid var(--color-border);border-radius:var(--radius-md);overflow:hidden;">';
|
||||
html += '<div style="background:var(--color-surface-2);padding:var(--space-2) var(--space-3);font-size:var(--text-caption);font-weight:600;color:var(--color-text-primary);">💰 Estimado de ganancia (después de comisiones ML)</div>';
|
||||
html += '<table style="width:100%;border-collapse:collapse;font-size:var(--text-caption);">';
|
||||
html += '<tr style="background:var(--color-surface-1);"><th style="padding:6px 8px;text-align:left;">Item</th><th style="padding:6px 8px;text-align:right;">Precio</th><th style="padding:6px 8px;text-align:right;">Comisión</th><th style="padding:6px 8px;text-align:right;">Recibes</th></tr>';
|
||||
valid.forEach(function(v) {
|
||||
var price = v.price || 0;
|
||||
var fee = v.fee_amount || 0;
|
||||
var net = v.net_amount || 0;
|
||||
var feePct = v.fee_pct || 0;
|
||||
html += '<tr style="border-top:1px solid var(--color-border);">';
|
||||
html += '<td style="padding:6px 8px;">#' + esc(v.inventory_id) + '</td>';
|
||||
html += '<td style="padding:6px 8px;text-align:right;">$' + price.toFixed(2) + '</td>';
|
||||
html += '<td style="padding:6px 8px;text-align:right;color:var(--color-error);">-$' + fee.toFixed(2) + ' (' + feePct.toFixed(1) + '%)</td>';
|
||||
html += '<td style="padding:6px 8px;text-align:right;color:var(--color-success);font-weight:600;">$' + net.toFixed(2) + '</td>';
|
||||
html += '</tr>';
|
||||
});
|
||||
html += '</table>';
|
||||
if (valid.some(function(v){ return v.fee_source === 'approximation'; })) {
|
||||
html += '<div style="padding:var(--space-2) var(--space-3);font-size:10px;color:var(--color-text-muted);background:var(--color-surface-0);">* Comisión estimada. El monto real puede variar según ML.</div>';
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
if (invalid.length) {
|
||||
html += '<ul style="margin:0;padding-left:var(--space-4);font-size:var(--text-caption);color:var(--color-text-secondary);">';
|
||||
invalid.forEach(function(f) {
|
||||
html += '<li>Item #' + esc(f.inventory_id) + ': ' + esc(f.error) + '</li>';
|
||||
});
|
||||
html += '</ul>';
|
||||
}
|
||||
resultEl.innerHTML = html;
|
||||
}).catch(function(e) { btn.disabled = false; resultEl.innerHTML = '<span style="color:var(--color-error);">Error: ' + esc(e.message) + '</span>'; });
|
||||
};
|
||||
|
||||
window.executeMeliPublish = function() {
|
||||
var categoryId = document.getElementById('meliCategoryId').value.trim();
|
||||
if (!categoryId) { document.getElementById('meliPublishResult').innerHTML = '<span style="color:var(--color-error);">Selecciona una categoría de MercadoLibre</span>'; return; }
|
||||
var listingType = document.getElementById('meliListingType').value;
|
||||
var shippingMode = document.getElementById('meliShippingMode').value;
|
||||
var ids = Array.from(selectedItems);
|
||||
var resultEl = document.getElementById('meliPublishResult');
|
||||
var btn = document.getElementById('meliPublishBtn');
|
||||
btn.disabled = true;
|
||||
var useAsync = ids.length > 3;
|
||||
var endpoint = useAsync ? '/pos/api/marketplace-ext/listings/async' : '/pos/api/marketplace-ext/listings';
|
||||
resultEl.innerHTML = '<span style="color:var(--color-text-muted);">' + (useAsync ? 'Encolando ' : 'Publicando ') + ids.length + ' producto(s)...</span>';
|
||||
fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': 'Bearer ' + getToken(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
inventory_ids: ids,
|
||||
category_id: categoryId,
|
||||
listing_type: listingType,
|
||||
shipping_mode: shippingMode,
|
||||
custom_data: _collectMeliCustomData()
|
||||
})
|
||||
}).then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.error) { btn.disabled = false; resultEl.innerHTML = '<span style="color:var(--color-error);">Error: ' + esc(data.error) + '</span>'; return; }
|
||||
if (useAsync && data.task_id) {
|
||||
_pollMeliAsync(data.task_id, resultEl, btn);
|
||||
} else {
|
||||
btn.disabled = false;
|
||||
_renderPublishResult(data, resultEl);
|
||||
if ((data.success || []).length > 0) {
|
||||
setTimeout(function() { closeMeliPublishModal(); }, 2500);
|
||||
}
|
||||
}
|
||||
}).catch(function(e) { btn.disabled = false; resultEl.innerHTML = '<span style="color:var(--color-error);">Error: ' + esc(e.message) + '</span>'; });
|
||||
};
|
||||
|
||||
function refreshMeliPublishPreview() {
|
||||
var container = document.getElementById('meliPublishItemsPreview');
|
||||
var countEl = document.getElementById('meliPublishSelectedCount');
|
||||
countEl.textContent = selectedItems.size + ' producto(s) seleccionado(s)';
|
||||
container.innerHTML = '<p style="color:var(--color-text-muted);font-size:var(--text-caption);">Cargando verificaciones...</p>';
|
||||
|
||||
var ids = Array.from(selectedItems);
|
||||
fetch('/pos/api/marketplace-ext/inventory-check', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': 'Bearer ' + getToken(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ inventory_ids: ids })
|
||||
}).then(function(r){ return r.json(); })
|
||||
.then(function(data) {
|
||||
if (!data.items) { container.innerHTML = '<p style="color:var(--color-error);">Error cargando preview</p>'; return; }
|
||||
var html = '';
|
||||
data.items.forEach(function(it) {
|
||||
if (!it.exists) {
|
||||
html += '<div class="meli-preview-card" style="opacity:0.6;"><div style="color:var(--color-error);">Item #' + it.inventory_id + ' no encontrado</div></div>';
|
||||
return;
|
||||
}
|
||||
meliPreviewData[it.inventory_id] = it;
|
||||
var checks = '';
|
||||
checks += '<span class="meli-check ' + (it.has_image ? 'ok' : 'fail') + '">' + (it.has_image ? '✅' : '❌') + ' Imagen</span>';
|
||||
checks += '<span class="meli-check ' + (it.has_stock ? 'ok' : 'fail') + '">' + (it.has_stock ? '✅' : '❌') + ' Stock</span>';
|
||||
checks += '<span class="meli-check ' + (it.has_price ? 'ok' : 'fail') + '">' + (it.has_price ? '✅' : '❌') + ' Precio</span>';
|
||||
if (it.already_published) {
|
||||
checks += '<span class="meli-check ok">✅ <a href="' + esc(it.existing_listing.permalink || '#') + '" target="_blank" style="color:var(--color-success);text-decoration:underline;">Ya publicado (' + esc(it.existing_listing.status) + ')</a></span>';
|
||||
}
|
||||
var imgSrc = it.image_url || '';
|
||||
var imgHtml = imgSrc ? '<img src="' + esc(imgSrc) + '" alt="" onerror="this.style.display=\'none\'">' : '<div style="width:56px;height:56px;background:var(--color-surface-2);border-radius:var(--radius-sm);display:flex;align-items:center;justify-content:center;font-size:10px;color:var(--color-text-muted);">Sin img</div>';
|
||||
if (!it.has_image) {
|
||||
imgHtml = '<div class="meli-img-upload" onclick="document.getElementById(\'meliImgUpload-' + it.inventory_id + '\').click()">' +
|
||||
'<div style="font-size:10px;">+ Subir</div>' +
|
||||
'<input type="file" id="meliImgUpload-' + it.inventory_id + '" accept="image/*" onchange="handleMeliImageUpload(' + it.inventory_id + ', this)">' +
|
||||
'</div>';
|
||||
}
|
||||
html += '<div class="meli-preview-card" id="meliCard-' + it.inventory_id + '">' +
|
||||
imgHtml +
|
||||
'<div>' +
|
||||
'<input type="text" class="meli-title-input" id="meliTitle-' + it.inventory_id + '" value="' + esc(it.title) + '" maxlength="60" oninput="updateMeliTitleCount(' + it.inventory_id + ')">' +
|
||||
'<div style="font-size:10px;color:var(--color-text-muted);text-align:right;" id="meliTitleCount-' + it.inventory_id + '">' + it.title.length + '/60</div>' +
|
||||
'<div class="meli-checks-row">' + checks + '</div>' +
|
||||
'</div>' +
|
||||
'<div><label style="font-size:10px;color:var(--color-text-muted);">Precio</label><input type="number" class="meli-num-input" id="meliPrice-' + it.inventory_id + '" value="' + it.price + '" oninput="scheduleMeliFeeEstimates()"></div>' +
|
||||
'<div><label style="font-size:10px;color:var(--color-text-muted);">Stock</label><input type="number" class="meli-num-input" id="meliStock-' + it.inventory_id + '" value="' + it.stock + '"></div>' +
|
||||
'<div id="meliFee-' + it.inventory_id + '" style="font-size:10px;color:var(--color-text-muted);min-width:130px;line-height:1.35;"></div>' +
|
||||
'</div>';
|
||||
});
|
||||
container.innerHTML = html;
|
||||
scheduleMeliFeeEstimates();
|
||||
}).catch(function() { container.innerHTML = '<p style="color:var(--color-error);">Error de red</p>'; });
|
||||
}
|
||||
|
||||
window.selectMeliCategory = function(id, name) {
|
||||
document.getElementById('meliCategoryId').value = id;
|
||||
document.getElementById('meliCategorySearch').value = name;
|
||||
document.getElementById('meliCategoryResults').innerHTML = '';
|
||||
meliCatItems = [];
|
||||
meliCatActiveIndex = -1;
|
||||
loadCategoryAttributes(id);
|
||||
scheduleMeliFeeEstimates();
|
||||
};
|
||||
|
||||
window.selectMeliCategoryIdx = function(idx) {
|
||||
var c = meliCatItems[idx];
|
||||
if (!c) return;
|
||||
selectMeliCategory(c.category_id, c.category_name || c.category_id);
|
||||
};
|
||||
|
||||
window.highlightMeliCat = function(idx) {
|
||||
meliCatActiveIndex = idx;
|
||||
var items = document.querySelectorAll('.meli-cat-item');
|
||||
items.forEach(function(el, i) { el.classList.toggle('is-active', i === idx); });
|
||||
};
|
||||
|
||||
window.loadCategoryAttributes = function(categoryId) {
|
||||
var grid = document.getElementById('meliAttrsGrid');
|
||||
var section = document.getElementById('meliAttrsSection');
|
||||
grid.innerHTML = '<p style="color:var(--color-text-muted);font-size:var(--text-caption);">Cargando atributos...</p>';
|
||||
section.style.display = 'block';
|
||||
fetch('/pos/api/marketplace-ext/categories/' + encodeURIComponent(categoryId) + '/attributes', { headers: { 'Authorization': 'Bearer ' + getToken() } })
|
||||
.then(function(r){ return r.json(); })
|
||||
.then(function(data) {
|
||||
meliCategoryAttrs = data.attributes || [];
|
||||
if (!meliCategoryAttrs.length) { grid.innerHTML = '<p style="color:var(--color-text-muted);font-size:var(--text-caption);">No hay atributos obligatorios adicionales.</p>'; return; }
|
||||
var html = '';
|
||||
meliCategoryAttrs.forEach(function(attr) {
|
||||
var attrId = esc(attr.id);
|
||||
var attrName = esc(attr.name || attr.id);
|
||||
var inputHtml = '<input type="text" class="meli-title-input" id="meliAttr-' + attrId + '" placeholder="' + attrName + '">';
|
||||
if (attr.values && attr.values.length) {
|
||||
// Some ML attributes (like BRAND) have a closed list but the API still
|
||||
// accepts free-text via value_name. Provide a select + "Other" fallback.
|
||||
var selectId = 'meliAttrSel-' + attrId;
|
||||
var otherId = 'meliAttrOther-' + attrId;
|
||||
inputHtml = '<select class="meli-title-input" id="' + selectId + '" onchange="onMeliAttrSelectChange(\'' + attrId + '\')">' +
|
||||
'<option value="">Selecciona ' + attrName + '</option>' +
|
||||
attr.values.map(function(v) { return '<option value="' + esc(v.name) + '">' + esc(v.name) + '</option>'; }).join('') +
|
||||
'<option value="__other__">Otra marca (escribir)...</option>' +
|
||||
'</select>' +
|
||||
'<input type="text" class="meli-title-input" id="' + otherId + '" placeholder="Escribe la ' + attrName + '" style="display:none;margin-top:6px;">';
|
||||
}
|
||||
html += '<div class="inv-field" id="meliAttrWrap-' + attrId + '"><label>' + attrName + (attr.tags && attr.tags.required ? ' *' : '') + '</label>' + inputHtml + '</div>';
|
||||
});
|
||||
grid.innerHTML = html;
|
||||
}).catch(function() { grid.innerHTML = '<p style="color:var(--color-error);font-size:var(--text-caption);">Error cargando atributos</p>'; });
|
||||
};
|
||||
|
||||
window.updateMeliTitleCount = function(id) {
|
||||
var el = document.getElementById('meliTitle-' + id);
|
||||
var countEl = document.getElementById('meliTitleCount-' + id);
|
||||
if (el && countEl) countEl.textContent = el.value.length + '/60';
|
||||
};
|
||||
|
||||
window.handleMeliImageUpload = function(itemId, input) {
|
||||
if (!input.files || !input.files[0]) return;
|
||||
var file = input.files[0];
|
||||
var formData = new FormData();
|
||||
formData.append('file', file);
|
||||
fetch('/pos/api/inventory/items/' + itemId + '/image', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': 'Bearer ' + getToken() },
|
||||
body: formData
|
||||
}).then(function(r){ return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.image_url) {
|
||||
showToast('Imagen subida', 'success');
|
||||
refreshMeliPublishPreview();
|
||||
if (inventoryVS) inventoryVS.refresh();
|
||||
} else {
|
||||
showToast(data.error || 'Error subiendo imagen', 'error');
|
||||
}
|
||||
}).catch(function(){ showToast('Error de red', 'error'); });
|
||||
};
|
||||
|
||||
// ─── Estimador de comisiones MeLi (por artículo) ───
|
||||
var meliFeeTimeout;
|
||||
function fmtMoney(n) {
|
||||
return '$' + (parseFloat(n) || 0).toLocaleString('es-MX', {minimumFractionDigits: 2, maximumFractionDigits: 2});
|
||||
}
|
||||
function updateMeliFeeEstimates() {
|
||||
var categoryId = (document.getElementById('meliCategoryId') || {}).value;
|
||||
var listingType = (document.getElementById('meliListingType') || {}).value || 'gold_special';
|
||||
var ids = Object.keys(window.meliPreviewData || (typeof meliPreviewData !== 'undefined' ? meliPreviewData : {}));
|
||||
if (!ids.length) return;
|
||||
ids.forEach(function (id) {
|
||||
var feeEl = document.getElementById('meliFee-' + id);
|
||||
if (!feeEl) return;
|
||||
if (!categoryId) { feeEl.innerHTML = ''; return; }
|
||||
var priceEl = document.getElementById('meliPrice-' + id);
|
||||
var price = parseFloat(priceEl && priceEl.value) || 0;
|
||||
if (price <= 0) { feeEl.innerHTML = '<span style="color:var(--color-text-muted);">Ingresa un precio para estimar comisiones</span>'; return; }
|
||||
feeEl.innerHTML = '<span style="color:var(--color-text-muted);">Calculando comisiones...</span>';
|
||||
fetch('/pos/api/marketplace-ext/listing-prices?price=' + encodeURIComponent(price) + '&category_id=' + encodeURIComponent(categoryId), { headers: { 'Authorization': 'Bearer ' + getToken() } })
|
||||
.then(function (r) { return r.json().then(function (d) { return {ok: r.ok, data: d}; }); })
|
||||
.then(function (res) {
|
||||
if (!res.ok || res.data.error) { feeEl.innerHTML = '<span style="color:var(--color-warning);">No se pudo estimar: ' + esc(res.data.error || 'error') + '</span>'; return; }
|
||||
var offers = res.data.listing_prices || [];
|
||||
var offer = null;
|
||||
offers.forEach(function (o) { if (o.listing_type_id === listingType) offer = o; });
|
||||
if (!offer && offers.length) offer = offers[0];
|
||||
if (!offer || !offer.sale_fee_details) { feeEl.innerHTML = ''; return; }
|
||||
var fee = offer.sale_fee_details.gross_amount || 0;
|
||||
var pct = offer.sale_fee_details.percentage_fee || 0;
|
||||
var net = price - fee;
|
||||
feeEl.innerHTML = '💰 Comisión MeLi: <strong style="color:var(--color-warning);">' + fmtMoney(fee) + ' (' + pct + '%)</strong>'
|
||||
+ ' · Recibes: <strong style="color:' + (net > 0 ? 'var(--color-success)' : 'var(--color-error)') + ';">' + fmtMoney(net) + '</strong>'
|
||||
+ ' <span style="opacity:.65;">(estimado, sin costo de envío)</span>';
|
||||
})
|
||||
.catch(function () { feeEl.innerHTML = ''; });
|
||||
});
|
||||
}
|
||||
function scheduleMeliFeeEstimates() {
|
||||
clearTimeout(meliFeeTimeout);
|
||||
meliFeeTimeout = setTimeout(updateMeliFeeEstimates, 500);
|
||||
}
|
||||
window.scheduleMeliFeeEstimates = scheduleMeliFeeEstimates;
|
||||
|
||||
// =====================================================================
|
||||
// EXPOSE GLOBALS (for onclick handlers in HTML)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user