Mejoras de sesion: estabilizacion, IA, Nexus Yonke y marketplace B2B
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled

- 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:
2026-10-12 00:57:31 +00:00
parent f2d4558b80
commit a386ca5b4a
165 changed files with 31313 additions and 2203 deletions

23
audit_dash.js Normal file
View 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
View 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
View 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
View 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();
})();

View File

@@ -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">&larr; Inicio</a>
<button class="theme-toggle" onclick="toggleTheme()" id="themeToggle">
<span id="themeIcon">&#9790;</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">

View File

@@ -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 ──

View File

@@ -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) {

File diff suppressed because it is too large Load Diff

View File

@@ -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:

View File

@@ -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:

View File

@@ -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": []

View File

@@ -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']

View File

@@ -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
View 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
View 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.

View 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.

View 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.

View 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.

View File

@@ -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

View File

@@ -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")

View 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))

View 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)",
}

View 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

View File

@@ -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):

View File

@@ -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");

View File

@@ -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">

View File

@@ -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 (

View File

@@ -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'])

View File

@@ -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
View 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()

View File

@@ -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)

View File

@@ -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:

View File

@@ -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',
})

View File

@@ -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({

View File

@@ -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')

View File

@@ -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():

View File

@@ -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 ────────────────────────────────────

View File

@@ -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)

View File

@@ -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 = """

View File

@@ -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()

View File

@@ -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':

View 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.',
})

View File

@@ -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 %} &middot; RFC {{ business.rfc }}{% endif %}</p>
</div>
<div class="body">
<div class="meta">
<div><strong>Folio</strong>G-{{ w.id }}</div>
<div><strong>Estado</strong><span class="badge badge-{{ w.status }}">{{ w.status }}</span></div>
<div><strong>Cliente</strong>{{ w.customer_name or 'Publico general' }}</div>
<div><strong>Venta</strong>#{{ w.sale_id or '—' }}</div>
<div><strong>Pieza</strong>{{ w.name or '—' }}</div>
<div><strong>No. parte</strong>{{ w.part_number or '—' }}</div>
<div><strong>Inicio</strong>{{ w.start_date }}</div>
<div><strong>Vence</strong>{{ w.end_date }}</div>
<div><strong>Cobertura</strong>{{ coverage }}</div>
</div>
<div class="terms">
<p>Esta garantia ampara defectos de funcionamiento de la pieza por {{ coverage }} a partir de
la fecha de compra. Presente este comprobante junto con la pieza para cualquier reclamacion.</p>
{% if w.notes %}<p>Notas: {{ w.notes }}</p>{% endif %}
{% if business.address %}<p>{{ business.address }}</p>{% endif %}
{% if business.phone %}<p>Tel: {{ business.phone }}</p>{% endif %}
</div>
</div>
<div class="actions">
<button class="btn" onclick="window.print()">Imprimir</button>
</div>
<div class="footer">{{ business.name }} — Comprobante generado el {{ w.created_at[:10] }}</div>
</div>
</body>
</html>
"""

View File

@@ -34,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
View 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()

View File

@@ -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', '')

View File

@@ -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()

View File

@@ -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",
}

View File

@@ -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.

View File

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

View File

@@ -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) ────────────

View File

@@ -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$;

View 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;

View 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);

View 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;

View 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);

View 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;

View File

@@ -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)

View File

@@ -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').

View File

@@ -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

View 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
View 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,
}

View File

@@ -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):

View File

@@ -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

View File

@@ -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))

View File

@@ -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

View File

@@ -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(
{

View 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(),
}

View File

@@ -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
View 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)]

View File

@@ -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:

View File

@@ -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
View 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

View File

@@ -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

View File

@@ -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")

View File

@@ -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
View 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

View File

@@ -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:

View 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())),
}

View 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()

View File

@@ -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
View 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
View 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)

View 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
View 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()

View File

@@ -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})

View File

@@ -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:

View File

@@ -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 ────────────────────────────

View File

@@ -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

View File

@@ -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],
}

View File

@@ -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
View 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}

File diff suppressed because it is too large Load Diff

View File

@@ -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();
});
}

View File

@@ -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();

View File

@@ -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,

View File

@@ -30,6 +30,10 @@ const Customers = (() => {
return '$' + n.toFixed(0);
};
const esc = (s) => String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
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

View 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